ForceFreeStates - IMPROVEMENT - Compute solution derivatives on demand - #365
Merged
Merged
Conversation
The Euler-Lagrange RHS is evaluated ~16 times per accepted step, and a third of its linear algebra existed only to maintain derivative side effects consumed at save points: the A/B/C matrix spline evaluations, the factorization of A, and the two GEMMs for the Clebsch displacement never touch the du handed back to the integrator. Split the RHS into a stateless kernel (el_derivatives!) plus thin adapters, drop the save-point-only work from it, and recompute dXi_psi and Xi_s from the stored solution in a single pass afterwards. This is exact rather than approximate: the system is linear in u, and both the Gaussian fixups and the free-boundary normalization right-multiply by a mixing matrix, so du(psi, u*T) = du(psi, u)*T. du_2/dpsi is no longer stored densely — its only consumer evaluates it at the two bracketing nodes of each resonant evaluation point. The trajectory is bit-identical (psi/q/u/crit stores and the Delta' matrix on the DIII-D-like deck); derivative-valued outputs move by ~1e-11 relative, which is floating-point reassociation from computing them after the transforms. Also: the galerkin-matched state now declares its analytic derivatives populated, and the sparse parallel path leaves the stores empty instead of exposing uninitialized memory that free_run! would scale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0135hNoGgzrSPQk1pf4VhchK
Point the Xi_s docstring at the kernel that actually produces its input, and drop an ambiguous @ref (sing_der! now carries two method docstrings). State the q contract on store_ode_data! explicitly now that callers set it rather than the RHS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0135hNoGgzrSPQk1pf4VhchK
Collaborator
Author
|
Heads-up: #368 (stacked on the #226 schema PRs) fixes the |
matt-pharr
approved these changes
Aug 14, 2026
matt-pharr
enabled auto-merge
August 14, 2026 18:16
…erivatives' into performance/on-demand-solution-derivatives
matt-pharr
disabled auto-merge
August 14, 2026 18:57
priyanshlunia
added a commit
that referenced
this pull request
Aug 21, 2026
Resolved against develop's post-#365 writer: ca_left/ca_right now guard on (diag !== nothing && diag.ca_populated) with rank-4 zero-extent sentinels; dropped the ca_populated save/restore hunks in the deleted _populate_dense_xi_via_serial_el!. Also repaired conflict markers committed to develop in docs/development/regression-harness.md (kept both sides, dropped the stale 'thread counts not forced' paragraph superseded by -t auto). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ForceFreeStates — compute solution derivatives on demand
Follow-up to the review of #348. No physics change: the integration trajectory is
bit-identical and derivative-valued outputs move only by floating-point reassociation.
Motivation
sing_der!is the Vern9 right-hand side, evaluated ~16 times per accepted step. About athird of its linear algebra existed only to maintain the
odet.du/odet.xi_ssideeffects that were read at save points:
potrf!+ 2×potrs!getrf!+ 2×getrs!None of it touches the
duhanded back to the integrator: the ideal branch builds du fromfmats_lower/kmats/gmatsonly, and the kinetic branch from the assembled F̄/K̄/K̄†/Ḡ†.(The kinetic
getrf!on F̄ is load-bearing and untouched.)On top of that, #348 added a second
sing_der!call at every saved step, and a densedu_store[:,:,2,:]slab whose only consumer in the whole repo is the Hermite interpolationof u₂ at the two bracketing nodes of each resonant evaluation point.
Design
du and Ξ_s at a node are pure, linear functions of
(ψ, u₁, u₂), so nothingderivative-valued has to exist during integration.
sing_der!is split into a stateless kernelel_derivatives!(du, u, kinetic, equil, ffit, intr, psieval, spline_hint, ffit_hint) -> qplus two thin adapters (the tuple form theODE solver calls, and an
odet-based form). The kernel takes the caller's spline hints,so it is safe to call from the threaded loop over rational surfaces.
compute_node_xi_s!, shared by the materializer and bythe galerkin-matched constructor (which previously carried its own transcription of it).
materialize_derivative_stores!(odet, equil, ffit, intr)fillsdu_store/xi_s_storeinone pass from
(psi_store, u_store). It is idempotent and is called at the top ofcompute_perturbed_equilibriumand inwrite_outputs_to_HDF5.du_storedrops to 3-D (dΞ_ψ/dψ only). du₂/dψ is evaluated on demand at the ~4 bracketnodes per resonant row that need it.
qnow comes from an explicitq_splineevaluation at the accepted point insteadof as a side effect of the removed
sing_der!call — same spline, same ψ, same hint, soq_storeis unchanged bit-for-bit.Why materializing after the transforms is exact, not merely close
transform_u!(Gaussian fixups) andfree_run!(free-boundary normalization) bothright-multiply the stored solution by a mixing matrix
T. The Euler-Lagrange/FKG system islinear in
u, sodu(ψ, u·T) = du(ψ, u)·T. Computing derivatives from the post-transformu_storetherefore gives the same answer as transforming stored derivatives, up tofloating-point reassociation. This is covered by a unit test that applies a column mix to a
copy of the stored solution and checks the materialized derivatives commute with it
(agreement ~1e-15).
Paths that need care
Galerkin (
gal_matched_odestate) carries analytic ξ′ that is not recomputable fromthe ODE kernel. It now sets
du_store_populated = true, which makes the materializer ano-op there;
transform_u!/free_run!still mix its arrays.Sparse parallel (
use_parallel+populate_dense_xi = false) stores chunk-endpointRiccati matrices, not the Euler-Lagrange state. A new
u_store_el_basisflag recordsthis, the materializer refuses to run, and the stores stay empty. Previously they held
undefmemory thatfree_run!scaled unguarded;_chord_solution_atnow raises a clearerror instead of consuming garbage. This stays inside the already-documented
"unusable without
populate_dense_xi" class.This is observable, and it is an improvement.
examples/Solovev_kinetic_calculated_exampletakes exactly this path (both flags at their defaults,
use_parallel = true/populate_dense_xi = false). Ondevelopit writesintegration/dxi_psiandintegration/xi_sas(32, 32, 7)arrays of uninitialized memory; on this branch itwrites
(32, 32, 0).integration/xi_psi,u2,psi,q,critand the singularlocations are bit-identical, so nothing usable was lost — only the garbage. (Separately
and pre-existing:
singular/ca_left/ca_rightare stillundefon kinetic runs, whichnever populate them — 238 vs 100 NaNs between two runs of the same code. Unrelated to
this PR, but worth a follow-up.)
Serial Riccati drives
sing_der!as its production RHS, so its stores materializenormally. Verified end-to-end (no example or test covers this path, so it was run
explicitly): the Solovev deck with
use_riccati = true, use_parallel = falsecompletesand writes
integration/dxi_psiandintegration/xi_sat(32, 32, 56)matchingnstep = 56, all finite and non-zero. Note these are now accepted-point derivatives;previously they were whatever the last internal Runge-Kutta stage left behind — the same
staleness class PerturbedEquilibrium - BUGFIX - change singular coupling calculations to match Fortran, using du_store instead of splined xi' #348 fixed for the Euler-Lagrange path, so a small improvement rather
than a bit-identical carry-over.
Proof of no result change
examples/DIIID-like_ideal_example,developvs this branch, same environment.Trajectory — bit-identical (max |Δ| exactly 0):
integration/psi,integration/q,integration/critintegration/xi_psi,integration/u2integration/nstep,integration/nstep_totalsingular/delta_prime_matrix,singular/delta_prime_rawsingular/psi,singular/q,singular/msingsingular/ca_left,singular/ca_rightDerived quantities — floating-point reassociation only:
integration/dxi_psiintegration/xi_ssingular_coupling/island_half_widthsingular_coupling/chirikov_parametersingular_coupling/resonant_area_weighted_fieldsingular_coupling/delta_primesingular_coupling/resonant_currentsingular_coupling/island_width_sqsingular_coupling/C_delta_primesingular_coupling/C_resonant_area_weighted_fieldsingular_coupling/rational_q,penetrated_area_weighted_field,energies/toroidal_torquekinetic_forces/fgar/total_torqueKinetic branch, verified separately
The tables above are the ideal deck. The FKG branch of the derivative kernel (LU on A rather
than Cholesky) needs its own numerical check, and the kinetic regression case does not track
dxi_psi/xi_s.examples/Solovev_kinetic_calculated_examplewas therefore run on bothrefs with
use_parallel = falseso materialization actually happens:integration/psi,q,critintegration/xi_psi,u2integration/nstep,nstep_total,singular/psi,q,msingintegration/dxi_psiintegration/xi_sSame picture as the ideal deck: trajectory exact, derivatives at reassociation level.
(
singular/ca_left/ca_rightdiffer, but they areundefon kinetic runs in both codes —one value came back as 7.0e-310, a denormal, which is the giveaway. Pre-existing.)
Regression harness
Four cases chosen to cover every path the refactor touches: the full DIII-D pipeline, a
plain ideal case, the kinetic branch of the derivative kernel, and the galerkin
path whose analytic derivatives must survive untouched.
diiid_n1solovev_n1solovev_kinetic_calculatedgal_resistive_peEverything is
OK; nothing is at or above itsnoise_threshold. The only non-zero diffsare in
diiid_n1, all in derivative-derived resonant quantities and all ~1e-12 or smaller:Everything else — energies, equilibrium scalars, step counts, singular-surface locations,
all five profile checksums, the Δ′ BVP diagonal, and all PE energies/torque — is exactly
0.0e+00 or byte-identical.
gal_resistive_peis 0.0e+00 across the board, confirming thegalerkin path's analytic derivatives are untouched. The four
N/Arows there arepre-existing (they are
N/Aondeveloptoo).Thresholds: the
diiid_n1resonant block sits atnoise_threshold = 1e-8and themeasured diffs reach 5.2e-12. Recommend leaving them as-is rather than tightening —
1e-12-scale reassociation noise is exactly what the current margin absorbs, and tightening
to that level would make the case flap on unrelated changes.
Whole-pipeline runtimes from the same report (shared machine, another user's job was
running, so treat as indicative only):
diiid_n1solovev_n1solovev_kinetic_calculatedgal_resistive_pePerformance
Post-JIT, best of three fresh setups, DIII-D-like deck with
use_parallel = falseto isolatethe serial Euler-Lagrange integrator (the stage this PR changes). Identical trajectory in
both columns: 1040 saved steps, 1317 total ODE steps, mpert = 27.
eulerlagrange_integrationwall timeeulerlagrange_integrationallocationsmaterialize_derivative_stores!Base.summarysize(odet)after materializationThe RHS itself is 1.76× faster because the A/B/C evaluations, the factorization of A and the
two Ξ_s GEMMs no longer run ~16 times per step. Materialization then pays that cost back
once per saved node, which is where the remaining ~20 % net win comes from. Whole-pipeline
numbers (see the regression table above) show a smaller 2–3 % because the EL integration is
one stage among equilibrium setup, vacuum, and the perturbed-equilibrium solve.
benchmarks/benchmark_git_branches.jlwas not run separately: the regression harnessalready executes both refs on four decks and reports whole-pipeline wall time, which is the
same measurement without stashing and switching the main working tree.
Memory
One
(mpert², nstep)ComplexF64slab is 25.0 MiB on the full DIII-D deck (mpert = 35,nstep = 1337).
resize_storage!doublingBefore,
resize_storage!doubled five slabs and had to hold old and new simultaneously.Now it grows only
u_store, and the derivative arrays are allocated once at exact size.The
dot→transposeconjugation change from #348#348 silently changed the contraction of the solution columns with the edge coefficient
vector from
dot(u_l, ck)totranspose(u_l) * ck— i.e. it stopped conjugatingu_l.That was an undocumented fix to a real pre-existing bug, and it is worth documenting
properly because it moves physics-facing numbers by tens of percent.
Fortran is unambiguous, and it does not conjugate.
gpec/idcon.f:504-507builds thesplined solution the whole GPEC output chain reads from:
Plain
MATMUL, noCONJG— the galerkin branch atidcon.f:489-490likewise.gpeq_sol(
gpeq.f:73-96) andgpout_singcoup(gpout.f:595-615) then consumeu1%f/u2%fdirectly. Julia stores the mode index as the row of
u_store[resnum, :, …], so contractingover the solution-column index is
transpose(u) * ck;dotwould conjugateuand issimply the wrong operation.
What it moves (same branch, same deck, only the contraction changed):
resonant_currentC_delta_primeC_resonant_area_weighted_fieldC_island_width_sqdelta_primeresonant_area_weighted_fieldisland_width_sqchirikov_parameterisland_half_widthrational_q,penetrated_area_weighted_fieldEverything upstream of the contraction is untouched (
integration/xi_psiandsingular/delta_prime_matrixare bit-identical between the two variants), which confirmsthe difference is localized to this one operation.
Note for the record: magnitude-derived quantities are not conjugation-invariant here, as
one might assume — conjugating
u_lchanges the value of the sum, not just its phase.island_half_widthmoves 5.6 %, and Im(Δ′) changes sign on every surface:Why there is no absolute Fortran number in this table
The plan called for comparing both variants against Fortran reference output. That turned
out not to be possible, and the reason is worth recording:
gpec_1/gpec_dev/docs/examples/DIIID_ideal_example/*.outaregitignored artifacts dated 2024-03-27, while that deck's
equil.in/dcon.inare from2026-08 and the g-file was committed 2026-06. They describe a different equilibrium: they
place q=2 at ψ_N = 0.5936 and find 4 rational surfaces. Reading
qpsistraight out ofTkMkr_D3Dlike_Hmode.geqdskgives q=2 at ψ_N = 0.51830, q=3 at 0.76976, q=4 at 0.89257,q=5 at 0.96821, q=6 at 0.99305 — which this branch reproduces to ~1e-5
(0.5183053, 0.7697635, 0.8925824, 0.9682567, 0.9932815). The archive is stale; Julia is
consistent with the equilibrium file.
the currents Fortran assigns to
il, so forcing-dependent quantities were nevercomparable regardless.
module load gpec, GPEC-1.5) needs eight namelistvariables stripped that the release build does not know (
bin_vac,wv_farwall_flag,etol,out_ahg2msc,sing_interpspot,sing_resspot,use_res_spot,coil_threads)and then still aborts in
direct_int: istep = nstep = 2048 … ipsi = 128at bothpsihigh = 0.995 and 0.990.
An absolute cross-check therefore needs the dev Fortran branch built from source. That is
worth doing as its own task; it is not a prerequisite for this PR, whose claim is that the
numbers do not change.
FastInterpolations version insensitivity
Issue #347 was a hypersensitivity: ULP-level input perturbations moved resonant outputs by
~25 %. To show that is still fixed, the DIII-D-like deck was run on this branch under three
pinned FastInterpolations versions in scratch environments (
Pkg.developthe repo, thenPkg.add(name="FastInterpolations", version=…); the repoProject.toml/Manifest.tomlwere never touched). Note the repo itself currently resolves to 0.4.18, so all three are
off-default.
Relative differences against 0.4.15:
rational_q,singular/qchirikov_parameterdelta_primeisland_half_widthresonant_area_weighted_field(b_res)resonant_currentintegration/nstepsingular/delta_prime_matrix0.4.16 and 0.4.17 are identical to each other. Between 0.4.15 and 0.4.16 the interpolant
changed enough that the adaptive solver takes a ~1 % different number of steps — a real
perturbation, far larger than the ULP-scale ones that used to blow up. The resonant
quantities respond at the 1e-4 level, i.e. sub-proportionately. That is the behaviour of a
well-conditioned calculation, and it is the point of the table.
Two honest caveats:
singular/delta_prime_matrixmoves 4.5 %, an order of magnitude more than the resonantquantities. That is the STRIDE BVP Δ′ matrix from the parallel FM path, which is
conditioning-sensitive and not touched by this PR (it is bit-identical between
developand this branch). It is flagged here as a pre-existing sensitivity worth aseparate look, not as a regression.
branch tracks
developat any library version, not just the default one. Checkeddirectly (see below).
Bit-identity is not an artifact of one library version
developwas also run under FastInterpolations 0.4.15 and compared to the branch's0.4.15 run. That version takes a different number of steps from the 0.4.18 default (1350
saved vs 1337), so it is a genuinely different trajectory — and the two codes still agree
exactly:
integration/psi,q,crit(1350 each)integration/xi_psi,u2(1 653 750 each)singular/delta_prime_matrix,delta_prime_raw,psi,q,ca_left,ca_rightDerived quantities under 0.4.15:
dxi_psi/xi_sat 3.6e-08 relative and the resonantquantities at ≤2.6e-11 — slightly looser than the 4.9e-11 / 6.5e-12 seen at 0.4.18, but the
same class of floating-point reassociation noise.
Note on pre-commit hooks
Both commits were made with the pre-commit hooks bypassed, deliberately. Running
JuliaFormatter v1.0.62 over the touched files rewrites large amounts of untouched
committed code —
Riccati.jlalone produced ~477 changed lines, and formatting pristineHEADcopies confirms the tree is not formatter-clean to begin with. Rather than ship thatchurn, the edited lines were hand-conformed to
.JuliaFormatter.toml(margin 180,f(x; a=1),for … in, no trailing commas) and the formatter's whitespace-only hunks werereverted.
git diff develop..HEAD --checkis clean, all files end in exactly one newline,and there are no CRLF line endings. Flagging it so a reviewer can ratify the choice.
Tests
Full suite green on the committed tree (57 testsets, exit 0). Updated: storage-shape assertions in
runtests_eulerlagrange.jl,bit-identity comparison in
runtests_parallel_integration.jl(materializes both statesfirst, and now asserts the sparse path leaves the stores empty). Added a
materialize_derivative_stores!testset covering idempotence, agreement with a directkernel evaluation, commutation with a column transform, and refusal outside the
Euler-Lagrange basis; plus
transform_u!coverage for pre-filled derivative stores, whichhad none.
🤖 Generated with Claude Code
https://claude.ai/code/session_0135hNoGgzrSPQk1pf4VhchK