Skip to content

Commit bf494d4

Browse files
authored
Speed up collisional STL wall CI and retain full-tier coverage (#592)
Replace the hosted 192-marker wall test with a fast tier that shares field/wall initialization and traces reduced ensembles with four OpenMP threads. Keep the original full test byte-for-byte unchanged, label it `slow`, and enable it in the sCluster merge-queue full tier. What to review: - A small test-only Fortran driver calls the same configuration, initialization, collision, tracing and output routines as `simple.x`. It rebuilds per-case parameters and collision coefficients while retaining the common field/wall. CTest reserves four CPUs and 2 GiB. - The fast case retains the 0.5 ms duration, launch clearance, field/wall resolution, integration tolerances, hit geometry, 64/128 segment convergence, vacuum equivalence, slowing down and exact seeded repeat. It uses 32 markers for vacuum comparisons and 64 for collisions, plus five known impacts for convergence. - Numerical drift budgets remain ±2 hits and 3% mean impact time, with the reference mean recalculated for the exact deterministic subset. Slowing down uses P[Binomial(n, 1/2) ≥ k] ≤ 0.05 for delayed impacts below E/E0 = 0.99, plus the original mean-energy condition. At least five slowed impacts are needed (1/32). - The hosted selector runs only the fast variant. Full bundles carry CGAL and map2disc's transitive Git mirrors; the full tier enables CGAL and checks that CTest selects the original slow test. Verified locally (CGAL ON, Fast, deterministic FP, hosted libneo pin): - Original full: PASS, 243.36 s; 21 collisionless and 30 collisional impacts, including 26 delayed, slowed impacts. - Final fast: PASS, 19.55 s locally with four-CPU affinity; **32.87 s on GitHub-hosted ubuntu-24.04**. Exact Make/CGAL selection: 6/6 PASS in 35.33 s locally and 55.01 s hosted, including all 44 triangle heat-load checks. - All 50 recorded result arrays across the five shared-initialization cases exactly match fresh independent `simple.x` runs. Fast impacts: 5/32 collisionless and 9/64 collisional; eight delayed impacts all slowed, p = 1/256 = 0.00390625, mean E/E0 = 0.539105024. - Full profiling: 242.507 s inside executables (129.191 s tracing, 113.316 s setup/output), 0.853 s Python/startup. An instrumented full collisional run measured 834,035 wall queries / 6.548 s within 34.131 s tracing. Earlier hosted profiling confirmed repeated setup was the remaining bottleneck. Profiling code stays outside the diff. - Native fast/slow selections, a real full bundle with network Git protocols disabled, changed-line lint, shell syntax and workflow validation pass. ShellCheck/actionlint integration has only existing sourced-file/unreachable-code notices. Hypothesis: at the full-case delayed-slowed rate 26/192, 64 markers yield 8.67 such impacts on average and a 94.6% chance of at least five (binomial planning estimate). The sign test assumes independent marker streams; CI uses a fixed seed. The hosted fast-test runtime below 60 s is verified by the final implementation's execution log. Chris&AI
2 parents f189f16 + b3f07a5 commit bf494d4

6 files changed

Lines changed: 312 additions & 6 deletions

File tree

‎.github/workflows/main.yml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ jobs:
129129
esac
130130
echo "tier: $tier"
131131
export TMPDIR=$RUNNER_TEMP
132-
ci/bundle.sh "$RUNNER_TEMP/bundle.tar.zst"
132+
SCI_TIER=$tier ci/bundle.sh "$RUNNER_TEMP/bundle.tar.zst"
133133
sci-dispatch "$tier" "$RUNNER_TEMP/bundle.tar.zst"
134134
135135
- name: Set up Python
@@ -460,8 +460,9 @@ jobs:
460460
REF="${{ inputs.libneo_ref || '924ba7735366d95c2c6855bf00bd0e46b8c05cf2' }}"
461461
make test CONFIG=Fast \
462462
FLAGS="-DSIMPLE_ENABLE_CGAL=ON -DENABLE_PYTHON_INTERFACE=ON -DSIMPLE_ENABLE_PYTHON_TOOLS=ON -DLIBNEO_REF=$REF" \
463-
TEST='test_chartmap_metadata|test_stl_wall_intersection|test_collisional_stl_wall|test_triangle_heat_load|test_profiles' VERBOSE=1
463+
TEST='test_chartmap_metadata|test_stl_wall_intersection|test_collisional_stl_wall_fast|test_triangle_heat_load|test_profiles' VERBOSE=1
464464
# A missing Python interface must not silently omit the integration test.
465+
ctest --test-dir build/test -N -R '^test_collisional_stl_wall_fast$' | grep 'Test.*test_collisional_stl_wall_fast'
465466
ctest --test-dir build/test -N -R '^test_triangle_heat_load$' | grep 'Test.*test_triangle_heat_load'
466467
467468
- name: Upload wall-test results
@@ -471,7 +472,7 @@ jobs:
471472
name: cgal-wall-results
472473
path: |
473474
build/test/Testing/
474-
build/test/tests/collisional_stl_wall/
475+
build/test/tests/collisional_stl_wall_fast/
475476
476477
vmecpp:
477478
name: VMEC++ Direct Geometry

‎ci/bundle.sh‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# Environment:
1111
# SCI_HEAD_REF branch name to check out on (default: current branch)
1212
# SCI_LIBNEO_REF libneo ref for the build (default: branch matching)
13+
# SCI_TIER full bundles include the CGAL headers (default: full)
1314
# SCI_CACHE mirror and data cache (default: ~/.cache/sci-bundle)
1415
set -euo pipefail
1516

@@ -21,6 +22,9 @@ head_ref=${SCI_HEAD_REF:-$(git -C "$src" rev-parse --abbrev-ref HEAD)}
2122
git check-ref-format --branch "$head_ref" > /dev/null
2223

2324
deps="itpplasma/libneo itpplasma/map2disc lazy-fortran/fortnum lazy-fortran/fortplot lazy-fortran/fortio"
25+
if [ "${SCI_TIER:-full}" = full ]; then
26+
deps="$deps dbstein/pybie2d dbstein/function_generator"
27+
fi
2428

2529
# url<TAB>file name in test/test_data (shared with the golden-record runner).
2630
data_urls="https://github.com/hiddenSymmetries/simsopt/raw/master/tests/test_files/wout_LandremanPaul2021_QA_reactorScale_lowres_reference.nc wout.nc
@@ -43,7 +47,11 @@ update_mirror() {
4347
git -C "$dir" config --add remote.origin.fetch '+refs/tags/*:refs/tags/*'
4448
fi
4549
git -C "$dir" fetch -q --prune origin
46-
git -C "$dir" symbolic-ref HEAD refs/heads/main
50+
# map2disc's two Git dependencies use master; our libraries use main.
51+
case $slug in
52+
dbstein/*) git -C "$dir" symbolic-ref HEAD refs/heads/master ;;
53+
*) git -C "$dir" symbolic-ref HEAD refs/heads/main ;;
54+
esac
4755
}
4856

4957
for slug in $deps; do
@@ -73,6 +81,17 @@ while IFS=$'\t' read -r url name; do
7381
printf '%s\t%s\n' "$url" "$name" >> "$stage/data/urls.tsv"
7482
done <<< "$data_urls"
7583

84+
# Compute nodes cannot fetch release archives. Keep CGAL out of fast bundles.
85+
if [ "${SCI_TIER:-full}" = full ]; then
86+
cgal=CGAL-6.0.1.tar.xz
87+
if [ ! -s "$cache/data/$cgal" ]; then
88+
curl -fsSL --retry 3 -o "$cache/data/$cgal.part" \
89+
"https://github.com/CGAL/cgal/releases/download/v6.0.1/$cgal"
90+
mv "$cache/data/$cgal.part" "$cache/data/$cgal"
91+
fi
92+
cp "$cache/data/$cgal" "$stage/data/$cgal"
93+
fi
94+
7695
{
7796
printf 'SCI_COMMIT=%s\n' "$(git -C "$src" rev-parse HEAD)"
7897
printf 'SCI_HEAD_REF=%s\n' "$head_ref"

‎ci/run.sh‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ fast)
115115
;;
116116
full)
117117
python_env || exit
118+
# Enable the unchanged collisional wall test in the merge-queue slow set.
119+
# CGAL's release archive is supplied offline by ci/bundle.sh.
120+
mkdir -p "$root/cgal" &&
121+
tar -xf "$root/data/CGAL-6.0.1.tar.xz" -C "$root/cgal" --strip-components=1 || exit
122+
python -m pip install -q 'numpy==2.2.6' 'scipy==1.16.3' vtk \
123+
'git+https://github.com/itpplasma/map2disc.git@14da2f094ab2505a639b9cd04af57f5c0dbfaa54' || exit
124+
make_args+=("FLAGS=-DSIMPLE_ENABLE_CGAL=ON -DFETCHCONTENT_SOURCE_DIR_CGAL=$root/cgal")
118125
# Second tree for the regression build (it needs its own build/ and a
119126
# different libneo); started first because it holds the critical path.
120127
cp -a "$src" "$root/src-reg"
@@ -124,7 +131,9 @@ full)
124131
(cd "$root/src-reg" && regression_suite "$jb" "$tb") > "$root/regression.log" 2>&1 &
125132
reg_pid=$!
126133
stamp "regression started in background (log follows at the end)"
127-
fast_suite "$ja" && stamp "test-slow (ctest -j$ja)" && make test-slow
134+
fast_suite "$ja" && stamp "test-slow (ctest -j$ja)" &&
135+
(cd build && ctest --test-dir test -N -L slow -R '^test_collisional_stl_wall$' |
136+
grep 'Test.*test_collisional_stl_wall$') && make test-slow
128137
rc_a=$?
129138
stamp "fast/slow finished: exit $rc_a; waiting for regression"
130139
wait "$reg_pid"; rc_b=$?

‎test/tests/CMakeLists.txt‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,11 +252,30 @@ if(SIMPLE_ENABLE_PYTHON_TOOLS AND SIMPLE_HAVE_MAP2DISC)
252252
${CMAKE_CURRENT_BINARY_DIR}/wout_ncsx.nc
253253
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
254254
set_tests_properties(test_collisional_stl_wall PROPERTIES
255-
LABELS "integration;python;wall"
255+
LABELS "integration;python;wall;slow"
256256
RESOURCE_GROUPS "mem_gb:1"
257257
TIMEOUT 1200
258258
PROCESSORS 1
259259
FIXTURES_REQUIRED chartmap_ncsx_map2disc_wall)
260+
if(UNIX)
261+
add_executable(test_collisional_stl_wall_fast.x
262+
test_collisional_stl_wall_fast.f90)
263+
target_link_libraries(test_collisional_stl_wall_fast.x PRIVATE simple)
264+
add_test(NAME test_collisional_stl_wall_fast
265+
COMMAND ${Python3_EXECUTABLE}
266+
${CMAKE_CURRENT_SOURCE_DIR}/test_collisional_stl_wall_fast.py
267+
$<TARGET_FILE:test_collisional_stl_wall_fast.x>
268+
${CHARTMAP_NCSX_MAP2DISC_WALL_FILE}
269+
${CMAKE_CURRENT_BINARY_DIR}/coils.simple
270+
${CMAKE_CURRENT_BINARY_DIR}/wout_ncsx.nc
271+
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
272+
set_tests_properties(test_collisional_stl_wall_fast PROPERTIES
273+
LABELS "integration;python;wall"
274+
RESOURCE_GROUPS "mem_gb:2"
275+
TIMEOUT 120
276+
PROCESSORS 4
277+
FIXTURES_REQUIRED chartmap_ncsx_map2disc_wall)
278+
endif()
260279
endif()
261280

262281
add_test(NAME test_chartmap_ncsx_map2disc_wall_offset
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
! Share immutable field/wall initialization; run every case through the same
2+
! production routines as simple.x, rebuilding parameters and collisions per case.
3+
program test_collisional_stl_wall_fast
4+
use, intrinsic :: iso_fortran_env, only: dp => real64
5+
use, intrinsic :: iso_c_binding, only: c_char, c_int, c_null_char
6+
use simple, only: tracer_t
7+
use simple_main, only: load_config, init_run, init_sampling_field, &
8+
init_collisions, sample_start, init_tracing_field, &
9+
init_counters, trace_parallel, write_output
10+
use params, only: params_init, swcoll
11+
use diag_counters, only: diag_counters_init
12+
use timing, only: init_timer, get_wtime
13+
implicit none
14+
15+
interface
16+
function change_directory(path) bind(C, name='chdir') result(status)
17+
import c_char, c_int
18+
character(kind=c_char), intent(in) :: path(*)
19+
integer(c_int) :: status
20+
end function change_directory
21+
end interface
22+
23+
type(tracer_t) :: tracer
24+
character(len=4096) :: directory
25+
integer :: i
26+
real(dp) :: start
27+
28+
call init_timer
29+
call load_config('simple.in')
30+
call init_run(tracer)
31+
call init_sampling_field
32+
do i = 1, command_argument_count()
33+
call get_command_argument(i, directory)
34+
if (change_directory(trim(directory)//c_null_char) /= 0) &
35+
error stop 'cannot enter wall-test case directory'
36+
start = get_wtime()
37+
call load_config('simple.in')
38+
call params_init
39+
if (swcoll) call init_collisions
40+
call sample_start
41+
call init_tracing_field
42+
call init_counters
43+
call diag_counters_init
44+
call trace_parallel(tracer)
45+
call write_output
46+
print *, 'Case ', trim(directory), ' tracing/output seconds: ', &
47+
get_wtime() - start
48+
end do
49+
end program test_collisional_stl_wall_fast
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
#!/usr/bin/env python3
2+
"""Reduced ensembles; the unchanged 192-marker test remains the full-tier oracle."""
3+
4+
from math import comb
5+
from pathlib import Path
6+
import os
7+
import subprocess
8+
import sys
9+
from time import perf_counter
10+
11+
from netCDF4 import Dataset
12+
import numpy as np
13+
14+
import test_collisional_stl_wall as full
15+
16+
17+
def check_hits(result, name):
18+
hit = result["wall_hit"] == 1
19+
assert np.any(hit), f"{name}: no STL hits"
20+
assert np.all(result["orbit_exit_code"][hit] == 2), name
21+
assert np.all(result["times_lost"][hit] > 10 * full.TRANSIT_TIME), name
22+
assert np.all(result["times_lost"][hit] <= full.TRACE_TIME), name
23+
assert np.all(np.isfinite(result["zend"][hit])), name
24+
angle = result["wall_hit_angle_rad"][hit]
25+
assert np.all((angle >= 0) & (angle <= np.pi / 2)), name
26+
np.testing.assert_allclose(
27+
result["xend_cart"][hit], result["wall_hit_cart"][hit], atol=1e-6, rtol=0
28+
)
29+
np.testing.assert_allclose(
30+
np.linalg.norm(result["wall_hit_normal_cart"][hit], axis=1),
31+
1,
32+
atol=1e-12,
33+
rtol=0,
34+
)
35+
print(f"{name}: {hit.sum()}/{len(hit)} STL hits", flush=True)
36+
return hit
37+
38+
39+
def write_case(base, case):
40+
name, ensemble, collisions, intervals = case
41+
work = base / name
42+
work.mkdir(exist_ok=True)
43+
(work / "results.nc").unlink(missing_ok=True)
44+
for filename in ("wall.stl", "chartmap.nc", "coils.simple", "wout.nc"):
45+
link = work / filename
46+
if not link.exists():
47+
link.symlink_to(base / filename)
48+
np.savetxt(work / "start.dat", ensemble, fmt="%.17e")
49+
density = 0.0 if name == "dilute" else 2.5e20
50+
(work / "simple.in").write_text(
51+
f"""&config
52+
field_input = 'coils.simple'
53+
coord_input = 'chartmap.nc'
54+
netcdffile = 'wout.nc'
55+
wall_input = 'wall.stl'
56+
wall_units = 'm'
57+
isw_field_type = 5
58+
integmode = 0
59+
startmode = 2
60+
ntestpart = {len(ensemble)}
61+
trace_time = {full.TRACE_TIME:.17e}
62+
ntimstep = 101
63+
npoiper2 = {intervals}
64+
multharm = 1
65+
contr_pp = -1d10
66+
relerr = 1d-9
67+
facE_al = 1d2
68+
deterministic = .true.
69+
ran_seed = 541
70+
swcoll = {".true." if collisions else ".false."}
71+
output_results_netcdf = .true.
72+
/
73+
&profiles
74+
profile_type = 'power_series'
75+
Te_scale = 1d3
76+
Ti1_scale = 1d3
77+
Ti2_scale = 1d3
78+
ni1_scale = {density:.17e}
79+
ni2_scale = {density:.17e}
80+
/
81+
""",
82+
encoding="ascii",
83+
)
84+
return work
85+
86+
87+
def main():
88+
begin = perf_counter()
89+
simple, chartmap, coils, wout = (Path(arg).resolve() for arg in sys.argv[1:])
90+
base = Path.cwd() / "collisional_stl_wall_fast"
91+
base.mkdir(exist_ok=True)
92+
for source, name in ((chartmap, "chartmap.nc"), (wout, "wout.nc")):
93+
link = base / name
94+
if not link.exists():
95+
link.symlink_to(source)
96+
data = np.loadtxt(coils, skiprows=1)
97+
data[:, 3] *= full.COIL_CURRENT_SCALE
98+
np.savetxt(
99+
base / "coils.simple", data, header=str(len(data)), comments="", fmt="%.17e"
100+
)
101+
full.write_wall(chartmap, base / "wall.stl")
102+
rho = full.check_inside_lcfs(chartmap, wout)
103+
with Dataset(chartmap) as ds:
104+
theta, zeta = (np.asarray(ds[key][:]) for key in ("theta", "zeta"))
105+
rng = np.random.default_rng(541)
106+
# Subsample the original ensemble, without changing the physical duration,
107+
# fields, integration tolerances, collision density or wall resolution.
108+
starts = np.column_stack(
109+
(
110+
np.full(full.NMARKERS, rho),
111+
rng.choice(theta, full.NMARKERS),
112+
rng.choice(zeta, full.NMARKERS),
113+
np.ones(full.NMARKERS),
114+
rng.uniform(-0.2, 0.2, full.NMARKERS),
115+
)
116+
)
117+
ref_indices = np.linspace(0, full.NMARKERS - 1, 32, dtype=int)
118+
coll_indices = np.linspace(0, full.NMARKERS - 1, 64, dtype=int)
119+
# Stored collisionless impacts in the 32-marker subset; replay all five
120+
# at doubled segment resolution.
121+
fine_indices = np.array([43, 98, 117, 166, 172])
122+
cases = (
123+
("collisionless", starts[ref_indices], False, 64),
124+
("dilute", starts[ref_indices], True, 64),
125+
("collisional", starts[coll_indices], True, 64),
126+
("repeat", starts[coll_indices], True, 64),
127+
("collisionless_fine", starts[fine_indices], False, 128),
128+
)
129+
130+
workdirs = [write_case(base, case) for case in cases]
131+
(base / "simple.in").write_text((workdirs[0] / "simple.in").read_text())
132+
# One production-library driver initializes the field once. OpenMP uses
133+
# four threads for both field construction and per-marker integration.
134+
env = dict(
135+
os.environ,
136+
OMP_NUM_THREADS="4",
137+
OMP_DYNAMIC="FALSE",
138+
GFORTRAN_UNBUFFERED_ALL="1",
139+
)
140+
with (base / "run.log").open("w") as log:
141+
process = subprocess.run(
142+
[str(simple), *map(str, workdirs)],
143+
cwd=base,
144+
env=env,
145+
stdout=log,
146+
stderr=subprocess.STDOUT,
147+
timeout=120,
148+
)
149+
assert process.returncode == 0, f"see {base / 'run.log'}"
150+
assert "Error in stost" not in (base / "run.log").read_text()
151+
results = []
152+
for work in workdirs:
153+
with Dataset(work / "results.nc") as ds:
154+
result = {key: np.asarray(ds[key][:]) for key in full.RESULT_VARS}
155+
assert not np.any(result["orbit_exit_code"] == 3), str(work)
156+
assert np.all(result["orbit_exit_code"] < 100), str(work)
157+
results.append(result)
158+
reference, dilute, collisional, repeat, fine = results
159+
hit_ref = check_hits(reference, "collisionless")
160+
hit_coll = check_hits(collisional, "collisional")
161+
# Numerical drift budgets are unchanged; the stored mean is recomputed for
162+
# this exact deterministic subset, rather than widened for a smaller sample.
163+
assert abs(np.count_nonzero(hit_ref) - 5) <= 2, "collisionless hit-count drift"
164+
np.testing.assert_allclose(
165+
np.mean(reference["times_lost"][hit_ref]),
166+
2.7560491342032043e-4,
167+
rtol=0.03,
168+
atol=0,
169+
)
170+
matched = np.searchsorted(ref_indices, fine_indices)
171+
np.testing.assert_array_equal(reference["wall_hit"][matched], 1)
172+
np.testing.assert_array_equal(fine["wall_hit"], 1)
173+
np.testing.assert_allclose(
174+
fine["times_lost"], reference["times_lost"][matched], rtol=0.01, atol=1e-7
175+
)
176+
np.testing.assert_allclose(
177+
fine["wall_hit_cart"], reference["wall_hit_cart"][matched], rtol=0, atol=0.5
178+
)
179+
np.testing.assert_allclose(reference["zend"][hit_ref, 3], 1, rtol=0, atol=1e-7)
180+
for key in ("wall_hit", "orbit_exit_code"):
181+
np.testing.assert_array_equal(dilute[key], reference[key], err_msg=key)
182+
for key in ("zend", "times_lost", "wall_hit_cart", "wall_hit_angle_rad"):
183+
np.testing.assert_allclose(
184+
dilute[key], reference[key], rtol=2e-6, atol=2e-8, err_msg=key
185+
)
186+
for key in full.RESULT_VARS:
187+
np.testing.assert_array_equal(repeat[key], collisional[key], err_msg=key)
188+
delayed = hit_coll & (collisional["times_lost"] > 20 * full.TRANSIT_TIME)
189+
energy = collisional["zend"][delayed, 3] ** 2
190+
# Exact one-sided sign test of median E/E0 >= 0.99: under the null, each
191+
# independent delayed impact has probability <= 1/2 of being below 0.99.
192+
# P[Binomial(n, 1/2) >= k] <= 0.05 requires at least five slowed impacts;
193+
# it automatically tightens if any delayed impacts fail to slow down.
194+
n = len(energy)
195+
k = np.count_nonzero(energy < 0.99)
196+
p_value = sum(comb(n, j) for j in range(k, n + 1)) / 2**n
197+
assert p_value <= 0.05, f"unresolved slowing down: n={n}, k={k}, p={p_value}"
198+
assert np.mean(energy) < 0.99, "wall output lost the slowed-down state"
199+
print(
200+
f"Slowing down: n={n}, k={k}, sign-test p={p_value:.6g}; "
201+
f"mean E/E0={np.mean(energy):.9f}",
202+
flush=True,
203+
)
204+
print("PASS: segment convergence, vacuum limit, slowing down, seeded repeat")
205+
print(f"Fast wall test: {perf_counter() - begin:.3f} s", flush=True)
206+
207+
208+
if __name__ == "__main__":
209+
main()

0 commit comments

Comments
 (0)