Skip to content

Commit 8123918

Browse files
larsgebclaude
andcommitted
Pages: execute notebooks in-process to avoid Metal kernel-subprocess issue
The Jupyter kernel subprocess on macOS-14 CI cannot access the Metal GPU reliably, causing "Kernel died while waiting for execute reply" after ~75s. scripts/render_notebooks.py executes jupytext .py notebooks by running each code cell with exec() in the same Python process (no kernel spawn). plt.show() is patched to capture figures as base64 PNG cell outputs; any remaining open figures are collected at end-of-cell. nbconvert's HTMLExporter is then called as a library to produce the final HTML. CI: drop ipykernel and kernel-registration step; replace the nbconvert --execute invocation with the new script. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 09283da commit 8123918

2 files changed

Lines changed: 159 additions & 16 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,7 @@ jobs:
174174
run: |
175175
python3 -m venv .venv
176176
.venv/bin/pip install --quiet \
177-
numpy pybind11 matplotlib jupytext nbconvert ipykernel
178-
# Register this venv as the "python3" Jupyter kernel so nbconvert
179-
# executes notebooks with the same Python that has _m1_gpu_ops built in.
180-
.venv/bin/python3 -m ipykernel install --user --name python3
177+
numpy pybind11 matplotlib jupytext nbconvert
181178
182179
- name: Build with pybind11
183180
run: |
@@ -186,19 +183,12 @@ jobs:
186183
cmake --build build --parallel
187184
188185
- name: Render notebooks to HTML
186+
# Run cells with exec() in-process — avoids the Jupyter kernel subprocess
187+
# which cannot reliably access the Metal GPU on macOS CI runners.
188+
# plt.show() is patched to capture figures as base64 PNG cell outputs.
189189
env:
190-
MPLBACKEND: Agg # non-interactive backend — no display needed
191-
run: |
192-
# Convert jupytext light .py → .ipynb, execute, render to HTML.
193-
# Working directory is repo root so "build/" relative paths resolve.
194-
.venv/bin/jupytext --to notebook demo.py
195-
.venv/bin/jupytext --to notebook wave_demo.py
196-
mkdir -p _site
197-
.venv/bin/jupyter nbconvert \
198-
--to html --execute \
199-
--ExecutePreprocessor.timeout=300 \
200-
--output-dir _site \
201-
demo.ipynb wave_demo.ipynb
190+
MPLBACKEND: Agg
191+
run: .venv/bin/python3 scripts/render_notebooks.py _site demo.py wave_demo.py
202192

203193
- name: Create index page
204194
run: |

