Skip to content

Commit 7d7b765

Browse files
authored
Speed up documentation deployments (#804)
## Summary Speeds up CI docs builds from a median 32m04s to 8m39s (3.7x). - download up to four independent documentation prefixes from S3 concurrently - customize HTML files with a process pool sized to the CPUs available to the job - replace repeated full-document CSS selector scans with one consolidated document inspection - reduce post-processing output to periodic progress updates while retaining warnings - preserve the existing `html5lib` parser and byte-for-byte serialized output Closes #448. ## Validation - verified optimized HTML output is byte-identical to the original serial implementation - verified a second customization pass remains byte-idempotent - verified the S3 scheduler never exceeds four concurrent prefixes - verified a failed background S3 copy fails the overall script - ran `pre-commit run --all-files` Authors: - Bradley Dice (https://github.com/bdice) Approvers: - James Lamb (https://github.com/jameslamb) URL: #804
1 parent b6afa0c commit 7d7b765

2 files changed

Lines changed: 184 additions & 107 deletions

File tree

ci/customization/customize_doc.py

Lines changed: 146 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import os
1111
import re
1212
import sys
13-
from copy import deepcopy
13+
from concurrent.futures import ProcessPoolExecutor
1414

1515
import yaml
1616

@@ -23,6 +23,44 @@
2323
NVIDIA_STYLE_TAG_ID = "nvidia-selector-css"
2424
FA_TAG_ID = "rapids-fa-tag"
2525

26+
LIB_PATH_DICT = None
27+
PROJECT_TO_VERSIONS_DICT = None
28+
SELECTOR_PROJECT_NAMES = None
29+
30+
31+
def initialize_worker(
32+
lib_path_dict: dict,
33+
project_to_versions_dict: dict,
34+
selector_project_names: set[str],
35+
) -> None:
36+
"""Initializes read-only configuration used by each worker process."""
37+
global LIB_PATH_DICT, PROJECT_TO_VERSIONS_DICT, SELECTOR_PROJECT_NAMES
38+
LIB_PATH_DICT = lib_path_dict
39+
PROJECT_TO_VERSIONS_DICT = project_to_versions_dict
40+
SELECTOR_PROJECT_NAMES = selector_project_names
41+
42+
43+
def customize_manifest_file(filepath: str) -> None:
44+
"""Customizes one HTML file from the generated manifest."""
45+
project_name = get_lib_from_fp(
46+
lib_path_dict=LIB_PATH_DICT,
47+
filepath=filepath,
48+
)
49+
main(
50+
filepath=filepath,
51+
lib_path_dict=LIB_PATH_DICT,
52+
project_name=project_name,
53+
versions_dict=PROJECT_TO_VERSIONS_DICT[project_name],
54+
selector_project_names=SELECTOR_PROJECT_NAMES,
55+
)
56+
57+
58+
def process_count() -> int:
59+
"""Returns the number of CPUs available to this process, like nproc."""
60+
if hasattr(os, "sched_getaffinity"):
61+
return max(1, len(os.sched_getaffinity(0)))
62+
return max(1, os.cpu_count() or 1)
63+
2664

2765
def get_version_from_fp(*, filepath: str, versions_dict: dict):
2866
"""
@@ -130,7 +168,6 @@ def create_version_options(
130168
option_href = version_path
131169
version_text = f"{version_name} ({version_number_str})"
132170
if version_name == doc_version["name"]:
133-
print(f"default version: {version_name}")
134171
is_selected = True
135172
options.append(
136173
{"selected": is_selected, "href": option_href, "text": version_text}
@@ -159,7 +196,6 @@ def create_library_options(
159196
continue
160197
is_selected = False
161198
if lib == project_name:
162-
print(f"default lib: {lib}")
163199
is_selected = True
164200
options.append({"selected": is_selected, "href": option_href, "text": lib})
165201

@@ -225,21 +261,11 @@ def create_pixel_tags(soup):
225261
return [head_tag, body_tag]
226262

227263

228-
def uses_nvidia_sphinx_theme(soup) -> bool:
229-
"""
230-
Returns whether the document already uses the NVIDIA Sphinx Theme.
231-
"""
232-
return any(
233-
"nvidia-sphinx-theme" in link.get("href", "")
234-
for link in soup.find_all("link", href=True)
235-
)
236-
237-
238-
def create_css_link_tag(soup):
264+
def create_css_link_tag(soup, *, is_nvidia_theme: bool):
239265
"""
240266
Creates and returns the stylesheet tag for the injected selectors.
241267
"""
242-
if uses_nvidia_sphinx_theme(soup):
268+
if is_nvidia_theme:
243269
return soup.new_tag(
244270
"link",
245271
id=NVIDIA_STYLE_TAG_ID,
@@ -253,51 +279,86 @@ def create_css_link_tag(soup):
253279
return script_tag
254280

255281

256-
def delete_element(soup, selector):
257-
"""
258-
Deletes element from soup object if it already exists
259-
"""
260-
try:
261-
soup.select(f"{selector}")[0].extract()
262-
except Exception:
263-
pass
264-
265-
266-
def delete_rapids_custom_css_links(soup):
282+
def delete_rapids_custom_css_links(links):
267283
"""
268284
Deletes global RAPIDS custom CSS links from NVIDIA-themed pages.
269285
"""
270-
for link in soup.find_all("link", href=True):
271-
if link["href"].endswith("/assets/css/custom.css"):
272-
link.extract()
286+
for link in links:
287+
link.extract()
273288

274289

275-
def delete_existing_elements(soup):
290+
def delete_existing_elements(elements, *, doc_type: str, reference_el):
276291
"""
277292
Deletes any existing page elements to prevent duplicates on the page
278293
"""
279-
doxygen_title_area = "#titlearea > table"
280-
sphinx_home_btn = ".wy-side-nav-search .icon.icon-home"
281-
sphinx_doc_version = ".wy-side-nav-search .version"
282-
existing_jtd_container = "#rapids-jtd-container"
283-
existing_pydata_container = "#rapids-pydata-container"
284-
existing_doxygen_container = "#rapids-doxygen-container"
285-
286-
for element in [
287-
existing_jtd_container,
288-
existing_pydata_container,
289-
existing_doxygen_container,
290-
sphinx_doc_version,
291-
sphinx_home_btn,
292-
doxygen_title_area,
293-
f"#{SCRIPT_TAG_ID}",
294-
f"#{STYLE_TAG_ID}",
295-
f"#{NVIDIA_STYLE_TAG_ID}",
296-
f"#{FA_TAG_ID}",
297-
f"#{PIXEL_SRC_TAG_ID}",
298-
f"#{PIXEL_INVOCATION_TAG_ID}",
299-
]:
300-
delete_element(soup, element)
294+
for element in elements:
295+
element.extract()
296+
297+
if doc_type == "doxygen":
298+
if table := reference_el.find("table", recursive=False):
299+
table.extract()
300+
301+
if doc_type == "jtd":
302+
if version := reference_el.find(class_="version"):
303+
version.extract()
304+
if home_button := reference_el.find(
305+
lambda tag: {"icon", "icon-home"}.issubset(tag.get("class", []))
306+
):
307+
home_button.extract()
308+
309+
310+
def inspect_document(soup, *, filepath: str):
311+
"""Collects theme and customization state in one document traversal."""
312+
removable_ids = {
313+
"rapids-jtd-container",
314+
"rapids-pydata-container",
315+
"rapids-doxygen-container",
316+
SCRIPT_TAG_ID,
317+
STYLE_TAG_ID,
318+
NVIDIA_STYLE_TAG_ID,
319+
FA_TAG_ID,
320+
PIXEL_SRC_TAG_ID,
321+
PIXEL_INVOCATION_TAG_ID,
322+
}
323+
existing_elements = []
324+
rapids_css_links = []
325+
references = {}
326+
is_nvidia_theme = False
327+
328+
for element in soup.find_all(True):
329+
element_id = element.get("id")
330+
if element_id in removable_ids:
331+
existing_elements.append(element)
332+
333+
classes = element.get("class", [])
334+
if "wy-side-nav-search" in classes and "jtd" not in references:
335+
references["jtd"] = element
336+
elif element_id == "titlearea" and "doxygen" not in references:
337+
references["doxygen"] = element
338+
elif "bd-sidebar" in classes and "pydata" not in references:
339+
references["pydata"] = element
340+
341+
if element.name == "link" and (href := element.get("href")):
342+
if "nvidia-sphinx-theme" in href:
343+
is_nvidia_theme = True
344+
if element_id not in removable_ids and href.endswith(
345+
"/assets/css/custom.css"
346+
):
347+
rapids_css_links.append(element)
348+
349+
for doc_type in ("jtd", "doxygen", "pydata"):
350+
if doc_type in references:
351+
return (
352+
doc_type,
353+
references[doc_type],
354+
is_nvidia_theme,
355+
existing_elements,
356+
rapids_css_links,
357+
)
358+
359+
raise UnsupportedThemeError(
360+
f"Couldn't identify {filepath} as a supported theme type. Skipping file."
361+
)
301362

302363

303364
class UnsupportedThemeError(ValueError):
@@ -309,33 +370,6 @@ class UnsupportedThemeError(ValueError):
309370
pass
310371

311372

312-
def get_theme_info(soup, *, filepath: str):
313-
"""
314-
Determines what theme a given HTML file is using or exits if it's
315-
not able to be determined. Returns a string identifier and reference element
316-
that is used for inserting the library/version selectors to the doc.
317-
"""
318-
# Sphinx Themes
319-
jtd_identifier = ".wy-side-nav-search" # Just-the-docs theme
320-
pydata_identifier = ".bd-sidebar" # Pydata theme
321-
322-
# Doxygen
323-
doxygen_identifier = "#titlearea"
324-
325-
if soup.select(jtd_identifier):
326-
return "jtd", soup.select(jtd_identifier)[0]
327-
328-
if soup.select(doxygen_identifier):
329-
return "doxygen", soup.select(doxygen_identifier)[0]
330-
331-
if soup.select(pydata_identifier):
332-
return "pydata", soup.select(pydata_identifier)[0]
333-
334-
raise UnsupportedThemeError(
335-
f"Couldn't identify {filepath} as a supported theme type. Skipping file."
336-
)
337-
338-
339373
def main(
340374
*,
341375
filepath: str,
@@ -349,21 +383,29 @@ def main(
349383
parse the file and add library/version selectors and a Home button
350384
"""
351385

352-
print(f"--- {filepath} ---")
353-
354386
with open(filepath) as fp:
355387
soup = BeautifulSoup(fp, "html5lib")
356388

357389
try:
358-
doc_type, reference_el = get_theme_info(soup, filepath=filepath)
390+
(
391+
doc_type,
392+
reference_el,
393+
is_nvidia_theme,
394+
existing_elements,
395+
rapids_css_links,
396+
) = inspect_document(soup, filepath=filepath)
359397
except UnsupportedThemeError as err:
360398
print(f"{str(err)}", file=sys.stderr)
361399
return
362400

363401
# Delete any existing added/unnecessary elements
364-
delete_existing_elements(soup)
365-
if uses_nvidia_sphinx_theme(soup):
366-
delete_rapids_custom_css_links(soup)
402+
delete_existing_elements(
403+
existing_elements,
404+
doc_type=doc_type,
405+
reference_el=reference_el,
406+
)
407+
if is_nvidia_theme:
408+
delete_rapids_custom_css_links(rapids_css_links)
367409

368410
# Add Font Awesome to Doxygen for icons
369411
if doc_type == "doxygen":
@@ -392,7 +434,7 @@ def main(
392434
container = soup.new_tag("div", id=f"rapids-{doc_type}-container")
393435
script_tag = create_script_tag(soup)
394436
[pix_head_tag, pix_body_tag] = create_pixel_tags(soup)
395-
style_tab = create_css_link_tag(soup)
437+
style_tab = create_css_link_tag(soup, is_nvidia_theme=is_nvidia_theme)
396438

397439
# Append elements to container
398440
container.append(home_btn_container)
@@ -428,21 +470,21 @@ def main(
428470
SELECTOR_PROJECT_NAMES = get_selector_project_names(docs_yml_path=DOCS_YML_PATH)
429471

430472
with open(MANIFEST_FILEPATH) as manifest_file:
431-
for line in manifest_file:
432-
filepath = line.strip()
433-
434-
lib_path_dict = deepcopy(LIB_PATH_DICT)
435-
436-
# determine project name (e.g. 'cudf')
437-
project_name = get_lib_from_fp(
438-
lib_path_dict=lib_path_dict,
439-
filepath=filepath,
440-
)
441-
442-
main(
443-
filepath=filepath,
444-
lib_path_dict=lib_path_dict,
445-
project_name=project_name,
446-
versions_dict=deepcopy(PROJECT_TO_VERSIONS_DICT[project_name]),
447-
selector_project_names=SELECTOR_PROJECT_NAMES,
448-
)
473+
filepaths = [line.strip() for line in manifest_file if line.strip()]
474+
475+
with ProcessPoolExecutor(
476+
max_workers=process_count(),
477+
initializer=initialize_worker,
478+
initargs=(
479+
LIB_PATH_DICT,
480+
PROJECT_TO_VERSIONS_DICT,
481+
SELECTOR_PROJECT_NAMES,
482+
),
483+
) as executor:
484+
results = executor.map(customize_manifest_file, filepaths, chunksize=8)
485+
for completed, _ in enumerate(results, start=1):
486+
if completed % 1000 == 0 or completed == len(filepaths):
487+
print(
488+
f"Customized {completed}/{len(filepaths)} HTML files",
489+
flush=True,
490+
)

0 commit comments

Comments
 (0)