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 ( |
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.