PENTRC: fix OpenMP data race on the energy contour flag (and a nested-quadrature tolerance defect) - #280
Conversation
energy_imaxis selects the integration contour inside xintgrnd -- cx = i*x on the imaginary-axis leg against cx = x + i*ximag on the real one -- and xintgrl_lsode flips it between its two legs (energy.f90:190, :223). It is per-integration state exactly like energy_wn, energy_wd, energy_leff and the rest, and it was the only one of them missing from the threadprivate list. Shared, one thread's imaginary leg silently switches another thread's integrand from a decaying exponential to an oscillation, so values from deep in the Maxwellian tail come back four orders of magnitude too large and the pitch integrand stops being a function of lambda: neighbouring lambda separated by 1e-8 differ by 130% on average. lambdaintgrl_lsode then shrinks its step without bound and aborts with 'too many steps in lambda required'. That is why the failure resisted every numerical explanation -- tolerances, xmax, ximag, nl, compiler, kinetic profiles: it does not depend on the numerics, it depends on the thread count. Decks that set the deprecated and ignored openmp_threads = 1 silently ran on every available core. Gate: the ITER case that previously aborted now runs unmodified and gives bit-identical pentrc_tgar_n3.out and pentrc_pgar_n3.out at 1 and 16 threads, all 29 columns, including the pitch-resolved netCDF. Note for reviewers: recorded integrations (op_record=.true.) additionally mutate the shared energy_record object without synchronization. Production recording is serial so this does not affect the fix above, and it is left alone here.
pentrc_interface aliased both xatol/xrtol and lambdaatol/lambdartol to the single atol_xlmda/rtol_xlmda pair. The pitch integral's integrand IS the energy integral, so this is a nested adaptive quadrature, and with one tolerance for both the outer integrator chases the inner one's quadrature error -- which is not a smooth function of lambda. LSODE then shrinks its step without bound. New pent_control entries atol_x/rtol_x carry the energy tolerances. Their default of -1 means 'derive from the pitch tolerances', as nested_tolerance_margin = 1e-2 times them. This DOES change numerical behaviour for every deck that does not set the new controls: the effective energy tolerances go from the formerly aliased 1e-6/1e-3 to 1e-8/1e-5, i.e. tighter. Decks can restore the old values explicitly by setting atol_x = 1e-6, rtol_x = 1e-3. Note that energy.f90's own module defaults are tighter still, 1e-12/1e-9; those were overridden by the aliasing before this change and remain overridden by the derived values after it, which is a separate question about what the right energy tolerance actually is. This is NOT what unblocked the previously-aborting ITER case; the OpenMP race on energy_imaxis was. It is a real defect in its own right and is kept separate. Dropped from an earlier version of this change: an exclusion of both endpoints of the RLAR trapped pitch space. lmdamin = max(1/(1+epsr), bo/bmax) and lmdamax = min(1/(1-epsr), bo/bmin) are degenerate only when the bo/b term wins; when the cylindrical epsr bound wins the endpoint has two regular bounce points, and excluding it unconditionally would shrink the physical interval and discard nonsingular contributions.
edf6301 to
f2cd940
Compare
|
Updated after an independent adversarial review and a rebase onto current 1. Dropped the degenerate-endpoint change. The review was right and my justification was wrong. 2. The tolerance change is no longer described as behaviour-preserving. It is not. Every deck that does not set the new 3. Split out the documentation changes. The race fix itself is unchanged and re-verified on the new base. Output is byte-identical to the pre-rebase build, and bit-identical at 1 and 16 threads. One caveat added to the commit message, also from the review: recorded integrations ( |
logan-nc
left a comment
There was a problem hiding this comment.
Approve. Reviewed the diff line-by-line and independently reproduced both the bug and the fix's correctness gates on a different equilibrium (the in-repo a10 case) than the ITER deck in the PR.
Race fix (commit 1) — verified correct and complete within its stated scope.
energy_imaxisis assigned before use on every call (= .true.at energy.f90:198 only whenximag /= 0, unconditionally= .false.at :231 before the real leg), with first read only inside the subsequent LSODE legs — so thread-local initialization is guaranteed per call and nocopyinis needed.- Completeness sweep: every sibling per-integration module variable is already threadprivate (
pitch_ell,pitch_n, allpitch_*in pitch.f90:70-73; allenergy_*), both LSODE instances' common blocks are threadprivate (/DLS002/lsode2.f:30,/DLS011/lsode1.f:31), and the remaining shared energy-module state (xatol,xrtol,ximag,xmax,xnufac, ...) is set serially and read-only in the parallel region.energy_imaxiswas genuinely the last mutable hole in this class. The caveat about the serial-onlyenergy_recordpath is accurate. - The parallel region (over bounce harmonics, torque.F90:1413) is reached by both the dynamic and equil/input grid paths (tintgrl_grid also calls tintgrnd), so the fix covers all of them.
- Independent reproduction: a10 kinetic deck,
tgar,ximag = 0.05,nl = 2,psilims = 0.3 0.6. Develop at 8 threads dies with the exact reported signature — cascadingLSODE- Energy- ... error test failed repeatedly,pentrc_lambdaintrgl_lsode.errwritten,STOP ERROR: lambdaintgrl_lsode - too many steps in lambda required, no output — while 1 thread runs clean. This branch at 1 and 8 threads: clean and bit-identical outputs. The race requiresximag /= 0(line 198 is the only.true.write) plusnl >= 1, which explains both why it masqueraded as a numerics problem and why decks withximag = 0never see it.
Tolerance split (commit 2) — verified a faithful re-plumbing, and the behavior change is empirically justified.
- Both namelist-reader sites are converted;
xatol/xrtolhave no other writers; the-1sentinel semantics are safe (explicit0and the old aliased values remain expressible). - Exact no-op when pinned: this branch with
atol_x/rtol_xset to the old aliased values is bit-identical to a build carrying only the race-fix commit. The sentinel logic reproduces the old behavior exactly on request. - The default change is doing real work: on the a10 case, tightening the energy tolerances by the 1e-2 margin moves the integrated torque by 0.64% — ~60x the requested
rtol_psi = 1e-4— i.e. the old aliased nesting was producing errors well beyond the accuracy the outer integrals claimed. The new default is not just tidier, it is necessary for the outer tolerances to mean what they say. The 1e-2 margin looks right. - One measurement note for other reviewers: cross-build outputs differ at the ~1e-4 level even at 1 thread with identical tolerances (recompiling the modified translation unit under -O3 changes instruction scheduling/FMA fusion). That is codegen noise at the integration-tolerance level, not a semantic change — the meaningful gate is same-binary thread invariance, which holds.
Minor asks (non-blocking):
nested_tolerance_margin = 1e-2— make it1e-2_r8per the project's precision convention.- The new
atol_x/rtol_xcontrols are not documented ininput/pentrc.inor any example deck, and every shippedpentrc.instill commentsatol_xlmdaas "tolerance in energy and pitch integration", which this PR makes untrue. Worth updating the template comment and adding the new controls in this PR or a quick follow-up. - The 0.64%-scale default shift will surface in any regression comparison against old PENTRC outputs — flagging for the eventual release notes.
🤖 Generated with Claude Code
…s_cleanup PENTRC: document atol_x/rtol_x controls and fix precision suffix (cleanup for #280)
PENTRC aborts with
lambdaintgrl_lsode - too many steps in lambda requiredon an ITER equilibrium, independently of compiler, tolerances,xmax,ximag,nland kinetic profiles. The root cause is a data race.The race
energy_imaxisselects the integration contour insidexintgrnd:xintgrl_lsodeflips it between its imaginary-axis and real-axis legs (energy.f90:190,:223). It is per-integration state exactly likeenergy_wn,energy_wd,energy_leffand the rest — and it is the only one of them missing from thethreadprivatelist.Shared, one thread's imaginary leg silently switches another thread's integrand from
x + i*ximagtoi*x.exp(-cx)stops decaying and starts oscillating, so values from deep in the Maxwellian tail return order1e4instead of1e-2. The pitch integrand is then not a function oflambdaat all: inpentrc_lambdaintrgl_lsode.err, neighbouringlambdaseparated by1e-8differ by 130% on average, spanning-9.9e3to+1.4e4around a median of9e-3. LSODE shrinks its step without bound and gives up.This explains why the failure looked impossible to pin down: it does not depend on the numerics, it depends on the thread count. Decks that set the deprecated
openmp_threadssilently run on every available core.Scope of the
threadprivateclaimFor the ordinary parallel path the fix is complete:
energy_wn,energy_wt,energy_we,energy_wd,energy_wb,energy_nuk,energy_leff,energy_nand nowenergy_imaxisare all thread-private (energy.f90:62-81), and LSODE's/DLS002/common block is thread-private too (lsode2.f:29-30).It is not a claim that all of
energy.f90is thread-safe.xintgrl_lsode(..., op_record=.true.)callsrecord_method(energy.f90:293), which mutates the sharedenergy_recordobject (energy.f90:478-513) without synchronisation. Production recording is serial today, so this does not affect the demonstrated fix, but the recorded path should not be parallelised as it stands.Evidence
Same deck, same binary, unmodified ITER case:
pentrc_tgar_n3.outandpentrc_pgar_n3.outBit-identical output across thread counts is the correctness gate for a race fix, and it holds on all 29 columns of both output files.
Second commit: the nested quadrature shared one tolerance
pentrc_interface.f90aliasedxatol/xrtolto the sameatol_xlmda/rtol_xlmdapair used for the pitch integral. The pitch integrand is the energy integral, so the outer integrator was asked to resolve its integrand tortol_xlmdawhile that integrand was itself computed only tortol_xlmda, and it then chases the inner integrator's own quadrature error. The energy integration now takes its ownatol_x/rtol_x.This changes default numerical behaviour, deliberately. With
atol_x/rtol_xleft at their derive-me sentinel, the energy tolerances becomenested_tolerance_margin = 1e-2times the pitch tolerances, so the shipped defaults moveatolrtol1e-61e-31e-2margin)1e-81e-5energy.f90module defaults, for reference1e-121e-9Every deck that does not set the new controls therefore integrates the energy variable more tightly than before, and pays for it in runtime. That is the intent — the previous nesting was inconsistent — but it is a behaviour change, not a no-op, and reviewers should decide whether
1e-2is the margin they want. Note the derived values do not restoreenergy.f90's own1e-12/1e-9; a deck can ask for those explicitly withatol_x/rtol_x.Withdrawn from this PR
An earlier revision also removed the degenerate endpoints from the trapped-only pitch interval. That hunk is gone. The reasoning was wrong in general:
lmdamin = max(1/(1+epsr), bo/bmax)andlmdamax = min(1/(1-epsr), bo/bmin)are degenerate only when thebo/bmaxorbo/bminterm wins the extremum; when the cylindricalepsrbound wins, that endpoint can have two regular bounce points and deleting it discards nonsingular contributions. A correct fix has to test which term won. Filed separately rather than bundled here.Built and tested with gfortran 15.