Skip to content

Commit 16fd963

Browse files
authored
Execute examples in parallel (#900)
This PR makes the doc building execute the examples in parallel, significantly speeding up the overall execution. This has been achieved as follows: - There is now a dedicated function for executing and converting a single example (`_convert_example`) - We do some Thread-magic that Claude came up with - The whole job now fails when a single example fails, and it fails faster with a clearer error message --- <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2 parents fb3ecd6 + b170bc6 commit 16fd963

2 files changed

Lines changed: 133 additions & 119 deletions

File tree

docs/scripts/build_examples.py

Lines changed: 131 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,103 @@
33
import os
44
import shutil
55
import textwrap
6+
from concurrent.futures import ThreadPoolExecutor, as_completed
67
from pathlib import Path
7-
from subprocess import DEVNULL, STDOUT, check_call
8+
from subprocess import DEVNULL, check_call
89

910
from tqdm import tqdm
1011

1112
# TODO full rebuild option
1213

1314

14-
def build_examples(destination_directory: Path, dummy: bool, remove_dir: bool):
15+
def _convert_example(file: Path, sub_directory: Path) -> None:
16+
"""Convert a single example file into its documentation markdown page.
17+
18+
Args:
19+
file: The example ``.py`` file to convert.
20+
sub_directory: The example folder the file belongs to (used to locate figures).
21+
"""
22+
file_name = file.stem
23+
24+
# Convert the file to a jupyter notebook.
25+
# Note: stdout is discarded to keep the parallel build log clean, but stderr is
26+
# left untouched so the actual error surfaces when a conversion fails.
27+
check_call(["jupytext", "--to", "notebook", file], stdout=DEVNULL)
28+
29+
notebook_path = file.with_suffix(".ipynb")
30+
31+
# Execute the notebook, then convert it to markdown
32+
env = os.environ | {"PYTHONPATH": os.getcwd()}
33+
convert_execute = [
34+
"jupyter",
35+
"nbconvert",
36+
"--to",
37+
"notebook",
38+
"--inplace",
39+
"--execute",
40+
notebook_path,
41+
]
42+
check_call(convert_execute, stdout=DEVNULL, env=env)
43+
to_markdown = ["jupyter", "nbconvert", "--to", "markdown", notebook_path]
44+
check_call(to_markdown, stdout=DEVNULL, env=env)
45+
46+
# Wrap long lines, except those containing a link (detected via "](")
47+
markdown_path = file.with_suffix(".md")
48+
with open(markdown_path, encoding="UTF-8") as markdown_file:
49+
content = markdown_file.read()
50+
wrapped_lines = []
51+
ignored_substrings = (
52+
"![svg]",
53+
"![png]",
54+
"<Figure size",
55+
"it/s",
56+
"s/it",
57+
)
58+
for line in content.splitlines():
59+
# Skip formatter control lines
60+
if "fmt: off" in line or "fmt: on" in line:
61+
continue
62+
if any(substring in line for substring in ignored_substrings):
63+
continue
64+
if (
65+
len(line) > 88
66+
and "](" not in line
67+
and not line.lstrip().startswith("#")
68+
):
69+
wrapped = textwrap.wrap(line, width=88)
70+
wrapped_lines.extend(wrapped)
71+
else:
72+
wrapped_lines.append(line)
73+
74+
lines = [line + "\n" for line in wrapped_lines]
75+
# Append light/dark figures if both exist, else a single figure if present
76+
light_figure = Path(sub_directory / (file_name + "_light.svg"))
77+
dark_figure = Path(sub_directory / (file_name + "_dark.svg"))
78+
figure = Path(sub_directory / (file_name + ".svg"))
79+
if light_figure.is_file() and dark_figure.is_file():
80+
lines.append(f"```{{image}} {file_name}_light.svg\n")
81+
lines.append(":align: center\n")
82+
lines.append(":class: only-light\n")
83+
lines.append("```\n")
84+
lines.append(f"```{{image}} {file_name}_dark.svg\n")
85+
lines.append(":align: center\n")
86+
lines.append(":class: only-dark\n")
87+
lines.append("```\n")
88+
elif figure.is_file():
89+
lines.append(f"```{{image}} {file_name}.svg\n")
90+
lines.append(":align: center\n")
91+
lines.append("```\n")
92+
93+
with open(markdown_path, "w", encoding="UTF-8") as markdown_file:
94+
markdown_file.writelines(lines)
95+
96+
97+
def build_examples(
98+
destination_directory: Path,
99+
dummy: bool,
100+
remove_dir: bool,
101+
max_workers: int | None = None,
102+
):
15103
"""Create the documentation version of the examples files.
16104
17105
Note that this deletes the destination directory if it already exists.
@@ -20,6 +108,8 @@ def build_examples(destination_directory: Path, dummy: bool, remove_dir: bool):
20108
destination_directory: The destination directory.
21109
dummy: Only build a dummy version of the files.
22110
remove_dir: Remove the examples directory if it already exists.
111+
max_workers: Number of examples to convert concurrently. Defaults to the number
112+
of available CPUs. Lower it if the build runs out of memory.
23113
24114
Raises:
25115
OSError: If the directory already exists but should not be removed.
@@ -43,12 +133,9 @@ def ignore_pycache(_, contents: list[str]):
43133
# examples
44134
ex_file = """# Examples\n\n```{toctree}\n:maxdepth: 2\n\n"""
45135

46-
# List all directories in the examples folder
47136
ex_directories = [d for d in destination_directory.iterdir() if d.is_dir()]
48137

49-
# This list contains the order of the examples as we want to have them in the end.
50-
# The examples that should be the first ones are already included here and skipped
51-
# later on. All others are just included.
138+
# Desired example order; entries listed here come first, all others are appended
52139
ex_order = [
53140
"Basics<Basics/Basics>\n",
54141
"Searchspaces<Searchspaces/Searchspaces>\n",
@@ -59,147 +146,50 @@ def ignore_pycache(_, contents: list[str]):
59146
"Custom Surrogates<Custom_Surrogates/Custom_Surrogates>\n",
60147
]
61148

62-
# Iterate over the directories.
63-
for sub_directory in (pbar := tqdm(ex_directories)):
64-
# Get the name of the current folder
65-
# Format it by replacing underscores and capitalizing the words
149+
# Files to convert, collected across all folders so conversion can be batched
150+
files_to_convert: list[tuple[Path, Path]] = []
151+
152+
# First pass (sequential): build the toctree files and collect the conversion work
153+
for sub_directory in ex_directories:
154+
# Folder name, formatted for display (underscores to spaces, capitalized)
66155
folder_name = sub_directory.stem
67156
formatted = " ".join(word.capitalize() for word in folder_name.split("_"))
68157

69-
# Create the link to the folder to the top level toctree.
158+
# Add the folder to the top level toctree if not already present
70159
ex_file_entry = formatted + f"<{folder_name}/{folder_name}>\n"
71-
# Add it to the list of examples if it is not already contained
72160
if ex_file_entry not in ex_order:
73161
ex_order.append(ex_file_entry)
74162

75-
# We need to create a file for the inclusion of the folder.
76-
# We thus get the content of the corresponding header file.
163+
# Start the folder's toctree from its header file
77164
header_folder_name = sub_directory / f"{folder_name}_Header.md"
78165
header = header_folder_name.read_text()
79166

80167
subdir_toctree = header + "\n```{toctree}\n:maxdepth: 1\n\n"
81168

82-
# Set description of progressbar
83-
pbar.set_description("Overall progress")
84-
85-
# list all .py files in the subdirectory that need to be converted
86169
py_files = list(sub_directory.glob("**/*.py"))
87170

88-
# Iterate through the individual example files
89-
for file in (inner_pbar := tqdm(py_files, leave=False)):
90-
# Include the name of the file to the toctree
91-
# Format it by replacing underscores and capitalizing the words
171+
for file in py_files:
172+
# Add the file to the folder's toctree, with a formatted display name
92173
file_name = file.stem
93174

94175
formatted = " ".join(word.capitalize() for word in file_name.split("_"))
95-
# Remove duplicate "constraints" for the files in the constraints folder.
176+
# Drop duplicate "Constraints" in the constraints folders
96177
if "Constraints" in folder_name and "Constraints" in formatted:
97178
formatted = formatted.replace("Constraints", "")
98179

99-
# Also format the Prodsum name to Product/Sum
180+
# Format "Prodsum" as "Product/Sum"
100181
if "Prodsum" in formatted:
101182
formatted = formatted.replace("Prodsum", "Product/Sum")
102183
subdir_toctree += formatted + f"<{file_name}>\n"
103184

104-
# If we ignore the examples, we do not want to actually execute or convert
105-
# anything. Still, due to existing links, it is necessary to construct a
106-
# dummy file and then continue.
185+
# In dummy mode, write a placeholder so links still resolve
107186
if dummy:
108187
markdown_path = file.with_suffix(".md")
109-
# Rewrite the file
110188
with open(markdown_path, "w", encoding="UTF-8") as markdown_file:
111189
markdown_file.writelines("# DUMMY FILE")
112190
continue
113191

114-
# Set description for progress bar
115-
inner_pbar.set_description(f"Progressing {folder_name}")
116-
117-
# Create the Markdown file:
118-
119-
# 1. Convert the file to jupyter notebook
120-
check_call(
121-
["jupytext", "--to", "notebook", file], stdout=DEVNULL, stderr=STDOUT
122-
)
123-
124-
notebook_path = file.with_suffix(".ipynb")
125-
126-
# 2. Execute the notebook and convert to markdown.
127-
# This is only done if we decide not to ignore the examples.
128-
# The creation of the files themselves and converting them to markdown still
129-
# happens since we need the files to check for link integrity.
130-
convert_execute = [
131-
"jupyter",
132-
"nbconvert",
133-
"--to",
134-
"notebook",
135-
"--inplace",
136-
notebook_path,
137-
]
138-
convert_execute.append("--execute")
139-
to_markdown = ["jupyter", "nbconvert", "--to", "markdown", notebook_path]
140-
env = os.environ | {"PYTHONPATH": os.getcwd()}
141-
check_call(convert_execute, stdout=DEVNULL, stderr=STDOUT, env=env)
142-
check_call(to_markdown, stdout=DEVNULL, stderr=STDOUT, env=env)
143-
144-
# CLEANUP
145-
markdown_path = file.with_suffix(".md")
146-
# We wrap lines which are too long as long as they do not contain a link.
147-
# To discover whether a line contains a link, we check if the string "]("
148-
# is contained.
149-
with open(markdown_path, encoding="UTF-8") as markdown_file:
150-
content = markdown_file.read()
151-
wrapped_lines = []
152-
ignored_substrings = (
153-
"![svg]",
154-
"![png]",
155-
"<Figure size",
156-
"it/s",
157-
"s/it",
158-
)
159-
for line in content.splitlines():
160-
# Skip formatter control lines so they don't appear in docs
161-
if "fmt: off" in line or "fmt: on" in line:
162-
continue
163-
if any(substring in line for substring in ignored_substrings):
164-
continue
165-
if (
166-
len(line) > 88
167-
and "](" not in line
168-
and not line.lstrip().startswith("#")
169-
):
170-
wrapped = textwrap.wrap(line, width=88)
171-
wrapped_lines.extend(wrapped)
172-
else:
173-
wrapped_lines.append(line)
174-
175-
# Add a manual new line to each of the lines
176-
lines = [line + "\n" for line in wrapped_lines]
177-
# Delete lines we do not want to have in our documentation
178-
# lines = [line for line in lines if "![svg]" not in line]
179-
# We check whether pre-built light and dark plots exist. If so, we append
180-
# corresponding lines to our markdown file for including them.
181-
# If not, we check if a single plot version exists and append it
182-
# regardless of light/dark mode.
183-
light_figure = Path(sub_directory / (file_name + "_light.svg"))
184-
dark_figure = Path(sub_directory / (file_name + "_dark.svg"))
185-
figure = Path(sub_directory / (file_name + ".svg"))
186-
if light_figure.is_file() and dark_figure.is_file():
187-
lines.append(f"```{{image}} {file_name}_light.svg\n")
188-
lines.append(":align: center\n")
189-
lines.append(":class: only-light\n")
190-
lines.append("```\n")
191-
lines.append(f"```{{image}} {file_name}_dark.svg\n")
192-
lines.append(":align: center\n")
193-
lines.append(":class: only-dark\n")
194-
lines.append("```\n")
195-
elif figure.is_file():
196-
lines.append(f"```{{image}} {file_name}.svg\n")
197-
lines.append(":align: center\n")
198-
lines.append("```\n")
199-
200-
# Rewrite the file
201-
with open(markdown_path, "w", encoding="UTF-8") as markdown_file:
202-
markdown_file.writelines(lines)
192+
files_to_convert.append((file, sub_directory))
203193

204194
# Write last line of toctree file for this directory and write the file
205195
subdir_toctree += "```"
@@ -208,6 +198,30 @@ def ignore_pycache(_, contents: list[str]):
208198
) as f:
209199
f.write(subdir_toctree)
210200

