293 lines
15 KiB
Python
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()
|