|
| 1 | +""" |
| 2 | +Generate import-time and package-size bar charts. |
| 3 | +
|
| 4 | +Produces SVG files matching the visual style of the main timing charts: |
| 5 | +
|
| 6 | + import-time-light.svg, import-time-dark.svg |
| 7 | + package-size-light.svg, package-size-dark.svg |
| 8 | +
|
| 9 | +Run (from this directory): |
| 10 | +
|
| 11 | + uv run python perf_charts.py |
| 12 | + uv run python perf_charts.py --output ../../docs/_static/benchmarks/ |
| 13 | +
|
| 14 | +Import time is measured by spawning fresh Python subprocesses. Results |
| 15 | +are hardware-dependent; the script prints them before generating charts. |
| 16 | +
|
| 17 | +Package sizes are based on manylinux_2_17_x86_64 cp313 wheels from PyPI |
| 18 | +(updated manually when new versions are released). |
| 19 | +""" |
| 20 | + |
| 21 | +from __future__ import annotations |
| 22 | + |
| 23 | +import argparse |
| 24 | +import re |
| 25 | +import subprocess |
| 26 | +import sys |
| 27 | +from pathlib import Path |
| 28 | +from statistics import median |
| 29 | + |
| 30 | +import matplotlib.pyplot as plt |
| 31 | +import matplotlib.ticker as mticker |
| 32 | +import numpy as np |
| 33 | + |
| 34 | +# --------------------------------------------------------------------------- |
| 35 | +# Font/SVG helpers (shared with charts.py) |
| 36 | +# --------------------------------------------------------------------------- |
| 37 | + |
| 38 | +_SYSTEM_FONT = ( |
| 39 | + "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" |
| 40 | +) |
| 41 | + |
| 42 | +_SVG_RC = { |
| 43 | + "svg.fonttype": "none", |
| 44 | + "font.family": "sans-serif", |
| 45 | + "font.sans-serif": [ |
| 46 | + "Helvetica Neue", |
| 47 | + "Helvetica", |
| 48 | + "Arial", |
| 49 | + "Liberation Sans", |
| 50 | + "DejaVu Sans", |
| 51 | + ], |
| 52 | +} |
| 53 | + |
| 54 | + |
| 55 | +def _save_svg(fig: plt.Figure, output: Path) -> None: |
| 56 | + output.parent.mkdir(parents=True, exist_ok=True) |
| 57 | + fig.savefig(output, format="svg", transparent=True) |
| 58 | + svg = output.read_text() |
| 59 | + svg = re.sub( |
| 60 | + r'font-family:[^;"]+', |
| 61 | + f"font-family: {_SYSTEM_FONT}", |
| 62 | + svg, |
| 63 | + ) |
| 64 | + output.write_text(svg) |
| 65 | + print(f" Saved {output}") |
| 66 | + |
| 67 | + |
| 68 | +# --------------------------------------------------------------------------- |
| 69 | +# Import time measurement |
| 70 | +# --------------------------------------------------------------------------- |
| 71 | + |
| 72 | +_IMPORT_SNIPPET = """\ |
| 73 | +import time |
| 74 | +t0 = time.perf_counter_ns() |
| 75 | +import {module} |
| 76 | +print(time.perf_counter_ns() - t0) |
| 77 | +""" |
| 78 | + |
| 79 | +_IMPORT_FIRST_USE_SNIPPET = """\ |
| 80 | +import time |
| 81 | +t0 = time.perf_counter_ns() |
| 82 | +import whenever |
| 83 | +whenever.Instant.now() |
| 84 | +print(time.perf_counter_ns() - t0) |
| 85 | +""" |
| 86 | + |
| 87 | + |
| 88 | +def measure_import(module: str, python: str, n: int = 15) -> float: |
| 89 | + """Measure median import time (ns) by spawning fresh processes.""" |
| 90 | + if module == "whenever (first use)": |
| 91 | + snippet = _IMPORT_FIRST_USE_SNIPPET |
| 92 | + else: |
| 93 | + snippet = _IMPORT_SNIPPET.format(module=module) |
| 94 | + times = [] |
| 95 | + for _ in range(n): |
| 96 | + result = subprocess.run( |
| 97 | + [python, "-c", snippet], |
| 98 | + capture_output=True, |
| 99 | + text=True, |
| 100 | + ) |
| 101 | + if result.returncode == 0: |
| 102 | + times.append(int(result.stdout.strip())) |
| 103 | + if not times: |
| 104 | + return float("nan") |
| 105 | + return median(times) |
| 106 | + |
| 107 | + |
| 108 | +# --------------------------------------------------------------------------- |
| 109 | +# Import time chart |
| 110 | +# --------------------------------------------------------------------------- |
| 111 | + |
| 112 | +# Modules to measure, in chart order (top to bottom) |
| 113 | +IMPORT_MODULES = [ |
| 114 | + "whenever", |
| 115 | + "whenever (first use)", |
| 116 | + "datetime", |
| 117 | + "json", |
| 118 | + "arrow", |
| 119 | + "pendulum", |
| 120 | +] |
| 121 | +IMPORT_LABELS = { |
| 122 | + "whenever": "whenever\n(import only)", |
| 123 | + "whenever (first use)": "whenever\n(first use)", |
| 124 | + "datetime": "datetime", |
| 125 | + "json": "json", |
| 126 | + "arrow": "Arrow", |
| 127 | + "pendulum": "Pendulum", |
| 128 | +} |
| 129 | + |
| 130 | + |
| 131 | +def plot_import_time( |
| 132 | + times_ns: dict[str, float], |
| 133 | + output: Path, |
| 134 | + theme: str, |
| 135 | +) -> None: |
| 136 | + text_color = "#dde8f0" if theme == "dark" else "#333333" |
| 137 | + grid_color = "#3a4a5a" if theme == "dark" else "#e0e0e0" |
| 138 | + bar_color = "#E15759" |
| 139 | + |
| 140 | + modules = [m for m in IMPORT_MODULES if m in times_ns] |
| 141 | + values_ms = [times_ns[m] / 1e6 for m in modules] |
| 142 | + labels = [IMPORT_LABELS.get(m, m) for m in modules] |
| 143 | + |
| 144 | + n = len(modules) |
| 145 | + y_pos = np.arange(n - 1, -1, -1, dtype=float) |
| 146 | + |
| 147 | + with plt.rc_context(_SVG_RC): |
| 148 | + fig, ax = plt.subplots(figsize=(5, 0.55 * n + 0.5)) |
| 149 | + fig.patch.set_alpha(0) |
| 150 | + ax.set_facecolor("none") |
| 151 | + |
| 152 | + max_val = max(values_ms) if values_ms else 1.0 |
| 153 | + |
| 154 | + for i, (val, y) in enumerate(zip(values_ms, y_pos)): |
| 155 | + ax.barh( |
| 156 | + y, |
| 157 | + val, |
| 158 | + height=0.55, |
| 159 | + color=bar_color, |
| 160 | + edgecolor="none", |
| 161 | + zorder=3, |
| 162 | + ) |
| 163 | + ax.text( |
| 164 | + val + max_val * 0.02, |
| 165 | + y, |
| 166 | + f"{val:.1f} ms", |
| 167 | + va="center", |
| 168 | + ha="left", |
| 169 | + fontsize=9, |
| 170 | + color=text_color, |
| 171 | + zorder=4, |
| 172 | + clip_on=False, |
| 173 | + ) |
| 174 | + |
| 175 | + ax.set_yticks(y_pos) |
| 176 | + ax.set_yticklabels(labels, fontsize=9, color=text_color) |
| 177 | + ax.tick_params(axis="y", length=0, pad=6) |
| 178 | + |
| 179 | + ax.set_xlim(0, max_val * 1.35) |
| 180 | + ax.xaxis.set_major_formatter( |
| 181 | + mticker.FuncFormatter(lambda x, _: f"{x:.0f} ms") |
| 182 | + ) |
| 183 | + ax.tick_params(axis="x", labelsize=8, colors=text_color, length=0) |
| 184 | + |
| 185 | + ax.grid(True, axis="x", color=grid_color, linewidth=0.6, zorder=0) |
| 186 | + ax.grid(False, axis="y") |
| 187 | + for spine in ax.spines.values(): |
| 188 | + spine.set_visible(False) |
| 189 | + |
| 190 | + plt.tight_layout() |
| 191 | + _save_svg(fig, output) |
| 192 | + plt.close(fig) |
| 193 | + |
| 194 | + |
| 195 | +# --------------------------------------------------------------------------- |
| 196 | +# Package size chart |
| 197 | +# --------------------------------------------------------------------------- |
| 198 | + |
| 199 | +# Wheel sizes: (label, size_kb) — manylinux_2_17_x86_64, cp313 |
| 200 | +# Updated 2025-05-27 from PyPI |
| 201 | +PACKAGE_SIZES = [ |
| 202 | + ("whenever (pure python)", 116), |
| 203 | + ("whenever", 617), |
| 204 | + ("orjson", 131), |
| 205 | + ("msgspec", 220), |
| 206 | + ("pendulum", 341), |
| 207 | + ("arrow", 67), |
| 208 | + ("pydantic-core", 2048), |
| 209 | +] |
| 210 | + |
| 211 | + |
| 212 | +def plot_package_size( |
| 213 | + output: Path, |
| 214 | + theme: str, |
| 215 | +) -> None: |
| 216 | + text_color = "#dde8f0" if theme == "dark" else "#333333" |
| 217 | + grid_color = "#3a4a5a" if theme == "dark" else "#e0e0e0" |
| 218 | + bar_color = "#E15759" |
| 219 | + |
| 220 | + labels = [p[0] for p in PACKAGE_SIZES] |
| 221 | + values_mb = [p[1] / 1024 for p in PACKAGE_SIZES] |
| 222 | + |
| 223 | + n = len(labels) |
| 224 | + y_pos = np.arange(n - 1, -1, -1, dtype=float) |
| 225 | + |
| 226 | + with plt.rc_context(_SVG_RC): |
| 227 | + fig, ax = plt.subplots(figsize=(5, 0.55 * n + 0.5)) |
| 228 | + fig.patch.set_alpha(0) |
| 229 | + ax.set_facecolor("none") |
| 230 | + |
| 231 | + max_val = max(values_mb) if values_mb else 1.0 |
| 232 | + |
| 233 | + for i, (val, y) in enumerate(zip(values_mb, y_pos)): |
| 234 | + ax.barh( |
| 235 | + y, |
| 236 | + val, |
| 237 | + height=0.55, |
| 238 | + color=bar_color, |
| 239 | + edgecolor="none", |
| 240 | + zorder=3, |
| 241 | + ) |
| 242 | + # Format label |
| 243 | + if val >= 1.0: |
| 244 | + lbl = f"{val:.1f} MB" |
| 245 | + else: |
| 246 | + lbl = f"{int(val * 1024)} KB" |
| 247 | + ax.text( |
| 248 | + val + max_val * 0.02, |
| 249 | + y, |
| 250 | + lbl, |
| 251 | + va="center", |
| 252 | + ha="left", |
| 253 | + fontsize=9, |
| 254 | + color=text_color, |
| 255 | + zorder=4, |
| 256 | + clip_on=False, |
| 257 | + ) |
| 258 | + |
| 259 | + ax.set_yticks(y_pos) |
| 260 | + ax.set_yticklabels(labels, fontsize=9, color=text_color) |
| 261 | + ax.tick_params(axis="y", length=0, pad=6) |
| 262 | + |
| 263 | + ax.set_xlim(0, max_val * 1.35) |
| 264 | + ax.xaxis.set_major_formatter( |
| 265 | + mticker.FuncFormatter(lambda x, _: f"{x:.1f} MB") |
| 266 | + ) |
| 267 | + ax.tick_params(axis="x", labelsize=8, colors=text_color, length=0) |
| 268 | + |
| 269 | + ax.grid(True, axis="x", color=grid_color, linewidth=0.6, zorder=0) |
| 270 | + ax.grid(False, axis="y") |
| 271 | + for spine in ax.spines.values(): |
| 272 | + spine.set_visible(False) |
| 273 | + |
| 274 | + plt.tight_layout() |
| 275 | + _save_svg(fig, output) |
| 276 | + plt.close(fig) |
| 277 | + |
| 278 | + |
| 279 | +# --------------------------------------------------------------------------- |
| 280 | +# Entry point |
| 281 | +# --------------------------------------------------------------------------- |
| 282 | + |
| 283 | + |
| 284 | +def main() -> None: |
| 285 | + here = Path(__file__).parent |
| 286 | + parser = argparse.ArgumentParser( |
| 287 | + description=__doc__, |
| 288 | + formatter_class=argparse.RawDescriptionHelpFormatter, |
| 289 | + ) |
| 290 | + parser.add_argument( |
| 291 | + "--output", |
| 292 | + default=str(here / "charts"), |
| 293 | + metavar="DIR", |
| 294 | + help="output directory for SVG files (default: charts/)", |
| 295 | + ) |
| 296 | + parser.add_argument( |
| 297 | + "--python", |
| 298 | + default=sys.executable, |
| 299 | + metavar="PATH", |
| 300 | + help="Python interpreter to use for import measurements (default: sys.executable)", |
| 301 | + ) |
| 302 | + parser.add_argument( |
| 303 | + "--skip-import", |
| 304 | + action="store_true", |
| 305 | + help="skip import time measurement (use cached/hardcoded values)", |
| 306 | + ) |
| 307 | + args = parser.parse_args() |
| 308 | + out_dir = Path(args.output) |
| 309 | + |
| 310 | + # -- Import time -- |
| 311 | + if not args.skip_import: |
| 312 | + python = args.python |
| 313 | + print(f"Measuring import times (using {python})…") |
| 314 | + times_ns: dict[str, float] = {} |
| 315 | + for mod in IMPORT_MODULES: |
| 316 | + t = measure_import(mod, python=python) |
| 317 | + times_ns[mod] = t |
| 318 | + print(f" {mod:25s}: {t/1e6:.2f} ms") |
| 319 | + |
| 320 | + print("\nGenerating import-time charts…") |
| 321 | + for theme in ("light", "dark"): |
| 322 | + plot_import_time(times_ns, out_dir / f"import-time-{theme}.svg", theme) |
| 323 | + |
| 324 | + # -- Package size -- |
| 325 | + print("\nGenerating package-size charts…") |
| 326 | + for theme in ("light", "dark"): |
| 327 | + plot_package_size(out_dir / f"package-size-{theme}.svg", theme) |
| 328 | + |
| 329 | + print("\nDone.") |
| 330 | + |
| 331 | + |
| 332 | +if __name__ == "__main__": |
| 333 | + main() |
0 commit comments