scripts/render_notebooks.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#!/usr/bin/env python3
2+
"""
3+
In-process notebook renderer for m1-gpu-cpp demos.
4+
5+
Executes jupytext .py notebooks by running cells with exec() in the same
6+
Python process, avoiding Jupyter kernel subprocess issues on macOS CI where
7+
Metal GPU access is available to the main process but not reliably inherited
8+
by kernel subprocesses.
9+
10+
Matplotlib figures are captured via a patched plt.show() and appended to
11+
cell outputs as base64-encoded PNG images. nbconvert's HTMLExporter is then
12+
called as a library (no subprocess) to produce the final HTML.
13+
14+
Usage:
15+
python scripts/render_notebooks.py <output_dir> <notebook.py> [...]
16+
"""
17+
18+
import base64
19+
import io
20+
import sys
21+
import traceback
22+
from pathlib import Path
23+
24+
import matplotlib
25+
26+
matplotlib.use("Agg")
27+
import matplotlib.pyplot as plt
28+
import jupytext
29+
import nbformat
30+
from nbformat import v4
31+
from nbconvert.exporters import HTMLExporter
32+
33+
34+
# ---------------------------------------------------------------------------
35+
# Figure capture helpers
36+
# ---------------------------------------------------------------------------
37+
38+
39+
def _capture_and_close() -> list[str]:
40+
"""Save every open matplotlib figure as a base64 PNG and close all."""
41+
images: list[str] = []
42+
for fnum in plt.get_fignums():
43+
buf = io.BytesIO()
44+
plt.figure(fnum).savefig(buf, format="png", bbox_inches="tight", dpi=100)
45+
buf.seek(0)
46+
images.append(base64.b64encode(buf.read()).decode())
47+
plt.close("all")
48+
return images
49+
50+
51+
# ---------------------------------------------------------------------------
52+
# Core executor
53+
# ---------------------------------------------------------------------------
54+
55+
56+
def execute_notebook(py_path: Path, out_dir: Path) -> None:
57+
print(f"Executing {py_path} ...")
58+
nb = jupytext.read(str(py_path))
59+
60+
# Shared namespace — persists across cells, just like a real kernel session.
61+
ns: dict = {"__name__": "__main__"}
62+
63+
# Patch plt.show() so that cells which call it trigger figure capture
64+
# instead of trying to open a GUI window. Images are accumulated per-cell.
65+
cell_images: list[str] = []
66+
67+
def _patched_show(*_args, **_kwargs) -> None:
68+
cell_images.extend(_capture_and_close())
69+
70+
plt.show = _patched_show
71+
72+
for exec_count, cell in enumerate(nb.cells, start=1):
73+
if cell.cell_type != "code":
74+
continue
75+
76+
# Drop IPython magic lines that jupytext stores as comments.
77+
src_lines = [
78+
line
79+
for line in cell.source.splitlines()
80+
if not line.lstrip().startswith(("# %", "# %%"))
81+
]
82+
src = "\n".join(src_lines).strip()
83+
84+
cell.outputs = []
85+
cell.execution_count = exec_count
86+
87+
if not src:
88+
continue
89+
90+
cell_images.clear()
91+
stdout_buf = io.StringIO()
92+
93+
try:
94+
sys.stdout = stdout_buf
95+
exec(compile(src, str(py_path), "exec"), ns) # noqa: S102
96+
except Exception as exc:
97+
cell.outputs.append(
98+
v4.new_output(
99+
output_type="error",
100+
ename=type(exc).__name__,
101+
evalue=str(exc),
102+
traceback=traceback.format_exc().splitlines(),
103+
)
104+
)
105+
finally:
106+
sys.stdout = sys.__stdout__
107+
108+
text = stdout_buf.getvalue()
109+
if text:
110+
cell.outputs.append(
111+
v4.new_output(output_type="stream", name="stdout", text=text)
112+
)
113+
114+
# Capture any figures not yet collected by patched show().
115+
cell_images.extend(_capture_and_close())
116+
117+
for img_b64 in cell_images:
118+
cell.outputs.append(
119+
v4.new_output(
120+
output_type="display_data",
121+
data={"image/png": img_b64, "text/plain": "<Figure>"},
122+
metadata={"image/png": {"width": 650}},
123+
)
124+
)
125+
126+
# Render to HTML using nbconvert as a library (no subprocess).
127+
exporter = HTMLExporter()
128+
exporter.exclude_input_prompt = True
129+
body, _ = exporter.from_notebook_node(nb)
130+
131+
out_dir.mkdir(parents=True, exist_ok=True)
132+
out_path = out_dir / f"{py_path.stem}.html"
133+
out_path.write_text(body, encoding="utf-8")
134+
print(f" → {out_path}")
135+
136+
137+
# ---------------------------------------------------------------------------
138+
# Entry point
139+
# ---------------------------------------------------------------------------
140+
141+
142+
def main() -> None:
143+
if len(sys.argv) < 3:
144+
print(f"Usage: {sys.argv[0]} <output_dir> <notebook.py> [...]", file=sys.stderr)
145+
sys.exit(1)
146+
147+
out_dir = Path(sys.argv[1])
148+
for nb_path in (Path(p) for p in sys.argv[2:]):
149+
execute_notebook(nb_path, out_dir)
150+
151+
152+
if __name__ == "__main__":
153+
main()

0 commit comments

Comments
 (0)