Files
doos/docs/charts/make_charts.py
T

293 lines
15 KiB
Python

#!/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 <!-- bench:NAME:begin --> and <!-- bench:NAME:end --> in the markdown documents
and inline values written as <!--bench:KEY-->text<!--/bench-->
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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" role="img" '
f'aria-labelledby="t d" style="max-width:100%;height:auto">\n<title id="t">{escape(title)}</title>\n'
f'<desc id="d">{escape(desc)}</desc>\n<style>{STYLE}</style>\n'
f'<rect class="bg" x="0" y="0" width="{w}" height="{h}" rx="8"/>\n')
def legend(x, y, items):
out, cx = [], x
for cls, text in items:
out.append(f'<rect class="{cls}" x="{cx}" y="{y - 9}" width="12" height="12" rx="2"/>')
out.append(f'<text class="t2" x="{cx + 18}" y="{y + 1}">{escape(text)}</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'<text class="t1" x="{left - 190 + 12}" y="{y}">{name}</text>')
y += 14
y0 = y
for t in range(0, axis_max + 1, step):
x = left + t * scale
body.append(f'<line class="grid" x1="{x:.1f}" y1="{y0 - 2}" x2="{x:.1f}" y2="{y0 + len(grp) * ROW}"/>')
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'<text class="lab" x="{left - 10}" y="{by + BAR_H / 2 + 4:.1f}" text-anchor="end">{escape(r["label"])}</text>')
body.append(f'<path class="{cls}" d="{bar_path(left, by, w, BAR_H)}"><title>{escape(r["label"])}: {fmt_ms(r["ms"])}</title></path>')
body.append(f'<text class="val" x="{left + w + 8:.1f}" y="{by + BAR_H / 2 + 4:.1f}">{fmt_ms(r["ms"])}</text>')
y += ROW
body.append(f'<line class="axis" x1="{left}" y1="{y0 - 2}" x2="{left}" y2="{y}"/>')
for t in range(0, axis_max + 1, step):
body.append(f'<text class="tick" x="{left + t * scale:.1f}" y="{y + 14}" text-anchor="middle">{t}</text>')
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 += '<text class="t1" x="20" y="28">Time to start a trivial command (ms, lower is better)</text>\n'
out += (f'<text class="t2" x="20" y="46">{m["runs"]} runs each, average, warm cache, {m["date"]}, kernel {escape(m["kernel"])}, '
f'{m["cpus"]} cores. Docker is a rootful daemon.</text>\n')
out += legend(20, 64, [("acc", "doos"), ("oth", "other tools")]) + "\n"
out += "\n".join(body) + "\n</svg>\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'<line class="grid" x1="{x:.1f}" y1="{y0 - 2}" x2="{x:.1f}" y2="{y0 + len(rows) * ROW}"/>')
body.append(f'<text class="tick" x="{x:.1f}" y="{y0 + len(rows) * ROW + 16}" text-anchor="middle">{lab}</text>')
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'<text class="lab" x="{left - 10}" y="{by + BAR_H / 2 + 4:.1f}" text-anchor="end">{names.get(r["id"], r["id"])}</text>')
body.append(f'<path class="{cls}" d="{bar_path(left, by, w, BAR_H)}"><title>{names.get(r["id"], r["id"])}: {fmt_bytes(r["bytes"])}</title></path>')
body.append(f'<text class="val" x="{left + w + 8:.1f}" y="{by + BAR_H / 2 + 4:.1f}">{fmt_bytes(r["bytes"])}</text>')
y += ROW
body.append(f'<line class="axis" x1="{left}" y1="{y0 - 2}" x2="{left}" y2="{y}"/>')
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 += '<text class="t1" x="20" y="28">Size of the main executable (log scale, smaller is smaller)</text>\n'
out += '<text class="t2" x="20" y="46">The docker CLI excludes dockerd and containerd.</text>\n'
out += '<text class="t2" x="20" y="62">doos excludes the helpers it calls (slirp4netns, pasta, newuidmap, curl, tar).</text>\n'
out += legend(20, 82, [("acc", "doos"), ("oth", "other tools")]) + "\n"
out += "\n".join(body) + f'\n<text class="t2" x="{left}" y="{y + 38}">Axis is logarithmic: each gridline is 10x the previous one.</text>\n</svg>\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'<line class="grid" x1="{x:.1f}" y1="{y0 - 2}" x2="{x:.1f}" y2="{y0 + len(rows) * ROW}"/>')
body.append(f'<text class="tick" x="{x:.1f}" y="{y0 + len(rows) * ROW + 16}" text-anchor="middle">{t}</text>')
y = y0
for lab, v, cls in rows:
w = v * scale
by = y + (ROW - BAR_H) / 2
body.append(f'<text class="lab" x="{left - 10}" y="{by + BAR_H / 2 + 4:.1f}" text-anchor="end">{lab}</text>')
body.append(f'<path class="{cls}" d="{bar_path(left, by, w, BAR_H)}"><title>{lab}: {v:.1f} CPU-seconds</title></path>')
body.append(f'<text class="val" x="{left + w + 8:.1f}" y="{by + BAR_H / 2 + 4:.1f}">{v:.1f} CPU-s</text>')
y += ROW
body.append(f'<line class="axis" x1="{left}" y1="{y0 - 2}" x2="{left}" y2="{y}"/>')
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 += '<text class="t1" x="20" y="28">CPU time used by 4 busy loops in one container (CPU-seconds)</text>\n'
out += (f'<text class="t2" x="20" y="46">{c["wall_seconds"]} s of wall time. Half a CPU allows about {ideal:.1f} CPU-seconds; '
f'measured {c["half_cpu"]:.1f}.</text>\n')
out += legend(20, 64, [("oth", "no limit"), ("acc", "--cpus 0.5")]) + "\n"
out += "\n".join(body) + "\n</svg>\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"(<!-- bench:{name}:begin -->\n).*?(\n<!-- bench:{name}:end -->)", re.S)
s = pat.sub(lambda m: m.group(1) + text + m.group(2), s)
s = re.sub(r"<!--bench:(\w+)-->.*?<!--/bench-->",
lambda m: f"<!--bench:{m.group(1)}-->{inline.get(m.group(1), 'n/a')}<!--/bench-->", 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()