Skip to content

Commit 2e28fd7

Browse files
authored
ENH: address long run-time of GPU tests (#127)
1 parent 5176700 commit 2e28fd7

5 files changed

Lines changed: 131 additions & 26 deletions

File tree

.github/scripts/build_dashboard.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,28 @@ def parse_junit(xml_path: Path) -> dict:
8585
}
8686

8787

88+
def parse_junit_dir(results_dir: Path) -> dict:
89+
"""Merge every ``test-results*.xml`` in ``results_dir`` into one summary.
90+
91+
The nightly suite runs as more than one pytest invocation, so that an
92+
overrun in one cannot take the other's results with it. Each writes its
93+
own JUnit XML and the dashboard reports their sum.
94+
"""
95+
reports = [
96+
parse_junit(path) for path in sorted(results_dir.glob("test-results*.xml"))
97+
]
98+
available = [report for report in reports if report["available"]]
99+
if not available:
100+
return parse_junit(results_dir / "test-results.xml")
101+
102+
merged = {
103+
key: sum(report[key] for report in available)
104+
for key in ("tests", "passed", "failed", "errors", "skipped")
105+
}
106+
merged["available"] = True
107+
return merged
108+
109+
88110
def parse_coverage(json_path: Path) -> dict:
89111
"""Return coverage percentage from a coverage.py JSON report."""
90112
if not json_path.exists():
@@ -357,7 +379,7 @@ def main() -> None:
357379
parser.add_argument(
358380
"--results-dir",
359381
default="results/",
360-
help="Directory containing test-results.xml and coverage.json",
382+
help="Directory containing the test-results*.xml files and coverage.json",
361383
)
362384
parser.add_argument(
363385
"--output-dir",
@@ -384,7 +406,7 @@ def main() -> None:
384406
)
385407

