Skip to content

Commit 6b9c39b

Browse files
d-burgclaude
andcommitted
TESTING - NEW FEATURE - Test thread invariance of the parallel BVP path
ForceFreeStatesControl documents that the parallel FM/BVP path produces bit-identical Delta' across thread counts, but nothing tested it by varying threads, and the example decks disagreed about whether to rely on it: the SLAYER deck pins parallel_threads = 1 "to keep the regression Delta' (and hence gamma) reproducible", while the DIII-D-like ideal deck — whose Delta' the regression harness tracks — runs parallel_threads = 2. A golden Delta' has to be a property of the physics rather than of the machine it was measured on, so this settles the question by measurement. Measured on the DIII-D-like deck at parallel_threads = 1, 2, 4 and 16: the Delta' matrix diagonal and et[1] are bit-identical throughout. The 16-thread configuration is the informative one — it lifts 4*effective_threads above the min_bvp_intervals floor and so produces a genuinely different decomposition (64 chunks vs 53, with different boundaries), which reassociates the propagator products without moving the result. Invariance therefore holds across decomposition, not merely across scheduling. - test/runtests_thread_invariance.jl compares parallel_threads 1 vs 2 on the Solovev and DIII-D-like decks, asserting exact equality rather than a tolerance: the code claims bit-identity, and a tolerance would mask the reassociation the test exists to catch. It also asserts the chunk boundaries are unchanged at these caps, so that a future decomposition change surfaces as a failure instead of silently turning the comparison into a stronger claim than intended. Skipped when the session has one thread, where effective_threads collapses to 1 and every comparison is vacuous. - The test workflow gains a multi-threaded leg (JULIA_NUM_THREADS = 4). The suite had only ever run single-threaded, so the parallel paths were exercised solely in their degenerate form. Existing job names are preserved byte-identically because branch protection names them as required checks. - balance_integration_chunks' docstring gave target_n as max(2*msing + 3, 4*Threads.nthreads()), omitting both the parallel_threads cap and the min_bvp_intervals term that actually dominates. runtests_parallel_integration.jl mirrored the same stale formula and would fail on a machine with more threads than the cap; it passed only because CI is single-threaded. - CLAUDE.md's single-test-file invocation could not work (runtests.jl passes ARGS to include, which resolves relative to test/), and two of the listed files do not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 043c197 commit 6b9c39b

6 files changed

Lines changed: 161 additions & 14 deletions

File tree

.github/workflows/test.yaml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,17 @@ jobs:
8787
fi
8888
8989
test:
90-
name: runtests ${{ matrix.version }} - ${{ matrix.os }}
90+
# The job name must stay byte-identical for the single-threaded legs: branch protection
91+
# names these contexts as required checks, so renaming them would leave every pull request
92+
# waiting forever on a status that never reports. The multi-threaded leg gets a distinct
93+
# name and is therefore additive rather than breaking.
94+
name: runtests ${{ matrix.version }} - ${{ matrix.os }}${{ matrix.threads != '1' && format(' ({0} threads)', matrix.threads) || '' }}
9195
needs: changes
9296
if: needs.changes.outputs.julia == 'true'
9397
runs-on: ${{ matrix.os }}
9498
timeout-minutes: 90
99+
env:
100+
JULIA_NUM_THREADS: ${{ matrix.threads }}
95101
strategy:
96102
fail-fast: false
97103
matrix:
@@ -100,6 +106,16 @@ jobs:
100106
- '1.x' # latest (currently 1.12)
101107
os:
102108
- ubuntu-latest
109+
threads:
110+
- '1'
111+
include:
112+
# The parallel FM/BVP paths degenerate to their serial form when
113+
# effective_threads = min(nthreads, parallel_threads) collapses to 1, so a
114+
# single-threaded matrix never exercises threaded execution at all. This leg runs the
115+
# suite multi-threaded, which is what makes the thread-invariance tests meaningful.
116+
- version: '1.11'
117+
os: ubuntu-latest
118+
threads: '4'
103119

104120
steps:
105121
- name: Checkout repository

