Skip to content

Commit 235e249

Browse files
committed
further improve import time
1 parent eabb578 commit 235e249

10 files changed

Lines changed: 1425 additions & 89 deletions

File tree

benchmarks/comparison/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ uv run python charts.py # to charts/
8989
uv run python charts.py --output ../../docs/_static/benchmarks/ # update docs
9090
```
9191

92+
### Import time and package size charts
93+
94+
`perf_charts.py` measures import time (by spawning subprocesses) and
95+
generates package-size comparison charts:
96+
97+
```shell
98+
# Use the development Python (with current whenever build):
99+
uv run python perf_charts.py --python $(which python) --output ../../docs/_static/benchmarks/
100+
101+
# Or skip measurement and only regenerate size charts:
102+
uv run python perf_charts.py --skip-import --output ../../docs/_static/benchmarks/
103+
```
104+
92105
## Verifying the Python build
93106

94107
pyperf spawns worker processes using `sys.executable`, which is the `.venv`
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
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

Comments
 (0)