-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathjustfile
More file actions
463 lines (377 loc) · 19 KB
/
Copy pathjustfile
File metadata and controls
463 lines (377 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
set shell := ["bash", "-eu", "-o", "pipefail", "-c"]
# Pass recipe arguments through as real argv (preserves quoting, e.g. a
# `--mpiexec-args="--bind-to core"` value with spaces) via "$@" instead of the
# space-splitting `{{ ARGS }}` interpolation.
set positional-arguments := true
version := `uvx setuptools-scm | tr -d '\n'`
project_source_dir := `pwd | tr -d '\n'`
docs_dir := "build/docs"
html_dir := "build/docs/html"
bench_results := "benches/results"
# Fumadocs (Next.js) documentation site lives in `docs/`; the static export
# is written to `docs/out`.
site := "docs"
# Run the Python docs toolchain in the synced docs environment.
docs_uv := "uv run --group docs --group test --no-dev --all-extras --no-extra mpi"
uv_sync := "uv sync --no-progress --all-extras -v"
no_mpi_extra := "--no-extra mpi"
mpi_enabled := lowercase(env('monoprop_ENABLE_MPI', 'off'))
mpi_extra := if mpi_enabled =~ '^(on|true|yes|1)$' { "" } else { no_mpi_extra }
build_dir := "build/editable" / env('SKBUILD_CMAKE_BUILD_TYPE', 'Release')
mpiexec := "mpiexec --map-by :OVERSUBSCRIBE"
# Keep both OpenMPI 4 (ORTE) and 5 (PRRTE) spell oversubscription env-vars.
export OMPI_MCA_rmaps_base_oversubscribe := "1"
export PRTE_MCA_rmaps_default_mapping_policy := ":oversubscribe"
default: build-docs
build *ARGS:
{{ uv_sync }} {{ mpi_extra }} "$@"
# Output directory for `capture-baseline` / `diff-baseline` (gitignored).
baseline_dir := ".baseline-capture"
# Capture a golden baseline snapshot into `.baseline-capture/LABEL` (default "golden") via
# tools/capture-baseline.py. Run once on an unmodified tree to seed the golden baseline, then
# `just diff-baseline` after any change that must not move a term or a coefficient.
capture-baseline LABEL='golden':
uv run --no-sync python tools/capture-baseline.py --out "{{ baseline_dir }}/{{ LABEL }}"
# Rebuild monoprop and diff a fresh capture against a stored one (default "golden"). Byte-identical
# is the bar: term order is a regression signal, not an implementation detail.
diff-baseline AGAINST='golden':
just build --reinstall-package monoprop --no-cache
rm -rf "{{ baseline_dir }}/candidate"
just capture-baseline candidate
diff -rq "{{ baseline_dir }}/{{ AGAINST }}" "{{ baseline_dir }}/candidate"
# Report what the installed extension was actually built as.
info:
uv run --no-sync python -c 'import pprint, monoprop as mp; pprint.pprint({"version": mp.__version__, "variant": mp.__variant__, "compiler_flags": mp.__compiler_flags__, "MPI": mp.has_mpi})'
test: build test-py test-cpp
# The test legs below run against whatever is installed (--no-sync, so that a run cannot
# silently rebuild a differently configured wheel). LABEL, when given, names the JUnit
# report CI uploads.
test-py LABEL='':
uv run --no-sync pytest -r aR --durations=50 --durations-min=5.0 \
{{ if LABEL == '' { '' } else { '--junit-xml=pytest-' + LABEL + '.xml -o junit_family=legacy' } }}
test-cpp LABEL='':
ctest --test-dir {{ build_dir }} --output-on-failure --no-tests=error --label-exclude mpi \
{{ if LABEL == '' { '' } else { '--output-junit ctest-serial-' + LABEL + '.xml' } }}
# --no-tests=error: the MPI tests are registered only by an MPI-enabled build, so an
# empty set here means the build was misconfigured.
test-cpp-mpi LABEL='':
ctest --test-dir {{ build_dir }} --output-on-failure --no-tests=error --label-regex mpi \
{{ if LABEL == '' { '' } else { '--output-junit ctest-mpi-' + LABEL + '.xml' } }}
# RANKS is a single integer or a semicolon-separated list (e.g. "1;2;4"); the suite runs
# once per entry. Extra arguments go to pytest, e.g. `just test-py-mpi "1;2;4" -m mpi`.
test-py-mpi RANKS='' *PYTEST_ARGS:
#!/usr/bin/env bash
set -euo pipefail
shift 1
requested_ranks={{ quote(RANKS) }}
ranks="${requested_ranks:-${monoprop_MPI_TEST_PROCS:-2}}"
for r in ${ranks//;/ }; do
echo "Running the Python test suite with ${r} MPI rank(s)"
{{ mpiexec }} -n "$r" uv run --no-sync pytest tests --with-mpi -v "$@"
done
# Build MPI-enabled, then run every leg. The C++
# rank matrix is a build-time setting.
test-mpi RANKS='': && (test-py-mpi RANKS) test-cpp test-cpp-mpi
requested_ranks={{ quote(RANKS) }}; \
ranks="${requested_ranks:-${monoprop_MPI_TEST_PROCS:-2}}"; \
monoprop_ENABLE_MPI=ON {{ uv_sync }} --group workspace-test \
--reinstall-package monoprop --no-cache \
--config-settings-package="monoprop:cmake.define.monoprop_MPI_TEST_PROCS=${ranks}"
# Build and run a consumer project against the installed package.
test-find-package BUILD_DIR='build/find-package-smoke':
#!/usr/bin/env bash
set -euo pipefail
build_dir={{ quote(BUILD_DIR) }}
site_packages="$(uv run --no-sync python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')"
cmake -S cpp/tests/find_package_smoke -B "$build_dir" \
-Dmonoprop_DIR="$site_packages/monoprop/cmake"
cmake --build "$build_dir"
"$build_dir/smoke"
# The sanitizer legs run against a tree built with SKBUILD_CMAKE_BUILD_TYPE=AsanUbsan (or
# Tsan) and the matching monoprop_SANITIZER define.
#
# Only the C++ binary is fully instrumented, so the option sets differ per leg and cannot
# be hoisted to the environment.
ubsan_options := "halt_on_error=1:print_stacktrace=1"
sanitizer_log := project_source_dir / "sanitizer-log"
test-cpp-asan:
ASAN_OPTIONS="detect_leaks=1:leak_check_at_exit=1:detect_stack_use_after_return=1:detect_invalid_pointer_pairs=1:check_initialization_order=1:strict_init_order=1:strict_string_checks=1:halt_on_error=1" \
LSAN_OPTIONS="suppressions={{ project_source_dir }}/.github/lsan.supp" \
UBSAN_OPTIONS="{{ ubsan_options }}" \
ctest --test-dir {{ build_dir }} --output-on-failure
# CPython is uninstrumented, so the leak, pointer-pair and initialization checks are off
# here; the C++ leg covers those. ASan needs libstdc++ preloaded too, or its __cxa_throw
# interceptor does not resolve. pytest replaces stderr, so the reports go to log files.
test-py-asan:
LD_PRELOAD="$(g++ -print-file-name=libasan.so):$(g++ -print-file-name=libstdc++.so.6)" \
ASAN_OPTIONS="detect_leaks=0:detect_stack_use_after_return=1:halt_on_error=1:log_path={{ sanitizer_log }}" \
UBSAN_OPTIONS="{{ ubsan_options }}:log_path={{ sanitizer_log }}" \
uv run --no-sync pytest -r aR --durations=50 --durations-min=5.0
# Print what test-py-asan sent to the log files. Silent when the tests themselves failed.
sanitizer-reports:
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
files=({{ sanitizer_log }}.*)
if (( ${#files[@]} == 0 )); then
echo "No sanitizer report was written; the failure came from the tests themselves."
exit 0
fi
for f in "${files[@]}"; do
echo "::group::$(basename "$f")"
cat "$f"
echo "::endgroup::"
done
# TSan cannot load an instrumented _core into stock CPython, so this leg is C++ only, and
# restricted to the concurrent partition and shared-memory paths.
test-cpp-tsan:
TSAN_OPTIONS="halt_on_error=1:history_size=4" \
ctest --test-dir {{ build_dir }} --output-on-failure -R "(partition_|shm_comm_)"
# Collect one instrumented build. MPI must be "on" or "off"; each variant needs its own build
# and output directories because the preprocessor selects different compatibility paths.
code-coverage-collect MPI BUILD_DIR OUTPUT_DIR:
#!/usr/bin/env bash
set -euo pipefail
mpi={{ quote(MPI) }}
build_dir={{ quote(BUILD_DIR) }}
output_dir={{ quote(OUTPUT_DIR) }}
export GCOV_EXIT_AT_ERROR=1
if [[ "$mpi" != "on" && "$mpi" != "off" ]]; then
echo "MPI must be 'on' or 'off', got: $mpi" >&2
exit 2
fi
sync_args=({{ uv_sync }})
if [[ "$mpi" == "off" ]]; then
sync_args+=({{ no_mpi_extra }})
fi
sync_args+=(--group workspace-test --reinstall-package monoprop)
# The top-level recipe isolates local rebuilds from a wheel cached for the other variant.
if [[ "${monoprop_COVERAGE_NO_CACHE:-OFF}" == "ON" ]]; then
sync_args+=(--no-cache)
fi
SKBUILD_BUILD_DIR="$build_dir" \
SKBUILD_CMAKE_BUILD_TYPE=Coverage \
monoprop_ENABLE_MPI="$mpi" \
"${sync_args[@]}"
rm -rf "$output_dir"
mkdir -p "$output_dir"
find "$build_dir" -type f -name '*.gcda' -delete
uv run --no-sync coverage erase
uv run --no-sync coverage run --parallel-mode -m pytest -m "not mpi"
if [[ "$mpi" == "on" ]]; then
# The coverage lane runs in a container as root.
export OMPI_ALLOW_RUN_AS_ROOT=1
export OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1
{{ mpiexec }} -n 2 \
uv run --no-sync coverage run --parallel-mode \
-m pytest tests --with-mpi -m mpi
fi
gcovr_args=(
--gcov-executable gcov
--gcov-ignore-parse-errors
--exclude-throw-branches
--exclude-unreachable-branches
--filter '^(cpp|src)/'
--exclude '^(cpp/)?tests/'
--merge-lines
"$build_dir"
)
GCOV_EXIT_AT_ERROR=1 uvx gcovr "${gcovr_args[@]}" \
--json "$output_dir/cpp-through-python.json" \
--json-pretty
ctest --test-dir "$build_dir" \
--output-on-failure \
--no-tests=error \
--label-exclude mpi
if [[ "$mpi" == "on" ]]; then
ctest --test-dir "$build_dir" \
--output-on-failure \
--no-tests=error \
--label-regex mpi
fi
GCOV_EXIT_AT_ERROR=1 uvx gcovr "${gcovr_args[@]}" \
--json "$output_dir/cpp-total.json" \
--json-pretty
if [[ "$mpi" == "on" ]]; then
uv run --no-sync python - "$output_dir/cpp-total.json" <<'PY'
import json
import sys
from pathlib import Path
report = json.loads(Path(sys.argv[1]).read_text())
covered = []
for entry in report["files"]:
path = entry["file"].replace("\\", "/")
if "/detail/mpi/" not in f"/{path}":
continue
if any(line.get("count", 0) > 0 for line in entry.get("lines", [])):
covered.append(path)
if not any(path.endswith("/MPICompat.cpp") for path in covered):
raise SystemExit("MPICompat.cpp has no covered lines")
if len(covered) < 2:
raise SystemExit(f"expected coverage in at least two MPI sources, found: {covered}")
print("Covered MPI sources:", *covered, sep="\n ")
PY
fi
shopt -s nullglob
shards=(.coverage.*)
if (( ${#shards[@]} == 0 )); then
echo "No parallel Python coverage files were produced" >&2
exit 1
fi
mv "${shards[@]}" "$output_dir/"
# Combine the raw data from serial and MPI collection runs into the stable report names consumed by
# Codecov, SonarQube, and the local HTML recipe.
code-coverage-aggregate SERIAL_DIR MPI_DIR OUTPUT_DIR='.':
#!/usr/bin/env bash
set -euo pipefail
serial_dir={{ quote(SERIAL_DIR) }}
mpi_dir={{ quote(MPI_DIR) }}
output_dir={{ quote(OUTPUT_DIR) }}
mkdir -p "$output_dir"
rm -f "$output_dir/.coverage" \
"$output_dir/python-coverage.xml" \
"$output_dir/python-coverage.info" \
"$output_dir/cpp-coverage-through-python-bindings.xml" \
"$output_dir/cpp-coverage-through-python-bindings-sonar.xml" \
"$output_dir/cpp-coverage-through-python-bindings.info" \
"$output_dir/cpp-coverage.xml" \
"$output_dir/cpp-coverage-sonar.xml" \
"$output_dir/cpp-coverage.info"
uvx --from coverage coverage combine \
--data-file "$output_dir/.coverage" \
"$serial_dir" "$mpi_dir"
uvx --from coverage coverage xml \
--data-file "$output_dir/.coverage" \
-o "$output_dir/python-coverage.xml"
uvx --from coverage coverage lcov \
--data-file "$output_dir/.coverage" \
-o "$output_dir/python-coverage.info"
# coverage.py emits repo-relative SF: paths while gcovr emits absolute ones.
sed -i 's|^SF:\([^/]\)|SF:{{ project_source_dir }}/\1|' \
"$output_dir/python-coverage.info"
uvx gcovr \
--add-tracefile "$serial_dir/cpp-through-python.json" \
--add-tracefile "$mpi_dir/cpp-through-python.json" \
--cobertura "$output_dir/cpp-coverage-through-python-bindings.xml" \
--sonarqube "$output_dir/cpp-coverage-through-python-bindings-sonar.xml" \
--lcov "$output_dir/cpp-coverage-through-python-bindings.info"
uvx gcovr \
--add-tracefile "$serial_dir/cpp-total.json" \
--add-tracefile "$mpi_dir/cpp-total.json" \
--cobertura "$output_dir/cpp-coverage.xml" \
--sonarqube "$output_dir/cpp-coverage-sonar.xml" \
--lcov "$output_dir/cpp-coverage.info"
# Render combined reports locally. lcov and genhtml are intentionally unnecessary in CI.
code-coverage-html REPORT_DIR='.' OUTPUT_DIR='monoprop-coverage':
lcov \
--ignore-errors inconsistent,corrupt \
-a {{ quote(REPORT_DIR) }}/python-coverage.info \
-a {{ quote(REPORT_DIR) }}/cpp-coverage-through-python-bindings.info \
-a {{ quote(REPORT_DIR) }}/cpp-coverage.info \
-o {{ quote(REPORT_DIR) }}/merged.info
genhtml {{ quote(REPORT_DIR) }}/merged.info -o {{ quote(OUTPUT_DIR) }} \
--legend --title "monoprop coverage" \
--prefix {{ project_source_dir }} \
--ignore-errors inconsistent \
--verbose
# Build both compile-time variants, combine their reports, and render the local HTML report.
code-coverage:
monoprop_COVERAGE_NO_CACHE=ON just code-coverage-collect off build/coverage/mpi-off build/coverage-data/mpi-off
monoprop_COVERAGE_NO_CACHE=ON just code-coverage-collect on build/coverage/mpi-on build/coverage-data/mpi-on
just code-coverage-aggregate build/coverage-data/mpi-off build/coverage-data/mpi-on .
just code-coverage-html . monoprop-coverage
# Install the documentation site's JavaScript dependencies.
docs-install:
cd {{ site }} && npm ci
# Each LABEL is one column in results/REPORT.md, so serial / MPI / thread variants
# sit side by side. Set the thread count with the monoprop_NUM_THREADS env var.
# Uses `--no-sync` so a run never rebuilds monoprop with the default (MPI=OFF) and
# clobbers an MPI build; sync deps once first with `uv sync --all-groups
# --all-extras` (or `just bench-build-mpi` for MPI). Examples:
# just bench serial
# monoprop_NUM_THREADS=10 just bench serial-t10 --num-modes 64 --bench-rounds 10
# Run the suite (timing + memory) for one LABEL; extra args go to pytest.
bench LABEL *ARGS:
@mkdir -p "{{ bench_results }}"
label="$1"; shift; \
monoprop_BENCH_LABEL="$label" monoprop_BENCH_RESULTS="{{ bench_results }}" \
uv run --no-sync python -m pytest benches -o filterwarnings=default \
--benchmark-json="{{ bench_results }}/time-$label.json" "$@"
uv run --no-sync monoprop-bench-report "{{ bench_results }}"
# Needs an MPI build (`just bench-build-mpi`) -- a non-MPI build is rejected by the
# preflight. `monoprop_PARTITIONS` is mandatory above one rank (benches/conftest.py refuses
# the run without it). Extra args are passed to mpiexec for pinning (and, as root, add
# `--allow-run-as-root`), e.g.
# monoprop_PARTITIONS=2 monoprop_NUM_THREADS=2 just bench-mpi r5t2 5 --map-by slot:PE=2 --bind-to core
# Run under MPI: RANKS ranks recorded as one LABEL column.
bench-mpi LABEL RANKS *MPIARGS:
uv run --no-sync python -c "import monoprop, sys; sys.exit(0 if monoprop.has_mpi else 'monoprop was built without MPI; run just bench-build-mpi first')"
@mkdir -p "{{ bench_results }}"
label="$1"; ranks="$2"; shift 2; \
monoprop_BENCH_LABEL="$label" monoprop_BENCH_RESULTS="{{ bench_results }}" \
uv run --no-sync mpiexec -n "$ranks" \
-x monoprop_BENCH_LABEL -x monoprop_BENCH_RESULTS \
${monoprop_PARTITIONS+-x monoprop_PARTITIONS} \
${monoprop_NUM_THREADS+-x monoprop_NUM_THREADS} "$@" \
python -m pytest benches -o filterwarnings=default \
--benchmark-json="{{ bench_results }}/time-$label.json"
uv run --no-sync monoprop-bench-report "{{ bench_results }}"
# A plain `just bench` uses `--no-sync`, so this MPI build survives until the next
# explicit `uv sync` / rebuild.
# Rebuild monoprop with MPI enabled (editable). Run once before `just bench-mpi`.
bench-build-mpi:
monoprop_ENABLE_MPI=ON {{ uv_sync }} --group bench --reinstall-package monoprop --no-cache
# Quick sanity run: tiny sizes, skip the slow static benchmarks.
bench-smoke:
@mkdir -p "{{ bench_results }}"
monoprop_BENCH_LABEL=smoke monoprop_BENCH_RESULTS="{{ bench_results }}" \
uv run --no-sync python -m pytest benches -o filterwarnings=default \
--benchmark-json="{{ bench_results }}/time-smoke.json" \
-m "not slow" --num-generators 8 --num-modes 8 --cutoff 6 --obs-terms 16
uv run --no-sync monoprop-bench-report "{{ bench_results }}"
# Deliberately keeps the `bench` default sizes rather than inventing a second set
# to keep in sync: they run in well under a minute yet leave the heavier
# Schrödinger operations in the 50 ms - 1 s range, where runner noise does not
# swamp the signal. Only the slow fixed models are dropped; they run nightly.
# More rounds than a local run, because CI reports the mean.
#
# The marker expression and round count come from the environment so a caller can
# pass values containing spaces, or an empty marker to select everything:
# monoprop_BENCH_MARKERS= monoprop_BENCH_ROUNDS=3 just bench-ci ci-bare-metal
# Run the continuous-benchmarking profile tracked by Bencher.
bench-ci LABEL *ARGS:
@mkdir -p "{{ bench_results }}"
label="$1"; shift; \
monoprop_BENCH_LABEL="$label" monoprop_BENCH_RESULTS="{{ bench_results }}" \
uv run --no-sync python -m pytest benches -o filterwarnings=default \
--benchmark-json="{{ bench_results }}/time-$label.json" \
-m "${monoprop_BENCH_MARKERS-not slow}" \
--bench-rounds "${monoprop_BENCH_ROUNDS:-5}" "$@"
# Convert one LABEL's artifacts into Bencher Metric Format JSON on stdout, e.g.
# just bench-ci ci-linux && just bench-bmf ci-linux > bmf.json
# Emit LABEL's results as Bencher Metric Format JSON.
bench-bmf LABEL:
uv run --no-sync monoprop-bench-bmf "{{ bench_results }}" "$1"
# Execute the tutorial notebooks and convert them to Markdown. Notebook
# execution fails the build on any cell error -- this is the notebook doctest.
gen-notebooks:
{{ docs_uv }} python docs/scripts/notebooks_to_mdx.py
# Generate the Python API reference MDX from docstrings (griffe -> JSON -> MDX).
gen-api:
{{ docs_uv }} python {{ site }}/scripts/gen_api_dump.py monoprop -d {{ site }}
cd {{ site }} && node scripts/generate-api.mjs
# Run the runnable docstring examples (the docstring-level doctest check).
doctest-py:
{{ docs_uv }} python -m pytest --doctest-modules src/monoprop
doctest-docs:
{{ docs_uv }} python -m pytest --markdown-docs docs/content/docs --ignore=docs/content/docs/tutorials --ignore=docs/content/docs/api
{{ docs_uv }} python -m pytest --markdown-docs README.md
# Build the static documentation site into `docs/out`.
build-docs: docs-install gen-api doctest-py doctest-docs gen-notebooks
cd {{ site }} && npm run build
# Check exported HTML links (including external URLs).
check-doc-links:
lychee --config .lychee.postbuild.toml 'docs/out/**/*.html'
# Serve the documentation locally with hot reloading.
serve-docs:
cd {{ site }} && npm run dev