Repository navigation
KineticForces - PERF! - Solve the bounce points directly from the spline coefficients - #456
Conversation
…xtrema _find_bounce_points_and_grid called Roots.find_zeros — a generic adaptive scan of the whole theta interval — once per pitch angle, so the scan repeated for all 128 lambda points on every flux surface, bounce harmonic and species. The bounce points are the level set B(theta) = bo/lambda, and B is monotone between its stationary points, so each such interval holds at most one root and supplies a guaranteed bracket. Enumerate those stationary points once per surface by walking the cubic's cells through the public coeffs/CellPoly interface and solving the quadratic derivative on each, then per lambda test the cached endpoint B values and Brent-solve only the intervals that actually bracket. This keeps the completeness of the adaptive scan — including root pairs that straddle an extremum too closely for a node-by-node sign scan to separate — while moving the per-cell work above the lambda loop. Degenerate lambdas, where v_par is tangent to zero at an extremum so no interval brackets strictly, fall back to find_zeros and keep their existing behaviour; the lambda grid excludes both trapped-passing endpoints, so these are rare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU
Assert the stationary-point enumeration is exact (dB/dtheta vanishes at each point, the count matches a dense derivative sign scan, and a period holds an even number) and that the bracketed solver reproduces Roots.find_zeros across the whole trapped lambda range — including lambdas pushed to within 1e-12 of both trapped-passing boundaries, where the two roots straddling an extremum are arbitrarily close and a node-by-node sign scan would merge them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU
…ot another solver The near-boundary lambdas asserted equality with Roots.find_zeros, which encodes that solver's resolution limit as the expected answer: as lambda approaches the trapped-passing boundary the two roots straddling the extremum collapse together, and whether the adaptive scan still separates them depends on its own step heuristics and on floating-point rounding that differs between Julia 1.11 and 1.12. The bracketed solver would then fail CI for being more accurate. Assert the geometry instead — exactly two roots, v_par vanishing at each, and the arc enclosing the extremum closing monotonically as lambda tightens, ending far below the theta-grid spacing. The arc is measured on the circle: this fixture's B maximum sits at theta ~ 0.999, so the enclosing arc runs through the theta = 0/1 seam. Equality with find_zeros is kept for the 200-point bulk sweep, where it is a fair comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU
…nce-root-enumeration
…erating The bracketed Brent solve was iterating to find something the spline already writes down. Inside a cell the interpolant is a cubic with known coefficients, so the crossing B(theta) = bo/lambda is a closed-form root, not a search. Decompose B once per surface into its cells, keeping each cell's coefficients and its endpoint value alongside the stationary points already being enumerated. Locating the crossing then needs only the cell, and the cell need not be searched for blind: lambda ascends through compute_bounce_data, so bo/lambda falls monotonically and each interval's crossing walks steadily along the cells. Each lambda resumes from the previous one's cell -- the same hint idiom FastInterpolations uses for its own searches -- which lands in the same cell for the median lambda and steps one or two otherwise, against about eight probes for a bisection that starts from nothing every time. Bisection remains the fallback for the first lambda and for the sweep near a stationary point, where B is flat and the crossing can jump many cells at once. The cubic is solved by the trigonometric form, which stays conditioned where Cardano's radicals cancel, with one Newton step to recover the digits lost when two roots nearly coincide, and an explicit repeated-root branch for the discriminant cancelling to zero. Roots now satisfy v_par = 0 to 4.4e-16 rather than to a solver tolerance, and agree with Roots.find_zeros to 1e-13 over 3000 lambda on each of five surface geometries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU
…nce-root-enumeration
|
Reviewed this (with Claude Code) — solid change, no correctness issues survived review. Notes below, roughly in order of how much I'd want an answer before approving. What I checked
Verified with an out-of-tree probe: the same-cell double-extremum path is correct, but untested
So the branch is correct — it bounds the cubic solve by the stationary points themselves rather than the cell's knot boundaries, which is what makes it work — but it's presently relying on code-reading rather than test coverage. Given it's the one branch this PR adds that the existing fixture can't reach, worth adding a small test case with two close-together extrema in one cell (happy to hand over the probe script if useful, it's disposable throwaway code, not proposing it as-is for the suite). Found and confirmed a real misclassification bug in
|
adrianaghiozzi
left a comment
There was a problem hiding this comment.
Claude left a few comments for posterity but the TLDR is that this should be good for merge.
It suggested an optional addition to the test suite - up to you whether that seems worth doing. And then it also pointed out an edge case in which '_real_cubic_roots' would fail, but noted that it's an issue that would likely never arise on a real equilibrium. Claude's analysis about this is documented here in case the issue ever does pop up.
|
Thanks @adrianaghiozzi ! |
|
Thanks @adrianaghiozzi — both findings were right, and the probe made the first one trivial to reproduce ( Follow-up is #483. Beyond the two items here, re-reviewing the merged code with your example in hand turned up the same cancellation at two more sites — the quadratic branch for a degenerate leading coefficient, and the stationary-point search in The same-cell path now has a deterministic fixture rather than a random one ( 🤖 Generated with Claude Code |
Release note
diiid_n1; the twokinetic_source = "calculated"cases move at 0.00% relative (1e-7 to 1e-8 absolute energies, 1-4 different adaptive ODE steps out of ~730) (harness @ 2df2724)Bounce-point location in the NTV kinetic module no longer searches. It solves the crossing directly from the spline's own cubic coefficients, cutting the KineticForces stage of the DIII-D-like example by roughly a quarter. It also resolves bounce-point pairs closer together than the poloidal grid spacing, which the previous adaptive scan could merge into one.
What changed
_find_bounce_points_and_gridcalledRoots.find_zeros— a generic adaptive scan over all of theta — once per pitch angle, so the scan repeated for all 128 lambda points on every flux surface, bounce harmonic and species.Bounce points are the level set B(theta) = bo/lambda, and B is monotone between its stationary points, so each such interval holds at most one root. That is enough to make the whole thing direct, with no search of any kind:
coeffs/CellPolyinterface, records each cell's polynomial and endpoint value and solves the quadratic dS/dtheta = 0 on each to get the exact stationary points.compute_bounce_data, so bo/lambda falls monotonically and each interval's crossing walks steadily along the cells. Starting where the last lambda finished is the same hint idiom FastInterpolations uses for its own searches: measured on the production grid over 256 cells, the median lambda stays in the same cell and the mean moves 2.84, against ~8 probes for a bisection that starts blind every time. Bisection remains the fallback for the first lambda and for the sweep near a stationary point, where B is flat and the crossing can jump many cells at once.Cost accounting
Julia's
Profilesampler around the wholemain()call onexamples/DIIID-like_ideal_example, 16 threads, JIT excluded by a warm-up run. Shares are against attributed CPU work; the raw sample count is dominated by idle worker threads (~210k samples parked inpoptask), which would roughly halve every share if left in.The per-lambda work is 241x cheaper. The per-surface decomposition is the new cost: it also builds the knot, knot-value and coefficient vectors, three 256-element allocations per
(psi, ell)call, andcompute_bounce_dataruns 13 times per surface at nl = 6. That 0.57% is recoverable by decomposing once per psi instead, which needs a signature change inTorque.jl; it is left as a follow-up to keep this change inside one file.Wall time: 92.5 to 76.1 s overall, KineticForces stage 54.2 to 41.9 s. QuadGK is now the dominant remaining cost at 43.6% and the natural next target. Read the shares within a run rather than absolute counts across runs — the profiles ran under different machine load, which moves absolute counts by several percent either way.
Correctness
Every trapped lambda on 40 flux surfaces of the DIII-D-like equilibrium, checked against
Roots.find_zeroson the identical spline, plus lambda injected to within 1e-13 of both trapped/passing boundaries:find_zeros' rootsfind_zeros(production grid: 0)Both solvers zero v_par to the same 4.441e-16, so the direct solve is not more accurate than
find_zeros— it is equally accurate without iterating. The 2.5e-10 displacement is conditioning, not error: near a fold abs(dB/dtheta) is ~1e-6, so two theta values that far apart are both roots to the last representable bit.Of the 80 count disagreements, 78 are ones where the new method is more complete: a part in 1e13 from the boundary the adaptive scan reports a single root where there are two, and the interval method separates them. The other 2 are the closed form declining to place a root in the expected cell at that same extreme and deferring to the fallback — more conservative than a bracketed iteration, which always returns something.
This matters beyond the tangency limit. At lambda within 1e-6 of the boundary the two bounce points straddling the B maximum sit 5.3e-4 apart against a 3.9e-3 theta-grid spacing — both inside a single cell. That is why the tempting swap to the existing
find_sign_change_rootshelper, which only detects sign changes between adjacent grid nodes, is not a safe substitution: it would find neither root and silently change which well_find_deepest_wellselects.Exact tangency, where no interval brackets strictly, is unreachable by construction because
_build_lambda_gridexcludes both endpoints in all three method branches. Any lambda yielding fewer than two roots still falls back tofind_zeros, preserving existing behaviour.Regression report
Re-run at
2df272486, after merging develop at4d47467db(which added the ErrorFields NTV-limited correction case,diiid_error_field, also run here since it exercises the bounce solve). Both refs were built from the pinned CI manifest.diiid_n1diiid_error_fieldsolovev_kinetic_ntvsolovev_kinetic_multiionsolovev_kinetic_calculatedsolovev_kinetic_nuzeroNotes for reviewers
Why the Mode A cases move. Same mechanism, not a separate effect: root positions shift in their last digits, the kinetic matrices follow, and the adaptive Euler-Lagrange integrator then takes one to three different steps out of ~735, surfacing as 1e-8 energy differences. Nothing moves at a physically meaningful level. The PR is tagged
!because results do move, not because anything meaningful changed.Where the speed actually came from. An intermediate version of this branch kept a bracketed Brent solve inside the monotone interval; it already had the per-lambda cost down to 0.70%. Replacing it with the direct solve took that to 0.062% but bought almost no wall time (77.6 to 76.1 s), because there was little left to win. The direct solve earns its place on exactness and on removing an iterative solver with a tolerance from a physics kernel, not on speed. Worth knowing before weighing the added cubic-root code.
An honest negative. The three psi-quadrature harness cases got faster, but the two Mode A cases did not. Those runs shared the node with other jobs, so the timings are noisy in both directions, but no Mode A speedup is claimed. The profile exercised the quadrature path only, since the DIII-D example runs
kinetic_source = "fixed".Scope. The diff touches
BounceAveraging.jlonly. Bothcompute_bounce_datacall sites inTorque.jlare unchanged, so this does not collide with the open_compute_fkg_matriceswork, and it serves both the quadrature and Mode A entry points without a signature change. The thread-localdeepcopypool inCompute.jland thethreadid()buffer indexing inForceFreeStates/Kinetic.jlare deliberately left alone because both sit in files that open work already edits.Tests. 1844 assertions in
runtests_kinetic.jl, including the cubic solver against polynomials with known roots — which is how the repeated-root branch was found necessary, since the discriminant cancels to zero for2u(u-1)^2and a naive branch reports only the simple root — and 480 assertions that a deliberately corrupt cell hint (out of range, negative, 1e6) yields byte-identical roots, so the hint can only cost steps, never change an answer.Prior art. The completeness idea here — that a node-by-node sign scan is not a safe replacement for the adaptive scan — comes from krystophny's #341. This rebuilds it on the supported
coeffs/CellPolyAPI rather than reaching into the interpolant's private cache fields.Review package: https://claude.ai/code/artifact/edf0c0db-776c-4c50-a283-7413bbc0ba96
Why you. Tagging you as reviewer because the profiling in #380 is what pointed at this hotspot, and because your report is also the reason it was re-measured first: those shares predated #390/#391/#392, and
find_zerosturned out to be 15.0% of CPU work rather than the ~26% quoted. Thefind_sign_change_rootsswap your report suggested and then declined is treated at length under Correctness above — declining it was right, and the figure in the review package shows the case that would have broken it.🤖 Generated with Claude Code
https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU