1# Generates the blog plots for the MicroVM vs Container cold-start post from a
2# benchmark samples CSV (see test/bench.test.ts). Usage:
3# uv run --with matplotlib --with scipy scripts/plot-blog.py data/samples-<run>.csv <outdir>
4import csv
5import sys
6from collections import defaultdict
7
8import matplotlib
9import numpy as np
10from scipy.stats import gaussian_kde
11
12matplotlib.use("Agg")
13import matplotlib.pyplot as plt
14
15samples_csv, outdir = sys.argv[1], sys.argv[2]
16
17rows = []
18with open(samples_csv) as f:
19 for r in csv.DictReader(f):
20 if r["ok"] == "true" and r["readyMs"]:
21 rows.append((r["env"], r["variant"], int(r["readyMs"]) / 1000.0))
22
23by_key = defaultdict(list)
24for env, variant, s in rows:
25 by_key[(env, variant)].append(s)
26
27# Colors readable on both light and dark backgrounds.
28FG = "#8b8b94"
29CF = "#f6821f" # Cloudflare orange
30CF2 = "#fbb673"
31VM = "#4f8ff7" # MicroVM blue
32VM2 = "#8fb8fa"
33VM3 = "#2fbf9b"
34
35plt.rcParams.update(
36 {
37 "figure.facecolor": "none",
38 "axes.facecolor": "none",
39 "savefig.facecolor": "none",
40 "text.color": FG,
41 "axes.edgecolor": FG,
42 "axes.labelcolor": FG,
43 "xtick.color": FG,
44 "ytick.color": FG,
45 "grid.color": FG,
46 "grid.alpha": 0.15,
47 "font.size": 12,
48 "font.family": "sans-serif",
49 }
50)
51
52
53def style(ax):
54 ax.spines["top"].set_visible(False)
55 ax.spines["right"].set_visible(False)
56 ax.grid(True, axis="x", linewidth=0.6)
57 ax.set_axisbelow(True)
58
59
60# ---------------------------------------------------------------- strip plot
61series = [
62 ("Cloudflare container — Effect image", ("container", "effectful"), CF),
63 ("Cloudflare container — plain Bun image", ("container", "bun"), CF2),
64 ("AWS MicroVM — Effect image (Bun)", ("lambda\u2192microvm", "effectful-bun"), VM),
65 ("AWS MicroVM — plain Bun image", ("lambda\u2192microvm", "bun"), VM2),
66 ("AWS MicroVM — from a Cloudflare Worker", ("worker\u2192microvm", "effectful-bun"), VM3),
67]
68
69import random
70
71
72def strip_plot(rows, fname, xmax=None, logx=False):
73 fig, ax = plt.subplots(figsize=(9.5, 0.75 + 0.78 * len(rows)))
74 random.seed(7)
75 for i, (label, key, color) in enumerate(reversed(rows)):
76 xs = by_key[key]
77 ys = [i + random.uniform(-0.16, 0.16) for _ in xs]
78 ax.scatter(xs, ys, s=26, color=color, alpha=0.75, linewidths=0)
79 ax.set_yticks(range(len(rows)))
80 ax.set_yticklabels([label for label, _, _ in reversed(rows)])
81 ax.set_xlabel("time to usable service (seconds) — every boot in the run")
82 if logx:
83 ax.set_xscale("log")
84 ax.set_xticks([1, 2, 3, 5, 10, 20, 40, 60])
85 ax.set_xticklabels(["1s", "2s", "3s", "5s", "10s", "20s", "40s", "60s"])
86 elif xmax:
87 ax.set_xlim(0, xmax)
88 style(ax)
89 fig.tight_layout()
90 fig.savefig(f"{outdir}/{fname}", dpi=200)
91 plt.close(fig)
92
93
94all_max = max(max(by_key[k]) for _, k, _ in series)
95strip_plot(series, "every-boot.png", xmax=all_max * 1.04)
96
97# ------------------------------------------------------- opencode strip plot
98# Solid hues = opencode, lighter tints of the same hue = hello world.
99VM_L = "#8fb8fa"
100VM3_L = "#93e2cc"
101oc_series = [
102 ("Cloudflare container — opencode (via Worker)", ("container", "opencode"), CF),
103 ("AWS MicroVM — opencode (via Lambda)", ("lambda\u2192microvm", "opencode"), VM),
104 ("AWS MicroVM — opencode (via Worker)", ("worker\u2192microvm", "opencode"), VM3),
105 ("Cloudflare container — hello world (via Worker)", ("container", "bun"), CF2),
106 ("AWS MicroVM — hello world (via Lambda)", ("lambda\u2192microvm", "bun"), VM_L),
107 ("AWS MicroVM — hello world (via Worker)", ("worker\u2192microvm", "bun"), VM3_L),
108]
109strip_plot(oc_series, "opencode.png", logx=True)
110
111# --------------------------------------------------- opencode density (KDE)
112# Density in log-space: the MicroVM variants show as tall narrow spikes
113# (consistency), the container as a wide hump at ~10s. NB: the container's
114# 20-60s tail is nearly invisible at this scale — the strip plot and the
115# table's max column carry that part of the story.
116fig, ax = plt.subplots(figsize=(9.5, 4.8))
117grid = np.logspace(np.log10(0.3), np.log10(70), 500)
118for label, key, color in oc_series:
119 xs = np.log10(np.array(by_key[key]))
120 density = gaussian_kde(xs, bw_method=0.25)(np.log10(grid))
121 ax.plot(grid, density, color=color, linewidth=2.2, label=label)
122 ax.fill_between(grid, density, color=color, alpha=0.15)
123ax.set_xscale("log")
124ax.set_xticks([1, 2, 3, 5, 10, 20, 40, 60])
125ax.set_xticklabels(["1s", "2s", "3s", "5s", "10s", "20s", "40s", "60s"])
126ax.set_xlabel("time to usable service (seconds)")
127ax.set_ylabel("density")
128ax.legend(frameon=False, fontsize=10.5)
129ax.spines["top"].set_visible(False)
130ax.spines["right"].set_visible(False)
131ax.grid(True, linewidth=0.6)
132ax.set_axisbelow(True)
133fig.tight_layout()
134fig.savefig(f"{outdir}/opencode-density.png", dpi=200)
135plt.close(fig)
136
137# ----------------------------------------------------------------------- CDF
138fig, ax = plt.subplots(figsize=(9.5, 4.8))
139for label, key, color in series:
140 xs = sorted(by_key[key])
141 ys = [(i + 1) / len(xs) * 100 for i in range(len(xs))]
142 ax.plot(xs, ys, color=color, linewidth=2.2, label=label)
143ax.set_xlabel("time to usable service (seconds)")
144ax.set_ylabel("% of boots at or below")
145ax.set_xscale("log")
146ax.set_xticks([1, 2, 3, 5, 10, 20])
147ax.set_xticklabels(["1s", "2s", "3s", "5s", "10s", "20s"])
148ax.set_ylim(0, 102)
149ax.legend(frameon=False, loc="lower right", fontsize=10.5)
150ax.spines["top"].set_visible(False)
151ax.spines["right"].set_visible(False)
152ax.grid(True, linewidth=0.6)
153ax.set_axisbelow(True)
154fig.tight_layout()
155fig.savefig(f"{outdir}/cdf.png", dpi=200)
156plt.close(fig)
157
158
159def pct(xs, p):
160 xs = sorted(xs)
161 return xs[min(len(xs) - 1, int(p / 100 * len(xs)))]
162
163
164for label, key, _ in series + oc_series:
165 xs = by_key[key]
166 print(
167 f"{label:45s} n={len(xs):3d} p50={pct(xs,50):.1f}s p95={pct(xs,95):.1f}s max={max(xs):.1f}s"
168 )