CLAUDE.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,22 @@ GPEC (Generalized Perturbed Equilibrium Code, Julia implementation) is a compreh
2222
# Run all tests
2323
julia --project=. -e 'using Pkg; Pkg.activate("."); Pkg.instantiate(); include("test/runtests.jl")'
2424

25-
# Run specific test file
26-
julia --project=. test/runtests.jl test/runtests_solovev.jl
27-
28-
# Available test files:
29-
30-
# - test/runtests_vacuum_julia.jl # Julia vacuum module
31-
# - test/runtests_solovev.jl # Analytical equilibrium
32-
# - test/runtests_ode.jl # ODE integration
33-
# - test/runtests_sing.jl # Singular surface handling
34-
# - test/runtests_fullruns.jl # End-to-end tests
25+
# Run specific test file — the argument is included relative to test/, so pass the bare
26+
# filename, not a path prefixed with test/
27+
julia --project=. test/runtests.jl runtests_sing.jl
28+
29+
# Run the suite multi-threaded (the parallel FM/BVP paths reduce to their serial form at one
30+
# thread, so a single-threaded run never exercises threaded execution)
31+
julia -t 4 --project=. test/runtests.jl
32+
33+
# A few of the available test files (see test/runtests.jl for the full list):
34+
35+
# - test/runtests_vacuum.jl # Vacuum module
36+
# - test/runtests_equil.jl # Equilibrium reconstruction
37+
# - test/runtests_sing.jl # Singular surface handling
38+
# - test/runtests_parallel_integration.jl # Parallel FM integration and BVP Delta'
39+
# - test/runtests_thread_invariance.jl # Parallel-vs-serial equivalence
40+
# - test/runtests_fullruns.jl # End-to-end tests
3541
```
3642

3743
### Building Documentation

src/ForceFreeStates/EulerLagrange.jl

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,13 @@ end
6767
Sub-divide integration chunks to produce a load-balanced set for parallel execution.
6868
Starts from the output of `chunk_el_integration_bounds` and iteratively splits the
6969
highest-cost chunk (by `ode_itime_cost`) until the total chunk count reaches
70-
`max(2*msing + 3, 4 * Threads.nthreads())`.
70+
`max(2*msing + 3, 4*effective_threads, 8*(msing + 1) + msing)`, where
71+
`effective_threads = min(Threads.nthreads(), ctrl.parallel_threads)`.
72+
73+
The last term (BVP propagator conditioning) dominates for every realistic thread count, so
74+
the decomposition — and hence the floating-point association of the propagator products — is
75+
normally independent of the thread count. It stops dominating only when
76+
`4*effective_threads` exceeds it, i.e. beyond roughly `(9*msing + 8)/4` threads.
7177
7278
Each split finds the equal-cost midpoint ψ_mid via bisection:
7379
ode_itime_cost(psi_start, psi_mid) ≈ ode_itime_cost(psi_start, psi_end) / 2

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ else
2929
include("./runtests_eulerlagrange.jl")
3030
include("./runtests_riccati.jl")
3131
include("./runtests_parallel_integration.jl")
32+
include("./runtests_thread_invariance.jl")
3233
include("./runtests_sing.jl")
3334
include("./runtests_innerlayer.jl")
3435
include("./runtests_tj_analytic.jl")

test/runtests_parallel_integration.jl

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,11 @@ using TOML
135135
balanced = GeneralizedPerturbedEquilibrium.ForceFreeStates.balance_integration_chunks(base_chunks, ctrl, intr)
136136

137137
# Must mirror balance_integration_chunks' internal target_n formula
138-
# (src/ForceFreeStates/EulerLagrange.jl). Keep this in sync.
139-
target_n = max(2 * intr.msing + 3, 4 * Threads.nthreads(), 8 * (intr.msing + 1) + intr.msing)
138+
# (src/ForceFreeStates/EulerLagrange.jl). Keep this in sync. The parallel width is
139+
# capped by ctrl.parallel_threads, not the raw thread count: using Threads.nthreads()
140+
# here overestimates the target on a machine with more threads than the cap.
141+
effective_threads = min(Threads.nthreads(), max(ctrl.parallel_threads, 1))
142+
target_n = max(2 * intr.msing + 3, 4 * effective_threads, 8 * (intr.msing + 1) + intr.msing)
140143

141144
# After balancing, chunk count equals target_n: the while-loop adds exactly one
142145
# chunk per iteration (a bisection split) and exits when length(result) >= target_n,

test/runtests_thread_invariance.jl

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using Test
2+
using TOML
3+
4+
# Thread-invariance of the parallel FM/BVP path.
5+
#
6+
# `ForceFreeStatesControl` documents that the parallel path produces bit-identical Δ′ across
7+
# thread counts, and the example decks disagree about whether to rely on it: the SLAYER deck
8+
# pins `parallel_threads = 1` for reproducibility while the DIII-D-like ideal deck — whose Δ′
9+
# the regression harness tracks — runs `parallel_threads = 2`. These tests hold the source and
10+
# the deck fixed and vary only the BVP thread cap, so a golden Δ′ is a property of the physics
11+
# rather than of the machine it was measured on.
12+
#
13+
# Two axes are reachable through `parallel_threads`:
14+
#
15+
# - scheduling: `Threads.@threads` over chunks vs a serial loop (any cap ≥ 2)
16+
# - decomposition: `balance_integration_chunks` targets
17+
# `max(2·msing+3, 4·effective_threads, 8·(msing+1)+msing)` sub-chunks, so once
18+
# `4·effective_threads` exceeds the `min_bvp_intervals` floor the chunk boundaries
19+
# themselves move and the propagator products reassociate
20+
#
21+
# The decomposition axis needs `effective_threads > (9·msing+8)/4` — about 14 threads for the
22+
# DIII-D-like deck's msing=5 — so it is out of reach of a typical CI runner and is exercised by
23+
# the nightly harness instead. Measured on this deck at parallel_threads = 1, 2, 4 and 16
24+
# (53 vs 64 chunks, confirmed different boundaries): Δ′ and et[1] were bit-identical throughout.
25+
#
26+
# `effective_threads = min(Threads.nthreads(), parallel_threads)` collapses every cap to 1 in a
27+
# single-threaded session, which would make these comparisons trivially true, so they are skipped
28+
# there rather than passing vacuously.
29+
30+
const GP_TI = GeneralizedPerturbedEquilibrium
31+
32+
"""
33+
Run the ideal stability pipeline on `dir` at a given BVP thread cap.
34+
35+
Mirrors the standalone setup used by the parallel-integration tests: build the equilibrium
36+
(applying the two-pass auto grid when the deck asks for it), integrate the Euler-Lagrange
37+
system, then assemble the STRIDE BVP Δ′ matrix. Returns the Δ′ matrix, the leading energy
38+
eigenvalue, and the chunk boundaries, so a caller can tell scheduling changes from
39+
decomposition changes.
40+
"""
41+
function _run_at_thread_cap(dir::String, parallel_threads::Int)
42+
inputs = TOML.parsefile(joinpath(dir, "gpec.toml"))
43+
inputs["ForceFreeStates"]["verbose"] = false
44+
inputs["ForceFreeStates"]["use_parallel"] = true
45+
inputs["ForceFreeStates"]["write_outputs_to_HDF5"] = false
46+
inputs["ForceFreeStates"]["parallel_threads"] = parallel_threads
47+
48+
intr = GP_TI.ForceFreeStates.ForceFreeStatesInternal(; dir_path=dir)
49+
ctrl = GP_TI.ForceFreeStates.ForceFreeStatesControl(; (Symbol(k) => v for (k, v) in inputs["ForceFreeStates"])...)
50+
eq_config = GP_TI.Equilibrium.EquilibriumConfig(inputs["Equilibrium"], dir)
51+
sol_config = haskey(inputs, "SOL_INPUT") ? GP_TI.Equilibrium.SolovevConfig(inputs["SOL_INPUT"]) : nothing
52+
equil = GP_TI.Equilibrium.setup_equilibrium(eq_config, sol_config)
53+
if GP_TI.Equilibrium.wants_two_pass(eq_config)
54+
mand = GP_TI.ForceFreeStates.rational_psi_nodes(equil; nlow=ctrl.nn_low, nhigh=ctrl.nn_high)
55+
psi_nodes = GP_TI.Equilibrium.refined_psi_grid(equil; tau=eq_config.psi_accuracy, mandatory=mand)
56+
rerun_input = GP_TI.Equilibrium.build_direct_from_ingest(eq_config, equil.ingest)
57+
equil = GP_TI.Equilibrium.setup_equilibrium(eq_config, rerun_input; override_psi_nodes=psi_nodes)
58+
end
59+
intr.wall_settings = GP_TI.Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in inputs["Wall"])...)
60+
GP_TI.ForceFreeStates.sing_lim!(intr, ctrl, equil)
61+
intr.nlow = ctrl.nn_low
62+
intr.nhigh = ctrl.nn_high
63+
intr.npert = 1
64+
GP_TI.ForceFreeStates.sing_find!(intr, equil)
65+
intr.mlow = min(intr.nlow * equil.params.qmin, 0) - 4 - ctrl.delta_mlow
66+
intr.mhigh = trunc(Int, intr.nhigh * equil.params.qmax) + ctrl.delta_mhigh
67+
intr.mpert = intr.mhigh - intr.mlow + 1
68+
intr.numpert_total = intr.mpert * intr.npert
69+
metric = GP_TI.ForceFreeStates.make_metric(equil, intr.mpert)
70+
ffit = GP_TI.ForceFreeStates.make_matrix(equil, intr, metric)
71+
odet, fm_propagators, fm_chunks, fm_S_left = GP_TI.ForceFreeStates.eulerlagrange_integration(ctrl, equil, ffit, intr)
72+
vac = GP_TI.ForceFreeStates.free_run!(odet, ctrl, equil, ffit, intr)
73+
GP_TI.ForceFreeStates.compute_delta_prime_matrix!(intr, fm_propagators, fm_chunks;
74+
wv=vac.wv, psio=equil.psio, S_at_surface_left=fm_S_left, ctrl=ctrl, equil=equil, ffit=ffit)
75+
return (dpm=copy(intr.delta_prime_matrix), et1=vac.et[1], msing=intr.msing,
76+
bounds=[(c.psi_start, c.psi_end) for c in fm_chunks])
77+
end
78+
79+
@testset "Thread invariance of the parallel BVP path" begin
80+
if Threads.nthreads() < 2
81+
@info "Thread-invariance tests skipped: effective_threads collapses to 1 in a single-threaded session. Run with `julia -t 4` (CI covers this in its multi-threaded matrix leg)."
82+
@test true
83+
else
84+
@testset "Solovev — leading eigenvalue" begin
85+
dir = joinpath(@__DIR__, "test_data", "regression_solovev_ideal_example")
86+
serial = _run_at_thread_cap(dir, 1)
87+
threaded = _run_at_thread_cap(dir, 2)
88+
# Bit-identical, not approximate: the parallel path claims exactness, and a
89+
# tolerance here would hide precisely the reassociation this test exists to catch.
90+
@test threaded.et1 === serial.et1
91+
end
92+
93+
@testset "DIII-D-like — Δ′ diagonal and leading eigenvalue" begin
94+
# The deck whose Δ′ the regression harness pins, and where the BVP Δ′ is
95+
# well-conditioned (Solovev sits near marginal stability and its BVP Δ′ is not).
96+
dir = joinpath(@__DIR__, "..", "examples", "DIIID-like_ideal_example")
97+
serial = _run_at_thread_cap(dir, 1)
98+
threaded = _run_at_thread_cap(dir, 2)
99+
100+
@test threaded.msing == serial.msing
101+
@test size(threaded.dpm) == size(serial.dpm)
102+
@test threaded.et1 === serial.et1
103+
for j in 1:serial.msing
104+
@test threaded.dpm[j, j] === serial.dpm[j, j]
105+
end
106+
@test threaded.dpm == serial.dpm
107+
108+
# At these caps the min_bvp_intervals floor fixes the chunk count, so the
109+
# boundaries should be untouched and only scheduling differs. If this fails the
110+
# decomposition moved and the Δ′ comparison above became a stronger claim than
111+
# the one this testset intends to make.
112+
@test threaded.bounds == serial.bounds
113+
end
114+
end
115+
end

0 commit comments

Comments
 (0)