386408
data = {
387-
"junit": parse_junit(results_dir / "test-results.xml"),
409+
"junit": parse_junit_dir(results_dir),
388410
"coverage": parse_coverage(results_dir / "coverage.json"),
389411
"run_url": args.run_url,
390412
"timestamp": timestamp,

.github/workflows/nightly-health.yml

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ name: Nightly Health
99
# - Artifact "health-dashboard": index.html + status.json (90-day retention)
1010
# - Artifact "health-test-results": JUnit XML + coverage reports (90-day retention)
1111
#
12+
# The suite runs as two pytest invocations, core and tutorials, so that an
13+
# overrunning tutorial cannot destroy the core suite's results.
14+
#
1215
# The dashboard is NOT deployed to GitHub Pages to avoid overwriting the
1316
# documentation site published by docs.yml. Use the workflow status badge
1417
# for a live pass/fail indicator in README:
@@ -41,10 +44,10 @@ jobs:
4144
timeout-minutes: 360
4245

4346
outputs:
44-
# Captures the pytest step's actual outcome (success / failure / skipped)
45-
# for the dashboard. The job itself is failed by the gate step at the end,
47+
# Captures the pytest steps' combined outcome (success / failure) for
48+
# the dashboard. The job itself is failed by the gate step at the end,
4649
# after the artifacts have been uploaded.
47-
test-outcome: ${{ steps.run-tests.outcome }}
50+
test-outcome: ${{ (steps.run-core-tests.outcome == 'success' && steps.run-tutorial-tests.outcome == 'success') && 'success' || 'failure' }}
4851

4952
steps:
5053
- name: Checkout code
@@ -154,26 +157,65 @@ jobs:
154157
print(f'OK: {n} GPU(s) visible to PyTorch and CuPy')
155158
"
156159
157-
- name: Run health test suite
158-
id: run-tests
159-
# continue-on-error keeps the job running so artifacts are always uploaded.
160-
# The step outcome (success/failure) is still captured and passed downstream.
160+
# The suite runs in two invocations rather than one. pytest-timeout on
161+
# Windows can only kill the whole process, so a single overrunning
162+
# tutorial used to take the JUnit XML, the coverage and every other
163+
# test's result with it, leaving the dashboard nothing to render.
164+
# Splitting the runs bounds that blast radius to one of them, and the
165+
# tutorial run additionally isolates each test in an xdist worker.
166+
#
167+
# --max-test-seconds fails a test that finishes but took too long, so the
168+
# overrun is reported with its duration and the rest of the suite still
169+
# runs. --timeout is the pytest-timeout backstop above it, left to catch
170+
# a genuine hang and nothing else. Both steps are continue-on-error so
171+
# the artifacts are always uploaded; the gate step at the end fails the
172+
# job.
173+
- name: Run core test suite
174+
id: run-core-tests
175+
continue-on-error: true
176+
run: |
177+
pytest tests/ --ignore=tests/test_tutorials.py -v `
178+
--run-all --require-tutorial-data `
179+
--max-test-seconds=400 `
180+
--timeout=900 `
181+
--cov=physiotwin4d `
182+
--junitxml=test-results-core.xml
183+
env:
184+
CUDA_VISIBLE_DEVICES: 0
185+
# The datasets, the results and the trained networks live on the
186+
# runner's own disk rather than in the checkout, which
187+
# actions/checkout wipes every run. Each root has a "test" subtree
188+
# that this suite reads and writes, so a nightly run never touches a
189+
# full run's files, and the downsampled subsets the fixtures build
190+
# under <input>/test survive between runs instead of being rebuilt.
191+
# Unset, each falls back to its in-repo default; see data/README.md.
192+
PHYSIOTWIN_INPUT_DATA_DIR: D:\PhysioTwin4D\nightly-runner\data
193+
PHYSIOTWIN_OUTPUT_DATA_DIR: D:\PhysioTwin4D\nightly-runner\output
194+
PHYSIOTWIN_WEIGHTS_DIR: D:\PhysioTwin4D\nightly-runner\network_weights
195+
196+
- name: Run tutorial test suite
197+
id: run-tutorial-tests
161198
continue-on-error: true
162-
# --max-test-seconds fails a test that finishes but took too long, so the
163-
# overrun is reported with its duration and the rest of the suite still
164-
# runs. --timeout raises the pytest-timeout backstop above it: on
165-
# Windows that backstop can only kill the whole process, which would
166-
# take the JUnit XML and every other result with it, so it is left to
167-
# catch a genuine hang and nothing else. Both numbers are provisional
168-
# until the per-tutorial timings are measured.
199+
# -n 1 keeps the tutorials strictly serial -- they share one GPU, one
200+
# "test" output subtree, and several bootstrap their prerequisite
201+
# tutorial inline -- while running them in an xdist worker process.
202+
# pytest-timeout then kills the worker rather than the session: xdist
203+
# reports that test as crashed, starts a fresh worker and carries on,
204+
# so an overrun costs one tutorial instead of the whole run.
205+
#
206+
# --cov-append adds to the core run's data, and the reports are written
207+
# here so that they cover both runs.
169208
run: |
170-
pytest tests/ -v --run-all --require-tutorial-data `
171-
--max-test-seconds=900 `
172-
--timeout=3600 `
209+
pytest tests/test_tutorials.py -v `
210+
--run-all --require-tutorial-data `
211+
-n 1 --max-worker-restart=40 `
212+
--max-test-seconds=600 `
213+
--timeout=900 `
173214
--cov=physiotwin4d `
215+
--cov-append `
174216
--cov-report=xml `
175217
--cov-report=json `
176-
--junitxml=test-results.xml
218+
--junitxml=test-results-tutorials.xml
177219
env:
178220
CUDA_VISIBLE_DEVICES: 0
179221
# The datasets, the results and the trained networks live on the
@@ -193,7 +235,8 @@ jobs:
193235
with:
194236
name: health-test-results
195237
path: |
196-
test-results.xml
238+
test-results-core.xml
239+
test-results-tutorials.xml
197240
coverage.xml
198241
coverage.json
199242
retention-days: 90
@@ -204,9 +247,10 @@ jobs:
204247
# would report success even when tests failed or the step timed out,
205248
# and the workflow status badge would say green while nothing passed.
206249
# build-dashboard runs on if: always(), so it still gets its inputs.
207-
if: steps.run-tests.outcome != 'success'
250+
if: steps.run-core-tests.outcome != 'success' || steps.run-tutorial-tests.outcome != 'success'
208251
run: |
209-
Write-Output "Health test suite outcome: ${{ steps.run-tests.outcome }}"
252+
Write-Output "Core suite outcome: ${{ steps.run-core-tests.outcome }}"
253+
Write-Output "Tutorial suite outcome: ${{ steps.run-tutorial-tests.outcome }}"
210254
Write-Output "See the health-test-results artifact for the JUnit XML."
211255
exit 1
212256

tutorials/parameters_heart_ct_kcl.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ class ParametersHeartCTKCL(ParametersBase):
4646
is resampled to before it is meshed into tetrahedra, which is the
4747
resulting element size. Below the thinnest wall of the heart, so
4848
that the myocardium survives the coarsening.
49+
model_points: Points kept per surface when building the shape model.
50+
``0`` keeps every point, which is what a full run does.
51+
model_points_test: Same, under ``TestTools.running_as_test``, where the
52+
KCL meshes are read at full resolution because that dataset has no
53+
downsampled test subset.
4954
number_of_pca_components: PCA components retained when building the
5055
heart statistical model, and used when fitting it to a patient.
5156
number_of_pca_components_test: Same, under ``TestTools.running_as_test``.
@@ -85,6 +90,9 @@ class ParametersHeartCTKCL(ParametersBase):
8590
surface_reduction_rate: float = 0.5
8691
mesh_element_size_mm: float = 1.5
8792

93+
model_points: int = 0
94+
model_points_test: int = 20000
95+
8896
number_of_pca_components: int = 10
8997
number_of_pca_components_test: int = 5
9098

@@ -133,6 +141,10 @@ def pca_components(self, test_mode: bool) -> int:
133141
else (self.number_of_pca_components)
134142
)
135143

144+
def points_per_model(self, test_mode: bool) -> int:
145+
"""Return the per-surface point budget for this run mode."""
146+
return self.model_points_test if test_mode else self.model_points
147+
136148
def greedy_iterations(self, test_mode: bool) -> list[int]:
137149
"""Return the Greedy iteration schedule for this run mode."""
138150
return list(

tutorials/tutorial_06_heart_create_statistical_model.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from parameters_heart_ct_kcl import HEART_CT_KCL
2727

2828
from physiotwin4d import (
29+
ContourTools,
2930
TestTools,
3031
WorkflowCreateStatisticalModel,
3132
)
@@ -53,6 +54,11 @@
5354
data_dir = HEART_CT_KCL.input_directory(test_mode)
5455
number_of_pca_components = HEART_CT_KCL.pca_components(test_mode)
5556

57+
# Points kept per surface; 0 keeps every point. The KCL meshes are the one
58+
# dataset with no downsampled test subset, so test mode reduces them here
59+
# instead, as tutorial_06_duke_heart_create_statistical_model.py does.
60+
model_points = HEART_CT_KCL.points_per_model(test_mode)
61+
5662
# Distance-map weights finetuned by
5763
# tutorial_02_duke_heart_distancemap_finetune_icon.py. Stock uniGradICON weights
5864
# are out of distribution for distance maps, so without these the
@@ -93,21 +99,42 @@
9399
sample_files = [
94100
path for path in sample_files if HEART_CT_KCL.hold_out_case not in path.name
95101
]
102+
if test_mode:
103+
sample_files = sample_files[:3]
96104
if len(sample_files) < 3:
97105
raise FileNotFoundError(
98106
f"Need at least 3 sample meshes under {sample_dir} or {data_dir}.\n"
99107
"See data/README.md for download instructions."
100108
)
101109

102-
reference_mesh = cast(pv.DataSet, pv.read(str(reference_file)))
103-
sample_meshes = [cast(pv.DataSet, pv.read(str(path))) for path in sample_files]
110+
contour_tools = ContourTools(log_level=log_level)
111+
112+
def read_model_surface(path: Path) -> pv.DataSet:
113+
"""Read a mesh, reduced to ``model_points`` when a budget is set."""
114+
mesh = cast(pv.DataSet, pv.read(str(path)))
115+
if not model_points:
116+
return mesh
117+
surface = contour_tools.extract_surface(mesh)
118+
return cast(
119+
pv.DataSet,
120+
contour_tools.remesh_and_smooth_surface(
121+
surface, 1.0 - model_points / surface.n_points, 0
122+
),
123+
)
124+
125+
reference_mesh = read_model_surface(reference_file)
126+
sample_meshes = [read_model_surface(path) for path in sample_files]
104127

105128
# Workflow initialization
106129

107130
workflow = WorkflowCreateStatisticalModel(
108131
sample_meshes=sample_meshes,
109132
reference_mesh=reference_mesh,
110133
number_of_pca_components=number_of_pca_components,
134+
# The distance maps step 3 registers are rasterized at this resolution,
135+
# and generating, dilating and affinely registering them is what the
136+
# step costs. 2 mm is an eighth of the voxels of the 1 mm default.
137+
reference_spatial_resolution=2.0 if test_mode else 1.0,
111138
icp_transform_type=HEART_CT_KCL.icp_transform_type,
112139
mask_dilation_mm=HEART_CT_KCL.mask_dilation_mm,
113140
distance_squared_max=HEART_CT_KCL.distancemap_squared_max,

tutorials/tutorial_06_lung_create_statistical_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777

7878
# Atlas iterations used to build the reference surface; 1 is a single
7979
# template-biased pass.
80-
mean_surface_iterations = 3
80+
mean_surface_iterations = 1 if test_mode else 3
8181

8282
# Distance-map weights finetuned by
8383
# tutorial_02_lung_distancemap_finetune_icon.py. Stock uniGradICON weights

0 commit comments

Comments
 (0)