Repository navigation
ForceFreeStates - PERF! - 🚨 Precompile the solve path and pin the harness to one build mode - #496
Conversation
…ss to one build mode A PrecompileTools workload runs a small inline Solovev case through the Riccati and forward formalisms at build time, so a fresh process skips most first-call compilation (DIII-D first main() 108 s -> 25 s; one-time build ~160 s). Caches the workload fills are cleared before the image is serialized. Code compiled into the package image rounds differently in the last bit from code compiled on first use (traced to the FastInterpolations spline kernels inlined into direct_get_bfield!), and the Delta' BVP amplifies that to ~1%. The regression harness therefore sets precompile_workload=false for every case via a stacked preference environment, keeping existing pins comparable, and the new diiid_n1_riccati_precompiled case tracks the precompiled build on its own. Users can disable the workload with the same preference (docs/src/set_up.md). The CI manifest pins are a fresh resolve for the new dependencies and also pick up upstream releases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… developer LocalPreferences.toml Preferences from earlier load-path entries win, so the stacked preference environment now goes before the active project. Also extend a user's JULIA_LOAD_PATH instead of overwriting it, and gitignore LocalPreferences.toml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…environment fingerprint The run epilogue now writes build_mode=aot|jit (precompile workload present and enabled, or not). It is stored with the run, shown in the env line, flagged when two compared runs differ, and warned about when a run's mode differs from its case's, e.g. the precompiled case run at a commit that predates the workload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the precompile workload The workload now calls one reset per module instead of reaching into their private caches, so a new cache is cleared where it is defined. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cument the recorded build mode set_up.md now sets the preference by UUID, since loading GPEC first builds it with the workload on, and drops the benchmark timings. The workload names its source deck and what it coarsens; the harness doc describes the build-mode fingerprint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…precompiled Riccati case from it A case file may set [case] base = "<case>" to inherit that case's example_dir, [quantities.*] and [overrides]; its own keys replace the base's same-named ones. A missing or chained base is a load error. diiid_n1_riccati_precompiled now supplies only name, description and precompile_workload instead of a 156-line copy that could drift from its base. Check: regress --list-cases shows diiid_n1_riccati_precompiled with the same quantity count (18) as diiid_n1_riccati. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… new dependencies The pins were a fresh resolve that also moved about 69 package versions. They are now develop's pins with only project_hash updated for PrecompileTools and Logging, both already in the resolved set, so this branch tests the precompile workload against the same dependencies as develop. The 1.11 pin was regenerated with Pkg.resolve(preserve=PRESERVE_ALL) on Julia 1.11.9; the 1.12 pin takes the project_hash Julia 1.12.7 computed for this Project.toml. A general dependency bump can ship separately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d disable it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng before the image is serialized Develop replaced the single-entry Legendre-quadrature memo (_PN_LAST_N, _PN_LAST_ENTRY) with the locked per-n dictionary alone, and the merge kept the two lines that reset it, so the precompile workload failed at reset_caches!() and the package did not build. A test now calls both modules' reset_caches! and checks the caches are empty, so a renamed cache fails the test suite rather than the build. The rest of runtests_vacuum.jl changes only by the project formatter's keyword-argument separator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it in the Area table Every top-level file under src/ is an Area in docs/development/naming.md and is named in CamelCase like HDF5Schema.jl and Rerun.jl. The harness detects the workload by this file name, so its lookup changes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pile-workload Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e spelling, and test that the caches are really emptied Refs from before the rename carry src/precompile.jl, which a case-sensitive file system reported as no workload. The cache test now fills all three caches before resetting them, and the setup notes say that a test build rebuilds the image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
This PR adds a PrecompileTools workload to reduce first-run JIT latency and updates the regression harness to explicitly model/record “jit vs aot” build mode so numerical comparisons remain meaningful.
Changes:
- Introduces a build-time precompile workload that exercises Riccati/forward solve paths and resets run-filled caches before image serialization.
- Adds
reset_caches!APIs for Vacuum/KineticForces module caches and tests that those caches are cleared. - Updates the regression harness to control the
precompile_workloadpreference per-case, and to persist/report build mode in fingerprints and the DB schema.
| File | Description |
|---|---|
| test/runtests_vacuum.jl | Switches to explicit keyword calls and adds a test ensuring caches emptied by reset_caches!. |
| src/Vacuum/PnQuadCache.jl | Adds Vacuum.reset_caches!() for module-level caches. |
| src/KineticForces/BounceAveraging.jl | Adds KineticForces.reset_caches!() and minor formatting/docstring fixes. |
| src/Precompile.jl | Adds PrecompileTools workload that runs a small Solovev case during package image build. |
| src/GeneralizedPerturbedEquilibrium.jl | Includes the new Precompile.jl file into the module. |
| regression-harness/src/types.jl | Adds precompile_workload::Bool to case specs and documents build-mode meaning. |
| regression-harness/src/config.jl | Adds case inheritance ([case] base) and parses precompile_workload. |
| regression-harness/cases/diiid_n1_riccati_precompiled.toml | New case that enables the workload to track the precompiled build. |
| regression-harness/src/runner.jl | Applies per-case workload preference via a stacked env; records/validates build mode. |
| regression-harness/src/env.jl | Extends environment fingerprint with build_mode and updates formatting/docs. |
| regression-harness/src/reporter.jl | Flags build-mode mismatches in environment comparisons. |
| regression-harness/src/database.jl | Adds build_mode column and persists it in run records. |
| docs/src/set_up.md | Documents how to disable the workload via Preferences/LocalPreferences.toml. |
| docs/development/regression-harness.md | Documents harness build-mode behavior and case inheritance semantics. |
| docs/development/naming.md | Adds Precompile to the naming table. |
| Project.toml | Adds PrecompileTools (and Logging) deps/compat entries. |
| ci/manifests/* | Updates CI manifest project hashes/checksum after Project.toml changes. |
| .gitignore | Ignores LocalPreferences.toml. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const GPEC_UUID = "462872dd-e066-4d2e-b993-6468b5239634" | ||
| const _PREFS_ENVS = Dict{Bool,String}() | ||
|
|
||
| """ | ||
| Launch `cmd` with GPEC's `precompile_workload` preference set from `case_spec`, via a small | ||
| environment stacked on the load path so no file is written into the run's project. | ||
|
|
||
| The environment goes first: preferences from earlier load-path entries win, so this outranks a | ||
| developer's `LocalPreferences.toml` beside the run's `Project.toml`. It declares GPEC only under | ||
| `[extras]` and has no Manifest, so package loading still falls through to the active project. | ||
| """ | ||
| function with_workload_preference(cmd::Cmd, case_spec::CaseSpec) | ||
| workload = case_spec.precompile_workload | ||
| env = get!(_PREFS_ENVS, workload) do | ||
| dir = mktempdir() | ||
| write(joinpath(dir, "Project.toml"), "[extras]\nGeneralizedPerturbedEquilibrium = \"$GPEC_UUID\"\n") | ||
| write(joinpath(dir, "LocalPreferences.toml"), "[GeneralizedPerturbedEquilibrium]\nprecompile_workload = $workload\n") | ||
| return dir | ||
| end |
| @lock _PN_CACHE_LOCK empty!(_PN_CACHE) | ||
| SINGULAR_QUAD_CACHE[] = nothing |
adrianaghiozzi
left a comment
There was a problem hiding this comment.
Reviewed with Claude Code, going through each piece independently rather than just reading the PR description. Summary below; happy to expand on any item.
What I checked and verified directly (not just read)
reset_caches!completeness. Grepped the wholesrc/tree for every module-level mutableconst(Dict/Ref/etc.), not just the three this PR touches. Found 6 total: the 3 genuine run-filled caches (Vacuum._PN_CACHE,Vacuum.SINGULAR_QUAD_CACHE,KineticForces._QUAD_WEIGHTS) are now correctly emptied by the two newreset_caches!functions; the other 3 (_COIL_KEY_DEFAULTS,ANALYTIC_EQ,ITPA_THRESHOLD_SCALINGS) are static lookup tables — confirmed every reference to them anywhere insrc/is a read, never a mutation, so they genuinely don't need resetting. This is a complete fix, not just "the two bugs they happened to find."- The load-path preference-override mechanism (
with_workload_preferenceinrunner.jl), which the whole build-mode-pinning feature depends on. Reproduced it directly: planted aLocalPreferences.tomlin the worktree set tofalse(simulating a developer's own setting), then confirmed a synthetic environment prepended toJULIA_LOAD_PATHwithprecompile_workload = truecorrectly overrides it (Base.get_preferencesresolves totrue). Reversed the experiment — appending instead of prepending the same synthetic env — and confirmed the developer'sfalsewins instead, proving it's genuinely the load-path order doing the work, not an artifact of the test. The "outranks a developer'sLocalPreferences.toml" claim is correct and now empirically tested, which matters since every regression-database row's build-mode label depends on it being right. - The CI manifest pin. Confirmed
PrecompileTools's manifest stanza (git-tree-sha1) is byte-identical betweendevelopand this branch — it was already a transitive dependency, just not a direct one, so promoting it changes nothing resolved.Loggingis a stdlib, needs no stanza. The one-lineproject_hashdiff in each pinned manifest is exactly what you'd expect from aProject.tomldeps-list change with zero actual version movement. test/runtests_vacuum.jl's 115/97-line diff, which looks unrelated to a precompile PR at first glance. It isn't: the only substantive addition is one new@testset("reset_caches! empties the run-filled caches") that fills all three caches, asserts they're populated, resets, and asserts empty — exactly the right regression guard against a future rename silently breaking the reset. Everything else in the diff is JuliaFormatter normalizing this file's pre-existing kwarg-call style to the repo's semicolon convention, triggered as a side effect of touching the file — expected per this repo's own documented formatter-churn policy, not something to push back on.- Ran the full kinetic/vacuum reasoning through by hand; did not find anything in
Precompile.jlitself, theCaseSpec/case-inheritance mechanism (resolve_case_base, one level deep, chained bases correctly rejected), or the_warn_build_modesafety net that looks wrong.
Independently reproduced the Apple-Silicon AOT-vs-JIT claim — and it's larger than "about 1%"
This machine is arm64, so I built the branch both ways (AOT via the real with_workload_preference load-path trick, JIT with the workload off) and ran the actual DIIID-like_riccati_deltaprime_example through both, same commit, same machine. Speedup matched the PR's feynman numbers proportionally (here: 35.7s AOT vs 169.3s JIT, ~4.7x; PR: 53.1s vs 242.7s, ~4.6x).
To rule out generic multithreading nondeterminism before attributing anything to build mode, I ran the JIT build twice as a control: bit-for-bit identical on every quantity I checked, including Delta_prime_matrix's diagonal and eigenmode_energies' stored order (0.0 diff both). So this build is fully deterministic on its own, and the AOT-vs-JIT divergence below is a genuine build-mode effect, not run-to-run noise.
The divergence itself is real but non-uniform, and bigger in the worst case than "about 1%" suggests:
- 3 of 5 singular surfaces: real parts of the Δ′ diagonal agree to ~0.1–1%, roughly matching the PR's framing.
- The surface closest to marginal (ψ≈0.968):
-2201.14+276.67i(AOT) vs-2197.22-1035.19i(JIT) — real parts close, but the imaginary part flips sign and changes magnitude by ~4x. eigenmode_energies' stored order also differs between AOT and JIT (confirmed real via the same JIT-vs-JIT control, which reproduced the order exactly) — so build mode can reshuffle which eigenvalue lands at which index, not just perturb values. Anyone consuming that array positionally across build modes would see what looks like total divergence rather than a reordering of an otherwise mostly-consistent set.
This is consistent with, not contradicting, the PR's physical explanation (Δ′ BVP amplifying near-marginal conditioning) — if anything it strengthens the case for pinning the harness to one build mode. Suggested amendment, not a blocker: the release note should say the divergence can include sign flips and eigenvalue reordering at near-marginal surfaces on Apple Silicon, not just "about 1%," so nobody reading it later underestimates how different two builds can look.
One non-blocking design question
The workload gets expensive specifically for people in a Revise-based edit/restart loop (every src/ change that forces a Julia restart re-pays the ~160s Solovev rebuild). The PR's answer is a manual opt-out preference, which is simple and predictable. Worth asking whether a Revise-presence check (auto-skip the workload when Revise is loaded) was considered instead of, or in addition to, the manual toggle — not requesting a change, just curious about the tradeoff that was weighed.
Overall
No correctness issues found anywhere in the diff after going through it piece by piece and independently verifying the three parts most likely to hide a subtle bug (cache-reset completeness, the preference-override ordering, and the Apple-Silicon build-mode divergence itself). The regression report in the PR body is thorough and the one new case is well-designed to track the precompiled build without polluting comparability of the other 16. The one suggested change is tightening the release note's description of how large the AOT/JIT divergence can get — everything else here is ready as-is.
|
Claude review provided above, but just wanted to highlight the one part that I (human Adriana!) thought was interesting. You do add a slight cost with this method for someone who is working in a development loop and potentially invoking a fresh "using GeneralizedPerturbedEquilibrium" several times in a row. I see that to get around that you currently have the ability to have a toml file with instructions to skip the small bit of extra precompile work. Since the codebase is still in such active development and I imagine many people rely on the Revise package when developing, one optional update would be to automatically check whether someone has loaded Revise and skip the extra precompile if so. Up to you if that sounds worthwhile or not but otherwise this should be good to go! |
|
Thanks @adrianaghiozzi! Regarding the release note: you’re right that “about 1 %” undersold it, I’ve amended the note. From claude: "on Apple Silicon the real parts of the Δ′ diagonal differ by about 1 %, but at a near-marginal surface the imaginary part changed sign and magnitude, and the stored order of eigenmode_energies differed. That matches what I saw in September on a near-marginal Solovev case, where the order change was two nearly degenerate modes swapping in the sort by Re(energy). I’ll re-measure the arm64 pair once OpenFUSIONToolkit/GPEC#491 is in, since it tightens the equilibrium tolerance this difference is amplified through." Regarding skipping the workload under Revise – I looked into it and I don't think it can be detected at build time. Julia builds the package image in a separate process that loads only GPEC’s dependencies, so it cannot see whether the session that triggered the build has Revise loaded (checked on 1.11). The image is also cached per preference setting, not per session, so whichever session built first would set the build mode for the next one, which is exactly what the harness now records. So the opt out stays the precompile_workload preference in docs/src/set_up.md: this is one line, per each checkout, and gitignored. Within a single Revise session nothing is rebuilt, and the cost only appears on a restart after editing src/. |

Release note
eigenmode_energiesdiffered between the two builds. None of this has been reproduced on x86_64. As a precaution the regression harness still runs every case with the workload off, so existing values stay comparable, and a newdiiid_n1_riccati_precompiledcase tracks the precompiled build. (harness @ 6b92b2c)precompile_workloadpreference (docs/src/set_up.md).A PrecompileTools workload runs a small inline Solovev case through the Riccati and forward paths at build time, so a fresh process skips most first-call compilation: the first
main()on the DIII-D-like example drops from 108 s to 25 s, for a one-time build of about 160 s.The workload runs only when Julia rebuilds the package image, not at the start of each run: after any change under
src/(a pull, a branch switch or a local edit) and after a change of dependency versions, Julia version or the preference. Repeated runs on an unchanged checkout reuse the cached image. Developers who editsrc/and restart Julia often will usually want it off locally;docs/src/set_up.mdsays how.Left: wall time of one run of the DIII-D-like Riccati Δ′ case from a fresh Julia process on feynman, and the one-time cost of building the image. Right: the precompiled build against one compiled on first use, for every tracked numeric quantity of that case: bit-identical on x86_64.
Regression report
Runs on feynman (SLURM, 8 threads, manifest pinned). The first three are against develop
cb18e386e, the last against current develop0e68a0553, which includes #480:43ce87228:regress --refs cb18e386e,43ce87228 --force. All workload-off cases are unchanged apart from the known SLAYER γ run-to-run noise:ggj_ray_q500isolovev_kinetic_nuzerodiiid_error_fielddiiid_slayer_n1gal_resistive_pesolovev_kinetic_calculatedgal_resistive_diiidsolovev_n1ggj_referencesolovev_multi_ndiiid_n1solovev_kinetic_ntvdiiid_n1_riccatisolovev_kinetic_multiiondiiid_multi_nefit_fixedbdy_separatrixd5246bfd4:regress --cases diiid_n1_riccati,diiid_n1_riccati_precompiled --refs cb18e386e,d5246bfd4 --force.Vacuum.reset_caches!still cleared two caches that develop had removed.d5246bfd4fixes that.diiid_n1_riccati: 17 unchanged.diiid_n1_riccati_precompiled: 17 unchanged. On this run the precompiled (aot) build matched develop's jit numbers on every quantity.diiid_n1_riccati_precompiledAfter the rename to
src/Precompile.jl, at6c6c04266:regress --cases diiid_n1,diiid_n1_riccati,diiid_n1_riccati_precompiled --refs cb18e386e,6c6c04266 --force.diiid_n1: 53 unchanged. Both Riccati cases: 17 unchanged. The rename moves no code, and the harness still detects the workload under the new file name (the precompiled case reports anaotbuild).After merging develop, at
6b92b2c31:regress --cases diiid_n1,diiid_n1_riccati,diiid_n1_riccati_precompiled --refs 0e68a0553,6b92b2c31 --force.diiid_n1: 53 unchanged. Both Riccati cases: 17 unchanged, including the precompiled case, where develop runs compiled on first use and this branch runs from the precompiled image. The workload builds on the merged code in 203 s.After the delta-review fixes, at
cd4389948(harness, test and docs only; no file undersrc/changed):regress --cases diiid_n1_riccati,diiid_n1_riccati_precompiled --refs 0e68a0553,cd4389948 --force. Both cases: 17 unchanged, and the precompiled case still reports anaotbuild.The Vacuum test file passes 392/392, including a test that fills all three run-filled caches and checks that both modules'
reset_caches!empty them.Notes for reviewers
direct_get_bfield!. It is not seen on x86_64 (see the release note).aotorjit) in the environment fingerprint, and the report flags two runs compared across modes;LocalPreferences.toml;[case] base = "<case>", which is how the precompiled case is derived without a copy;Precompile.jlare not mislabelled on a case-sensitive file system.src/precompile.jlbecamesrc/Precompile.jl. On a case-insensitive file system an existing checkout may keep the old spelling on disk after a pull, and the naming hook then reportsprecompileas missing from the Area table. A fresh checkout, or a two-stepgit mv, clears it.reset_caches!, which the workload calls before the image is serialized.project_hashupdated for the two new dependencies (PrecompileTools,Logging), both already in the resolved set. No package versions move.🤖 Generated with Claude Code