Skip to content

Commit 4755581

Browse files
committed
Update PyCon slides and interpreter figures
1 parent 34b2071 commit 4755581

24 files changed

Lines changed: 4016 additions & 2024 deletions

experiments/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Current experiment surface is split by role:
66
| --- | --- |
77
| [`kmeans/`](kmeans/) | Shared k-means implementations for MacBook validation plus server/A100 runs. |
88
| [`permutation/`](permutation/) | MacBook permutation validation plus shared matrix methods used by server runs. |
9+
| [`interpreter_effects/`](interpreter_effects/) | CPython 3.14 GIL vs free-threaded interpreter-effect experiments and plots. |
910
| [`server/`](server/) | Linux CPU/A100 long-safe orchestration and plotting. |
1011
| [`visualization/`](visualization/) | 16:9 figures for current slides/poster. |
1112
| [`results/`](results/) | Curated MacBook, server, A100, and presentation figures. |
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# CPython 3.14 Interpreter Effects
2+
3+
This suite compares standard CPython 3.14 (`py314`) with free-threaded
4+
CPython 3.14 (`py314t`) on statistical-computing shaped workloads.
5+
6+
It is deliberately scoped to interpreter effects. Do not describe a result as a
7+
JIT speedup unless the generated metadata reports both:
8+
9+
- `python.jit.available: true`
10+
- `python.jit.enabled: true`
11+
12+
The runner also writes `jit_claim_allowed` into metadata and CSV rows.
13+
14+
## Outputs
15+
16+
Each interpreter run writes:
17+
18+
- `raw_interpreter_effects_<env>.csv`: warmup and repeated measurements.
19+
- `summary_interpreter_effects_<env>.csv`: repeat-only median and IQR summaries.
20+
- `metadata_<env>.json`: environment report from `experiments.common.env_report`.
21+
22+
The plotting command writes slide-ready PNG/SVG files under `figures/`.
23+
24+
## Exact Commands
25+
26+
Run from the repository root. The BLAS thread pins are repeated here so the
27+
intent is visible before Python imports NumPy; the runner also forces these
28+
values inside the process.
29+
30+
```bash
31+
export OMP_NUM_THREADS=1
32+
export OPENBLAS_NUM_THREADS=1
33+
export MKL_NUM_THREADS=1
34+
```
35+
36+
Standard CPython 3.14 GIL build:
37+
38+
```bash
39+
conda run -n py314 python -m experiments.interpreter_effects.run_suite \
40+
--env-label py314 \
41+
--output-dir experiments/results/python314_interpreter_effects/latest \
42+
--experiments negative thread memory \
43+
--repeats 5 \
44+
--warmups 1
45+
```
46+
47+
Free-threaded CPython 3.14:
48+
49+
```bash
50+
conda run -n py314t python -m experiments.interpreter_effects.run_suite \
51+
--env-label py314t \
52+
--output-dir experiments/results/python314_interpreter_effects/latest \
53+
--experiments negative thread memory \
54+
--repeats 5 \
55+
--warmups 1
56+
```
57+
58+
Optional contention backup:
59+
60+
```bash
61+
conda run -n py314t python -m experiments.interpreter_effects.run_suite \
62+
--env-label py314t \
63+
--output-dir experiments/results/python314_interpreter_effects/latest \
64+
--experiments contention \
65+
--repeats 5 \
66+
--warmups 1
67+
```
68+
69+
Plot with the repository default development environment:
70+
71+
```bash
72+
conda run -n py312 python -m experiments.interpreter_effects.plot_interpreter_effects \
73+
--results-dir experiments/results/python314_interpreter_effects/latest
74+
```
75+
76+
For smoke testing only:
77+
78+
```bash
79+
conda run -n py312 python -m experiments.interpreter_effects.run_suite \
80+
--env-label py312_smoke \
81+
--output-dir /tmp/fsm4py_interpreter_effects_smoke \
82+
--experiments negative thread memory contention \
83+
--quick
84+
```
85+
86+
## Experiments
87+
88+
1. Single-thread negative control at `workers=1`
89+
- Pure Python CPU loop.
90+
- NumPy/BLAS-heavy matrix path.
91+
- Small statistical loop.
92+
93+
2. Thread scaling
94+
- `ThreadPoolExecutor` workers `1, 2, 4, 8, 16`.
95+
- CPU-bound Python permutation/bootstrap-like statistic.
96+
- Independent per-worker accumulators.
97+
98+
3. ProcessPool vs ThreadPool memory/runtime
99+
- `py314` defaults to `ProcessPoolExecutor`.
100+
- `py314t` defaults to `ThreadPoolExecutor`.
101+
- A large simulated NumPy array is shared by threads and copied into spawned
102+
worker processes.
103+
- Measures `wall_time_sec` and parent-plus-child `peak_rss_gb`.
104+
105+
4. Optional backup
106+
- Thread-local accumulation compared with shared mutable counter/list/dict.
107+
- Intended to show that contention can erase no-GIL speedups.
108+
109+
## Notes
110+
111+
- The process-pool memory comparison uses the `spawn` start method so child
112+
workers receive their own copy of the simulated array. This makes the memory
113+
contrast explicit and portable across platforms.
114+
- The generated figures are suitable for slides, but slide benchmark numbers
115+
should only be updated after inspecting the generated CSVs.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""CPython 3.14 interpreter-effects experiments."""
2+
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Create slide-ready plots for the CPython 3.14 interpreter-effects suite."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import os
7+
from pathlib import Path
8+
9+
os.environ.setdefault("MPLCONFIGDIR", "/private/tmp/fsm4py-matplotlib")
10+
os.environ.setdefault("XDG_CACHE_HOME", "/private/tmp/fsm4py-cache")
11+
12+
import matplotlib
13+
14+
matplotlib.use("Agg")
15+
import matplotlib.pyplot as plt
16+
import numpy as np
17+
import pandas as pd
18+
19+
PAPER = "#FBF7EF"
20+
INK = "#17202A"
21+
LINE = "#D7CDC0"
22+
BLUE = "#2368AD"
23+
BERRY = "#B51E59"
24+
GREEN = "#248A5A"
25+
ORANGE = "#E66A2C"
26+
DPI = 220
27+
28+
29+
def _setup_style() -> None:
30+
plt.rcParams.update(
31+
{
32+
"figure.facecolor": PAPER,
33+
"axes.facecolor": "#FFFFFF",
34+
"axes.edgecolor": LINE,
35+
"axes.labelcolor": INK,
36+
"axes.titlecolor": INK,
37+
"xtick.color": INK,
38+
"ytick.color": INK,
39+
"font.size": 12,
40+
"axes.titlesize": 15,
41+
"axes.labelsize": 12,
42+
"xtick.labelsize": 10,
43+
"ytick.labelsize": 10,
44+
"legend.fontsize": 10,
45+
}
46+
)
47+
48+
49+
def _read_summaries(results_dir: Path) -> pd.DataFrame:
50+
paths = sorted(results_dir.glob("summary_interpreter_effects_*.csv"))
51+
if not paths:
52+
raise FileNotFoundError(f"no summary_interpreter_effects_*.csv files found in {results_dir}")
53+
frames = [pd.read_csv(path) for path in paths]
54+
df = pd.concat(frames, ignore_index=True)
55+
for col in ("workers", "median_wall_time_sec", "iqr_wall_time_sec", "median_peak_rss_gb", "max_peak_rss_gb"):
56+
if col in df:
57+
df[col] = pd.to_numeric(df[col], errors="coerce")
58+
return df
59+
60+
61+
def _finish(fig: plt.Figure, out_dir: Path, stem: str) -> None:
62+
out_dir.mkdir(parents=True, exist_ok=True)
63+
fig.savefig(out_dir / f"{stem}.png", dpi=DPI)
64+
plt.close(fig)
65+
66+
67+
def plot_negative(df: pd.DataFrame, out_dir: Path) -> None:
68+
sub = df[df["experiment"].eq("single_thread_negative_control")].copy()
69+
if sub.empty:
70+
return
71+
order = ["pure_python_cpu_loop", "numpy_blas_matrix_path", "small_statistical_loop"]
72+
labels = ["Python loop", "NumPy/BLAS", "Stat loop"]
73+
envs = [env for env in ["py314", "py314t"] if env in set(sub["env_label"])]
74+
if not envs:
75+
envs = sorted(sub["env_label"].unique())
76+
x = np.arange(len(order))
77+
width = 0.34 if len(envs) > 1 else 0.5
78+
fig, ax = plt.subplots(figsize=(8.8, 4.8))
79+
fig.patch.set_facecolor(PAPER)
80+
colors = [BLUE, BERRY, GREEN]
81+
for idx, env in enumerate(envs):
82+
vals = []
83+
errs = []
84+
for workload in order:
85+
row = sub[(sub["env_label"].eq(env)) & (sub["workload"].eq(workload))]
86+
vals.append(float(row["median_wall_time_sec"].iloc[0]) if not row.empty else np.nan)
87+
errs.append(float(row["iqr_wall_time_sec"].iloc[0]) if not row.empty else 0.0)
88+
offset = (idx - (len(envs) - 1) / 2) * width
89+
ax.bar(x + offset, vals, width=width, yerr=errs, capsize=4, color=colors[idx % len(colors)], label=env)
90+
ax.set_title("Single-thread negative controls", loc="left", fontweight=900)
91+
ax.set_ylabel("median wall time (s), IQR error bar")
92+
ax.set_xticks(x, labels)
93+
ax.grid(axis="y", color=LINE, alpha=0.55)
94+
ax.legend(frameon=True, facecolor="#FFFFFF", edgecolor=LINE)
95+
fig.tight_layout()
96+
_finish(fig, out_dir, "python314_single_thread_negative_controls")
97+
98+
99+
def plot_thread_scaling(df: pd.DataFrame, out_dir: Path) -> None:
100+
sub = df[df["experiment"].eq("thread_scaling")].copy()
101+
if sub.empty:
102+
return
103+
envs = [env for env in ["py314", "py314t"] if env in set(sub["env_label"])]
104+
if not envs:
105+
envs = sorted(sub["env_label"].unique())
106+
fig, axes = plt.subplots(1, 2, figsize=(10.8, 4.6), constrained_layout=True)
107+
fig.patch.set_facecolor(PAPER)
108+
colors = {env: color for env, color in zip(envs, [BLUE, BERRY, GREEN, ORANGE])}
109+
for env in envs:
110+
env_df = sub[sub["env_label"].eq(env)].sort_values("workers")
111+
workers = env_df["workers"].to_numpy(float)
112+
times = env_df["median_wall_time_sec"].to_numpy(float)
113+
iqr = env_df["iqr_wall_time_sec"].to_numpy(float)
114+
axes[0].plot(workers, times, marker="o", linewidth=2.4, color=colors[env], label=env)
115+
axes[0].fill_between(workers, times - iqr / 2.0, times + iqr / 2.0, color=colors[env], alpha=0.16, linewidth=0)
116+
baseline = float(times[workers == 1][0]) if np.any(workers == 1) else float(times[0])
117+
speedup = baseline / times
118+
axes[1].plot(workers, speedup, marker="o", linewidth=2.4, color=colors[env], label=env)
119+
for ax in axes:
120+
ax.set_xscale("log", base=2)
121+
ax.set_xticks([1, 2, 4, 8, 16], ["1", "2", "4", "8", "16"])
122+
ax.grid(axis="y", color=LINE, alpha=0.55)
123+
axes[0].set_title("A. Runtime", loc="left", fontweight=900)
124+
axes[0].set_ylabel("median wall time (s)")
125+
axes[0].set_xlabel("ThreadPoolExecutor workers")
126+
axes[1].set_title("B. Speedup vs workers=1", loc="left", fontweight=900)
127+
axes[1].set_ylabel("speedup")
128+
axes[1].set_xlabel("ThreadPoolExecutor workers")
129+
axes[1].axhline(1.0, color=INK, linewidth=1.0, alpha=0.5)
130+
axes[0].legend(frameon=True, facecolor="#FFFFFF", edgecolor=LINE)
131+
_finish(fig, out_dir, "python314_thread_scaling")
132+
133+
134+
def plot_memory(df: pd.DataFrame, out_dir: Path) -> None:
135+
sub = df[df["experiment"].eq("pool_memory_runtime")].copy()
136+
if sub.empty:
137+
return
138+
sub["label"] = sub["env_label"].astype(str) + " " + sub["pool"].astype(str)
139+
fig, axes = plt.subplots(1, 2, figsize=(10.2, 4.4), constrained_layout=True)
140+
fig.patch.set_facecolor(PAPER)
141+
x = np.arange(len(sub))
142+
labels = sub["label"].tolist()
143+
palette = [BLUE, BERRY, GREEN, ORANGE]
144+
colors = [palette[idx % len(palette)] for idx in range(len(sub))]
145+
axes[0].bar(x, sub["median_wall_time_sec"], color=colors)
146+
axes[0].set_title("A. Runtime", loc="left", fontweight=900)
147+
axes[0].set_ylabel("median wall time (s)")
148+
axes[1].bar(x, sub["max_peak_rss_gb"], color=colors)
149+
axes[1].set_title("B. Peak RSS", loc="left", fontweight=900)
150+
axes[1].set_ylabel("max peak RSS (GiB)")
151+
for ax in axes:
152+
ax.set_xticks(x, labels, rotation=18, ha="right")
153+
ax.grid(axis="y", color=LINE, alpha=0.55)
154+
_finish(fig, out_dir, "python314_pool_memory_runtime")
155+
156+
157+
def plot_contention(df: pd.DataFrame, out_dir: Path) -> None:
158+
sub = df[df["experiment"].eq("contention_backup")].copy()
159+
if sub.empty:
160+
return
161+
envs = [env for env in ["py314", "py314t"] if env in set(sub["env_label"])]
162+
if not envs:
163+
envs = sorted(sub["env_label"].unique())
164+
fig, axes = plt.subplots(1, len(envs), figsize=(5.4 * len(envs), 4.5), squeeze=False, constrained_layout=True)
165+
fig.patch.set_facecolor(PAPER)
166+
for ax, env in zip(axes[0], envs):
167+
env_df = sub[sub["env_label"].eq(env)]
168+
for workload, color in zip(["thread_local", "shared_counter", "shared_list", "shared_dict"], [BLUE, BERRY, GREEN, ORANGE]):
169+
part = env_df[env_df["workload"].eq(workload)].sort_values("workers")
170+
if part.empty:
171+
continue
172+
ax.plot(part["workers"], part["median_wall_time_sec"], marker="o", linewidth=2.0, color=color, label=workload.replace("_", " "))
173+
ax.set_title(env, loc="left", fontweight=900)
174+
ax.set_xscale("log", base=2)
175+
ax.set_xticks([1, 2, 4, 8, 16], ["1", "2", "4", "8", "16"])
176+
ax.set_xlabel("workers")
177+
ax.set_ylabel("median wall time (s)")
178+
ax.grid(axis="y", color=LINE, alpha=0.55)
179+
ax.legend(frameon=True, facecolor="#FFFFFF", edgecolor=LINE)
180+
_finish(fig, out_dir, "python314_contention_backup")
181+
182+
183+
def main() -> None:
184+
parser = argparse.ArgumentParser(description=__doc__)
185+
parser.add_argument("--results-dir", type=Path, default=Path("experiments/results/python314_interpreter_effects/latest"))
186+
parser.add_argument("--out-dir", type=Path, default=None)
187+
args = parser.parse_args()
188+
_setup_style()
189+
out_dir = args.out_dir or (args.results_dir / "figures")
190+
df = _read_summaries(args.results_dir)
191+
plot_negative(df, out_dir)
192+
plot_thread_scaling(df, out_dir)
193+
plot_memory(df, out_dir)
194+
plot_contention(df, out_dir)
195+
print(f"wrote figures under {out_dir}")
196+
197+
198+
if __name__ == "__main__":
199+
main()

0 commit comments

Comments
 (0)