| 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> |
| 4 | import csv |
| 5 | import sys |
| 6 | from collections import defaultdict |
| 7 | |
| 8 | import matplotlib |
| 9 | import numpy as np |
| 10 | from scipy.stats import gaussian_kde |
| 11 | |
| 12 | matplotlib.use("Agg") |
| 13 | import matplotlib.pyplot as plt |
| 14 | |
| 15 | samples_csv, outdir = sys.argv[1], sys.argv[2] |
| 16 | |
| 17 | rows = [] |
| 18 | with 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 | |
| 23 | by_key = defaultdict(list) |
| 24 | for env, variant, s in rows: |
| 25 | by_key[(env, variant)].append(s) |
| 26 | |
| 27 | # Colors readable on both light and dark backgrounds. |
| 28 | FG = "#8b8b94" |
| 29 | CF = "#f6821f" # Cloudflare orange |
| 30 | CF2 = "#fbb673" |
| 31 | VM = "#4f8ff7" # MicroVM blue |
| 32 | VM2 = "#8fb8fa" |
| 33 | VM3 = "#2fbf9b" |
| 34 | |
| 35 | plt.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 | |
| 53 | def 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 |
| 61 | series = [ |
| 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 | |
| 69 | import random |
| 70 | |
| 71 | |
| 72 | def 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 | |
| 94 | all_max = max(max(by_key[k]) for _, k, _ in series) |
| 95 | strip_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. |
| 99 | VM_L = "#8fb8fa" |
| 100 | VM3_L = "#93e2cc" |
| 101 | oc_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 | ] |
| 109 | strip_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. |
| 116 | fig, ax = plt.subplots(figsize=(9.5, 4.8)) |
| 117 | grid = np.logspace(np.log10(0.3), np.log10(70), 500) |
| 118 | for 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) |
| 123 | ax.set_xscale("log") |
| 124 | ax.set_xticks([1, 2, 3, 5, 10, 20, 40, 60]) |
| 125 | ax.set_xticklabels(["1s", "2s", "3s", "5s", "10s", "20s", "40s", "60s"]) |
| 126 | ax.set_xlabel("time to usable service (seconds)") |
| 127 | ax.set_ylabel("density") |
| 128 | ax.legend(frameon=False, fontsize=10.5) |
| 129 | ax.spines["top"].set_visible(False) |
| 130 | ax.spines["right"].set_visible(False) |
| 131 | ax.grid(True, linewidth=0.6) |
| 132 | ax.set_axisbelow(True) |
| 133 | fig.tight_layout() |
| 134 | fig.savefig(f"{outdir}/opencode-density.png", dpi=200) |
| 135 | plt.close(fig) |
| 136 | |
| 137 | # ----------------------------------------------------------------------- CDF |
| 138 | fig, ax = plt.subplots(figsize=(9.5, 4.8)) |
| 139 | for 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) |
| 143 | ax.set_xlabel("time to usable service (seconds)") |
| 144 | ax.set_ylabel("% of boots at or below") |
| 145 | ax.set_xscale("log") |
| 146 | ax.set_xticks([1, 2, 3, 5, 10, 20]) |
| 147 | ax.set_xticklabels(["1s", "2s", "3s", "5s", "10s", "20s"]) |
| 148 | ax.set_ylim(0, 102) |
| 149 | ax.legend(frameon=False, loc="lower right", fontsize=10.5) |
| 150 | ax.spines["top"].set_visible(False) |
| 151 | ax.spines["right"].set_visible(False) |
| 152 | ax.grid(True, linewidth=0.6) |
| 153 | ax.set_axisbelow(True) |
| 154 | fig.tight_layout() |
| 155 | fig.savefig(f"{outdir}/cdf.png", dpi=200) |
| 156 | plt.close(fig) |
| 157 | |
| 158 | |
| 159 | def pct(xs, p): |
| 160 | xs = sorted(xs) |
| 161 | return xs[min(len(xs) - 1, int(p / 100 * len(xs)))] |
| 162 | |
| 163 | |
| 164 | for 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 | ) |