201+
# Second pass: convert the independent example files concurrently
202+
if files_to_convert:
203+
if max_workers is None:
204+
max_workers = os.cpu_count() or 1
205+
workers = max(1, min(max_workers, len(files_to_convert)))
206+
207+
with ThreadPoolExecutor(max_workers=workers) as executor:
208+
futures = {
209+
executor.submit(_convert_example, file, sub_directory): file
210+
for file, sub_directory in files_to_convert
211+
}
212+
# `result()` re-raises so a failed example aborts the build
213+
for future in tqdm(
214+
as_completed(futures), total=len(futures), desc="Converting examples"
215+
):
216+
file = futures[future]
217+
try:
218+
future.result()
219+
except Exception as ex:
220+
# Fail fast: cancel not-yet-started conversions instead of waiting
221+
# for the whole batch to finish before the error propagates.
222+
executor.shutdown(wait=False, cancel_futures=True)
223+
raise RuntimeError(f"Failed to convert example: {file}") from ex
224+
211225
# Append the ordered list of examples to the file for the top level folder
212226
ex_file += "".join(ex_order)
213227
# Write last line of top level toctree file and write the file

examples/Custom_Surrogates/custom_pretrained.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@
6060

6161
# Define model and fit
6262

63-
model = BayesianRidge()
64-
model.fit(train_x, train_y)
63+
model = BayesianRidge().fit(train_x, train_y)
6564

6665

6766
### Convert model to onnx
@@ -143,6 +142,7 @@
143142
}
144143

145144
### Model creation from dict (or json if string)
145+
146146
model_from_python = CustomONNXSurrogate(
147147
onnx_str=onnx_str, onnx_input_name=ONNX_INPUT_NAME
148148
)

0 commit comments

Comments
 (0)