diff --git a/changelog.d/fftw-plans-kept.changed.md b/changelog.d/fftw-plans-kept.changed.md new file mode 100644 index 00000000..a8626195 --- /dev/null +++ b/changelog.d/fftw-plans-kept.changed.md @@ -0,0 +1 @@ +The native backend now keeps the FFTW plans its runs measure by default, so each transform shape is measured once per machine and build instead of on every run. Plans are kept per transform in `wisdom.json` in the calibration store's directory for the machine and build, and a calibration profile's own plans take precedence. `SOLPROP_FFTW_WISDOM` now only moves this file, and the file it names no longer turns off a profile's FFT planning. `SOLPROP_CALIBRATION=off` keeps no default file, and `solprop calibrate --clear` removes the plans. Plans kept by earlier releases are not read; their files can be deleted. A name that cannot be used now logs a warning and the run plans without stored plans, where it used to raise. The store is keyed by the machine fingerprint, which tells Apple silicon models apart (#136). diff --git a/docs/dev/backends.md b/docs/dev/backends.md index cb4e0282..32e3d644 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -746,7 +746,8 @@ Threads are a property of the plan, chosen at construction: a plan of fewer real than `default_threaded_fft_min_samples` runs on one thread (§4), and the others take their width from the size rules of §3.4, within the process's thread budget (ADR-052: the affinity mask, the cgroup CPU quota on the process's own path rounded down, and `SOLPROP_THREADS`; -never `os.cpu_count()`). Wisdom is explicit and opt-in (`SOLPROP_FFTW_WISDOM`, §4). +never `os.cpu_count()`). The plans runs measure are kept by default, per machine and +build (ADR-076, §4). ### 3.4 Threading regimes @@ -1309,16 +1310,39 @@ threaded plans; free space's transverse planes are measured at any width. An exp planning, `measure` and experimental `patient` time it. Estimate can still consume matching wisdom already present in the process; the backend does not clear shared FFTW wisdom. Planning is destructive and precedes state initialization. -Wisdom outlives the process only when `SOLPROP_FFTW_WISDOM` names a file: the adapter -(`solprop_native._wisdom`) imports this build's file, `.native-`, before the -state plans, and afterwards merges the file into the process's wisdom and replaces it -whole when that adds a plan. The key digests FFTW's wisdom preamble (its version and -planner signature, which FFTW checks again on import) and the processor model. Readers -take no lock, since the file only ever changes by an atomic rename; writers serialise on -`.lock` (`flock` on POSIX). The C++ side only converts wisdom to and from text -(`export_fftw_wisdom`, `import_fftw_wisdom` in `fft_plan.hpp`), registering FFTW's -threaded solvers first, since they are part of the planner signature an import must -match. Metadata records `wisdom_loaded` and `wisdom_digest`. +The plans runs measure outlive the process, by default (ADR-076). The adapter +(`solprop_native._wisdom`) keeps them by plan key (`fft_plan_key`: kind, sizes, +strides, placement, alignment and threads), each with the wisdom of that transform +alone, in `wisdom.json` in the calibration store's directory of the machine and build +(`/machines//`, ADR-074's fingerprint), or as +`.native-` beside the file `SOLPROP_FFTW_WISDOM` names; +`SOLPROP_CALIBRATION=off` keeps no default file, and the process keeps what it measured +in memory for its later workspaces either way. A run's own planning (the policy's, or +the profile's) is offered the stored plans (`FftPlanning::stored`) and asked to keep +what it measures (`keep`). In `begin_seed` a key the profile holds is planned from the +profile's wisdom, as before; any other seeded above estimate plans *alone*: the +workspace's accumulated wisdom is exported and forgotten, only that key's known wisdom +imported (stored, or measured earlier in this workspace, so a key seeded twice is +measured once; a key to measure starts from what the workspace measured so far, whose +sub-plans it shares), and after the seed (`release_seed`) the transform's own wisdom is exported +into `measured` when the seed added an entry the stored text lacked, or the key is +listed in `reused`; the workspace's wisdom is then imported back over the transform's, +so that a profile's entries win where both hold one. A key left at estimate that the +store holds is not seeded: its wisdom is imported beneath the workspace's for +Kokkos-FFT's estimate plan to take, and it counts as reused. Seeds and their Kokkos-FFT +plans interleave (a workspace seeds several transforms before planning them), so the +wisdom accumulates within a workspace rather than being forgotten between keys; SEP 46's +transaction still forgets it before and after a profiled workspace. After construction +the adapter stores the measured plans not held by the profile, so a profile's winners +are never overridden. Readers take no lock, since the file only ever changes by an +atomic rename; writers serialise on `.lock` (`flock` on POSIX), merge the file's +plans with their own and replace it only when that adds or changes one. A calibration's +own workspaces (given `fft_planning`) and its children (`off`) neither read nor write +the store. The C++ side only converts wisdom to and from text (`export_fftw_wisdom`, +`import_fftw_wisdom` in `fft_plan.hpp`), registering FFTW's threaded solvers first, +since they are part of the planner signature an import must match. Metadata records +`wisdom_loaded` (the file held plans) and `wisdom_digest` (of the plans the workspace +built from wisdom or measured). Contiguous Raman transforms use the same planning policy, including long doubled rows and partial chunks. On macOS, NEON FFTW handles serial and long Raman rows; short parallel power-of-two rows (at most 16384 samples) retain Accelerate. Selection diff --git a/docs/dev/cpu-performance.md b/docs/dev/cpu-performance.md index 165beb04..64496ed7 100644 --- a/docs/dev/cpu-performance.md +++ b/docs/dev/cpu-performance.md @@ -188,7 +188,8 @@ measured while it existed. happens before the first plan is made; the plasma streams' plans were made earlier and stayed at estimate. -- **Measured plans kept across processes** (`SOLPROP_FFTW_WISDOM`, opt-in; +- **Measured plans kept across processes** (by default since ADR-076, opt-in through + `SOLPROP_FFTW_WISDOM` before; [measurements](benchmarks.md#persistent-fftw-wisdom)). A scan of one-thread jobs measured the same plans in every job: 0.5–4 s of construction against windows of 0.1–8 s on a Xeon Gold 6240. With the wisdom file in place a job constructs its backend in diff --git a/docs/dev/decisions.md b/docs/dev/decisions.md index b6d11dfc..8e1c0a6f 100644 --- a/docs/dev/decisions.md +++ b/docs/dev/decisions.md @@ -2165,3 +2165,64 @@ Withdrawn (development-process record, not a design decision). reach a run through a calibration's stored wisdom, not the default. Stored profiles keyed on the old widths miss and replan (each plan key holds its threads); the calibration's version is bumped with this change. + +## ADR-076 — Runs keep the FFTW plans they measure, by transform, by default + +- **Status:** proposed with PR #602 (issue #136), for the maintainer. +- **Context:** measured plans for threaded rows win in the step (0.94–0.97, ADR-075), but + planning them costs up to 12 s of a run's construction (`ctc` on 16 cores of an AMD EPYC + 7763: 13.8 s against 1.6 s), so the policy left them at estimate. Wisdom outlived the + process only when `SOLPROP_FFTW_WISDOM` named a file, for three reasons: solprop wrote + nothing to user directories, which ADR-074's store ended; a bad stored plan could slow + every later run in silence (SEP 46 §4), which is why a profiled workspace forgets all + wisdom; and #136's robustness bugs. The opt-in file was also one blob of wisdom, which a + profiled workspace forgets, so it helped only runs without a profile. Wisdom planned + alone, outside the run, once ran `h2_raman_averaged` 1.17 times slower on an AMD EPYC + 9B45; a plan measured by the run itself, in its own workspace, is the one the policy + already trusts. +- **Decision:** + - *What is kept.* Each plan key (`fft_plan_key`: kind, sizes, strides, placement, + alignment and threads, as a calibration keys its winners) holds the wisdom of that + transform alone, measured in place by the first run that planned it: the seed plans + with the workspace's other wisdom set aside, from the key's stored wisdom only (one to + measure from what the workspace itself measured so far, whose sub-plans it shares, so + that a cold run measures as much as before), and its own wisdom is exported after it (`FftPlanning::stored`, `keep`, `measured`, + `reused`). A stored plan applies at any effort, as FFTW applies wisdom: a transform + left at estimate takes it for its estimate plan, unseeded. + - *Where.* `wisdom.json` in the calibration store's directory of the machine and build + (ADR-074's fingerprint: machine, operating system, build digest, calibration version), + so machines and builds never share plans, and Apple silicon models are told apart. + `SOLPROP_FFTW_WISDOM` moves it, beside the named file as + `.native-`, and no longer turns off a profile's FFT planning; + `SOLPROP_CALIBRATION=off` keeps no default file. `solprop calibrate --clear` removes + both. A process also keeps what it measured in memory for its later workspaces. + - *Precedence.* A key the profile holds is planned from the profile's wisdom, never + from the store, and never written to it. SEP 46's transaction stays: a profiled + workspace forgets wisdom before and after it plans, and imports the store's wisdom key + by key, beneath the workspace's, so no unrelated wisdom reaches a profiled plan. This + amends SEP 46 §6.7's "runs no longer merge wisdom into the store when a profile + applies": they merge the plans the profile does not hold. + - *Writes and robustness.* Only a run's own planning (the policy's or the profile's) + reads and writes the store; a calibration's workspaces and children do neither. The + file is replaced whole by an atomic rename, under a `flock` writer lock, merging what + others stored. A name that cannot be used (an empty file name, an unknown user's + home, no home at all) and a file that cannot be read or written log a warning and the + run plans without stored plans (#136). +- **Consequences:** under `SOLPROP_FFTW_PLAN=measure` on 16 cores (one NUMA domain) of an + AMD EPYC 7763, five interleaved rounds against PR #598, a cold run plans as before + (`ctc` 13.7 s, `h2_raman_averaged` 5.1 s, `grin_nonlinear` 3.6 s, `rdw` 1.8 s, both + builds) and a warm one builds its backend in 0.18, 0.13, 0.36 and 0.16 s; on an Intel + Xeon Gold 6240 at 18 and 36 threads `ctc` took 23 s cold and 0.12 s warm. Windows with + the stored plans ran as those with plans measured in place: `h2_raman_averaged` 0.99–1.02 + across four comparisons on the EPYC (same build and against #598, two independent + stored measurements), no slower than plans measured in place; `ctc` 1.003–1.017, its + range spanning 1 with a second stored measurement, so the plan a single measurement + picks; `rdw` 1.00; the Xeon's geometric mean 1.015, every range spanning 1. Fields agree + to rounding. A first attempt that planned each transform from nothing measured a key + seeded twice twice and lost shared sub-plans (`rdw` 6.7 s cold on the Xeon against 1.4 s); + a transform to measure now starts from the workspace's own measurements. The store + holds about 20 kB a case at one width (80 kB for four cases on the EPYC) and grows with + the shapes a user runs; nothing evicts a plan, and `--clear` empties it. Plans kept by + the earlier opt-in file are not read. Measured threaded rows stay off by default: a + plan measured once now costs nothing after, which leaves the policy's threaded-rows + effort for the maintainer. diff --git a/docs/dev/tooling.md b/docs/dev/tooling.md index 683829aa..d4840acc 100644 --- a/docs/dev/tooling.md +++ b/docs/dev/tooling.md @@ -176,7 +176,8 @@ build works offline and survives a flaky mirror; refresh the copies occasionally `SOLPROP_OVERSUBSCRIBE`, which lets an explicit count exceed the CPUs (ADR-052). - The native backend's plans and neighbours: `SOLPROP_FFTW_PLAN` (`estimate` in CI and tests, `measure` by default, read by `native/include/solprop_native/fft_plan.hpp`), - `SOLPROP_FFTW_WISDOM` (a file name, beside which the native backend keeps its wisdom) + `SOLPROP_FFTW_WISDOM` (a file name, beside which the native backend keeps the plans its + runs measure instead of the calibration store, ADR-076) and `SOLPROP_BLAS_THREADS` (the width of the other BLAS libraries while a native backend lives). - In the test suite: `SOLPROP_TEST_BACKEND` (the backend the driver tests select; by diff --git a/docs/guide/performance/calibration.md b/docs/guide/performance/calibration.md index 108e2bd1..9600dafa 100644 --- a/docs/guide/performance/calibration.md +++ b/docs/guide/performance/calibration.md @@ -50,7 +50,9 @@ never runs your own simulation; for that, see plans made as a run without a profile makes them. A more exhaustive plan is not always a faster one, and a plan chosen alone not always the best in the step. Only a winning effort's wisdom is kept, so later runs build those plans at once instead - of measuring again; without one, runs plan as they would without a profile. + of measuring again; without one, runs plan as they would without a profile. The + calibration's problems are not yours: the plans of your own transforms are measured + by the first run that needs them and kept (below). A candidate replaces the built-in value only if it **wins in every round** of interleaved timings by more than the noise measured by timing the built-in setting @@ -93,6 +95,14 @@ A job takes its own allocation's row. Without one, it takes the workspace settin the row with the same layout and the nearest smaller core count, and otherwise the built-in table. +The same directory keeps the **FFT plans runs measure**, in `wisdom.json` beside the +machine and build's profiles. The first run of a transform shape measures its plan in +place, inside its own workspace, and stores it; every later run of that shape, in any +process, builds the plan from it at once. A plan the profile holds keeps precedence: +runs never read or overwrite one of the profile's winners from this file. The file is +kept with or without a profile, and goes with the profile when you clear it +([Performance tuning](tuning.md#the-native-backends-pools) says more). + ## The first run With no profile for the machine, the build and the job's allocation, the first native @@ -116,7 +126,7 @@ operation grain at once and the new waiting and team from its next session. |---|---| | `auto` (default) | use a profile; calibrate quickly on first use when there is none | | `use` | use a profile; never measure implicitly | -| `off` | the built-in table and no stored FFT wisdom, whatever the store holds; for bisecting and for holding settings fixed while code changes | +| `off` | the built-in table and no stored FFT plans, whatever the store holds (a file `SOLPROP_FFTW_WISDOM` names is still kept); for bisecting and for holding settings fixed while code changes | The notice is a Python warning of its own category, shown once; silence it with the standard warnings filters (`warnings.filterwarnings("ignore", @@ -171,7 +181,9 @@ built-in table for them. Each candidate's score is its median time relative to t built-in setting. Every run records the settings it actually applied in its metadata (`calibration` in the backend's settings), so a result says what produced it. -`solprop calibrate --clear` forgets this machine and build's profiles. +`solprop calibrate --clear` forgets this machine and build's profiles, their +refinements and the FFT plans runs stored, and the plans kept beside a file +`SOLPROP_FFTW_WISDOM` names. ## Refining one simulation @@ -196,8 +208,9 @@ stays the same. Explicit settings keep their precedence over any profile: `SOLPROP_THREADS`, `SOLPROP_NATIVE_THREADS` and the pool variables, `OMP_WAIT_POLICY`, `GOMP_SPINCOUNT` and -`KMP_BLOCKTIME`, `SOLPROP_FFTW_PLAN`, `SOLPROP_FFTW_WISDOM` and -`SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES` ([Performance tuning](tuning.md)). A setting +`KMP_BLOCKTIME`, `SOLPROP_FFTW_PLAN` and `SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES` +([Performance tuning](tuning.md)). `SOLPROP_FFTW_WISDOM` only moves the plans runs +store; the profile's own plans still apply. A setting measured under conditions an override changes is not used with it: operation thresholds fitted under spinning threads give way to the built-in table when you make the threads sleep. diff --git a/docs/guide/performance/tuning.md b/docs/guide/performance/tuning.md index 20e0d0c6..df86d6da 100644 --- a/docs/guide/performance/tuning.md +++ b/docs/guide/performance/tuning.md @@ -128,7 +128,7 @@ These settings apply whatever the pool: | `SOLPROP_OVERSUBSCRIBE` | `1` lets an explicit count exceed the CPUs and the quota | `0` | | `SOLPROP_BLAS_THREADS` | the width of every BLAS other than the native backend's own while a native backend lives ([below](#blas-and-vendor-libraries)) | 1 | | `SOLPROP_FFTW_PLAN` | the native backend's FFTW planner effort: `estimate`, `measure` or `patient`; any other value is refused | `measure`, except threaded row transforms, which stay at `estimate` | -| `SOLPROP_FFTW_WISDOM` | a file name, beside which the native backend keeps its FFTW wisdom ([below](#the-native-backends-pools)) | none | +| `SOLPROP_FFTW_WISDOM` | a file name, beside which the native backend keeps the FFTW plans its runs measure, instead of the calibration store ([below](#the-native-backends-pools)) | `wisdom.json` in the calibration store | **Free-space node groups.** A free-space, line or planar evaluation works through the plane's columns in node chunks, each paying a fixed cost for its launches, its temporal @@ -145,11 +145,10 @@ reports ([profiling](#profiling-the-native-backend)). **FFTW planning.** `measure` times candidate algorithms once per transform shape and keeps the fastest; that pays off on long runs, but the timing is only meaningful on an otherwise idle machine, and planning a large multimode problem can take seconds. Use -`SOLPROP_FFTW_PLAN=estimate` for short runs, tests and busy shared machines, and -`SOLPROP_FFTW_WISDOM=~/.cache/solprop/wisdom` to plan once and reuse the plans across -runs of the same shapes (the wisdom's digest is recorded in each run, since plans chosen -from different wisdom are different executions). The native backend keeps those plans -in a file of its own (below). +`SOLPROP_FFTW_PLAN=estimate` for short runs, tests and busy shared machines. The native +backend keeps the plans its runs measure, so each transform shape is measured once per +machine and build, not once per run (below; the plans' digest is recorded in each run, +since plans chosen from different wisdom are different executions). ## The native backend's pools @@ -243,42 +242,50 @@ holds them to its rounding bounds, not to identical bits Contiguous modal and Raman FFTW plans default to measured planning at one thread and estimate planning at wider widths; the explicit effort overrides both, including long Raman rows and partial chunks. Measured planning can add substantial construction -time even when the warmed propagation is faster. When construction dominates a short -run, keep the plans in a wisdom file (below) rather than give up measuring them; -`SOLPROP_FFTW_PLAN=estimate` skips planning altogether, but its plans ran one-thread +time even when the warmed propagation is faster. The native backend keeps the plans it +measures (below), so a shape pays that once per machine and build rather than on every +run; `SOLPROP_FFTW_PLAN=estimate` skips planning altogether, but its plans ran one-thread windows up to 1.6 times slower on an AMD EPYC 7763. On macOS with NEON FFTW, serial and long Raman rows use Kokkos-FFT, while parallel power-of-two rows of at most 16,384 samples retain vDSP. The [build guide](building-native.md) describes the coherent dependency recipe and the fallback for a library without NEON codelets. -**Reusing the native backend's plans.** `SOLPROP_FFTW_WISDOM` -keeps the native backend's measured plans: a backend imports them before it -plans and adds the plans it measured after. Nothing is read or written unless the -variable names a file. The native backend links its own FFTW, so its plans go to a file -of their own beside the named one, not to the named file itself: `.native-`, whose key digests the FFTW -library's version and configuration and the processor model: machines of different -kinds that share a home directory keep separate plans, and FFTW refuses wisdom another -build of it wrote. The processes of a scan can share one file. Readers take no lock, -since the file is only ever replaced whole; a writer takes `.lock`, merges what -others stored, and writes only when that adds a plan. A file that cannot be read or -written is reported in the log and costs only the measurement. Wisdom chooses among -FFTW's exact algorithms, so it changes no result beyond the rounding a fresh -measurement can change; the run's metadata records `wisdom_loaded` and the -`wisdom_digest` of the plans in force. For a scan of one-thread jobs it removes most of -each job's construction: on a Xeon Gold 6240, constructing the averaged helium -capillary took 1.40 s cold and 0.03 s with the wisdom in place, beside a 0.27 s window -(1.78 s and 0.05 s on an AMD EPYC 7763), and the comb-to-continuum capillary 5.0 s and -0.06 s ([measurements](../../dev/benchmarks.md#persistent-fftw-wisdom)). For example: - -```sh -export SOLPROP_FFTW_WISDOM=~/.cache/solprop/wisdom -``` - -The first job of each shape measures and stores its plans; every later one, in any -process, finds them. Threaded row transforms are planned at estimate unless -`SOLPROP_FFTW_PLAN` asks otherwise, so they have no measured plans to keep; but plans -that a run under an explicit `measure` stored for a width are found by later runs at -that width, whatever their effort. +**Reusing the native backend's plans.** The native backend keeps the FFTW plans its +runs measure, by default: the first run of a transform shape measures its plan and +stores it, and every later run of that shape, in any process, builds the plan from it +without measuring. The plans are kept by transform (its kind, sizes, strides, +placement, alignment and threads), each with the wisdom of that transform alone, +measured in place by the run that first planned it, inside its own workspace. They +live in `wisdom.json` in the [calibration store](calibration.md#where-the-results-live)'s +directory of this machine and build: the processor and its layout, the operating +system, the native build and the calibration's version, so different machines and +builds, such as an Apple M1 and an M5 Max sharing a home directory, never share plans. +`SOLPROP_FFTW_WISDOM` names another file, beside which the plans are kept as +`.native-`; `SOLPROP_CALIBRATION=off` keeps no default file, and +`solprop calibrate --clear` removes both. A [calibration profile](calibration.md)'s own +plans take precedence: a transform the profile holds is planned from the profile's +wisdom, and runs never store a plan for it. + +A stored plan applies at any effort: a transform the plan policy leaves at estimate, +such as a threaded row transform, takes the plan a run under `SOLPROP_FFTW_PLAN=measure` +stored for it. A plan stored at `measure` does not satisfy `patient`, which measures +again and stores the new plan. Without a file, a process still reuses the plans it +measured itself for its later workspaces. + +The processes of a scan can share one file. Readers take no lock, since the file is +only ever replaced whole; a writer takes `.lock`, merges what others stored, and +writes only when that adds a plan. A name that cannot be used, or a file that cannot be +read or written, is reported in the log and costs only the measurement. Wisdom chooses +among FFTW's exact algorithms, so it changes no result beyond the rounding a fresh +measurement can change; the run's metadata records `wisdom_loaded` (whether the file +held plans) and the `wisdom_digest` of the plans the workspace built from wisdom or +measured. For a scan of one-thread jobs it removes most of each job's construction: +on a Xeon Gold 6240, constructing the averaged helium capillary took 1.40 s cold and +0.03 s with the plans in place, beside a 0.27 s window (1.78 s and 0.05 s on an AMD +EPYC 7763), and the comb-to-continuum capillary 5.0 s and 0.06 s +([measurements](../../dev/benchmarks.md#persistent-fftw-wisdom)). Under +`SOLPROP_FFTW_PLAN=measure` on 16 cores of an AMD EPYC 7763, the comb-to-continuum +capillary's construction fell from 13.7 s to 0.18 s, its window unchanged. `OMP_NUM_THREADS` and `KOKKOS_NUM_THREADS` do **not** size the Kokkos pool — the backend initialises Kokkos with an explicit thread count, which takes precedence; set diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index 98d7c3fc..cf959a7a 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -296,7 +296,18 @@ void require_cpu_execution(const CpuExecution& execution); A transform's group is `rows` (one thread), `threaded_rows` or `planes`; its key names its kind, sizes, strides, placement, alignment and threads - (`fft_plan_key`). Efforts are "estimate", "measure" or "patient". */ + (`fft_plan_key`). Efforts are "estimate", "measure" or "patient". + + Plans runs measured before (solprop ADR-076), in the legacy and profile modes: + `stored` holds each transform's own wisdom by key, below the profile's `wisdom`, + which is consulted first. A transform seeded above estimate that `stored` holds, or + any when `keep` is set, is planned alone: the workspace's wisdom is set aside, the + transform's stored wisdom alone imported (or, for one to measure, what the + workspace measured so far, whose sub-plans it may share), and after the seed the + workspace's wisdom comes back over the transform's. A transform seeded twice is + measured once. A plan the stored wisdom built is listed in + `reused`, as is one left at estimate whose stored wisdom was imported for its + estimate plan; one that measured is kept in `measured` when `keep` is set. */ struct FftPlanning { enum class Mode { legacy, profile, capture }; Mode mode{Mode::legacy}; @@ -304,10 +315,16 @@ struct FftPlanning { std::map wisdom; std::string capture_effort{"measure"}; double time_limit{-1.0}; + std::map stored; + bool keep{false}; // What planning found: each transform's group, the wisdom captured, and the - // profile's plans that no longer build. - std::map groups, captured; - std::vector missing; + // profile's plans that no longer build; the stored plans reused and the transforms + // measured, each with its own wisdom. + std::map groups, captured, measured; + std::vector missing, reused; + // While one transform plans alone: the workspace's wisdom, set aside. + bool alone{false}; + std::string held; }; /** Choose a width without changing the runtime or a vendor library's pool. */ diff --git a/native/include/solprop_native/fft_plan.hpp b/native/include/solprop_native/fft_plan.hpp index 263e5a10..6d173d3f 100644 --- a/native/include/solprop_native/fft_plan.hpp +++ b/native/include/solprop_native/fft_plan.hpp @@ -70,6 +70,31 @@ inline unsigned fftw_effort_flag(const std::string_view effort) { std::string(effort) + "'"); } +// FFTW's process-wide wisdom as text, for the stored plans (solprop ADR-076) and a +// workspace's planning: the Python adapter owns the file, its key and its lock, and +// calls these with the GIL held, as planning does, so they never race the planner. +// Empty when the provider implements FFTW's API without its wisdom (MKL). Both first +// register the threaded solvers, as every seed does: they are part of the planner's +// configuration, whose signature heads exported wisdom and must match for an import. +inline std::string export_fftw_wisdom() { + fftw_init_threads(); + // FFTW allocates the text with plain malloc and documents free() for it: fftw_free + // pairs with fftw_malloc's aligned allocator, which on Windows is another heap. + const auto release = [](char* pointer) { std::free(pointer); }; + const std::unique_ptr text(fftw_export_wisdom_to_string(), + release); + return text ? std::string(text.get()) : std::string(); +} + +// Merge `text` into the process's wisdom. FFTW refuses, leaving its wisdom as it was, +// text that is not wisdom or that another FFTW version or configuration wrote (its +// preamble carries the planner's signature). Wisdom only chooses among FFTW's exact +// algorithms, so a plan taken from it computes the same transform. +inline bool import_fftw_wisdom(const std::string& text) { + fftw_init_threads(); + return fftw_import_wisdom_from_string(text.c_str()) != 0; +} + namespace detail { // The planning of the workspace this thread is building (`FftPlanningScope`). inline thread_local FftPlanning* planning = nullptr; @@ -105,58 +130,161 @@ class FftPlanningScope { bool forgets_; }; +// Whether `planned` holds a wisdom entry `stored` lacks: a seed that measured. FFTW +// writes one entry a line, under a preamble line that both share. +inline bool adds_wisdom(const std::string& planned, const std::string& stored) { + const auto lines = [](const std::string& text) { + std::vector found; + for (std::size_t first = 0; first < text.size();) { + const std::size_t last = std::min(text.find('\n', first), text.size()); + found.emplace_back(text.data() + first, last - first); + first = last + 1; + } + std::sort(found.begin(), found.end()); + return found; + }; + const auto had = lines(stored); + for (const auto line : lines(planned)) + if (!std::binary_search(had.begin(), had.end(), line)) + return true; + return false; +} + +// Merge `text` beneath the process's wisdom: where both hold an entry, the process's +// stays, so that a profile's plans keep theirs. +inline void import_beneath(const std::string& text) { + const std::string held = export_fftw_wisdom(); + fftw_forget_wisdom(); + static_cast(fftw_import_wisdom_from_string(text.c_str())); + if (!held.empty()) + static_cast(fftw_import_wisdom_from_string(held.c_str())); +} + +// The wisdom already known for a transform: measured earlier in this workspace, or +// stored by an earlier run; none when it must be measured. +inline const std::string* known_wisdom(const FftPlanning& planning, + const std::string& key) { + if (const auto found = planning.measured.find(key); found != planning.measured.end()) + return &found->second; + if (const auto found = planning.stored.find(key); found != planning.stored.end()) + return &found->second; + return nullptr; +} + +// Begin planning a transform alone (`FftPlanning::stored`): set the workspace's wisdom +// aside and import only the transform's own known wisdom; a transform to be measured +// starts from what this workspace measured so far, so that it shares their sub-plans +// as a workspace planning in one wisdom does, and from nothing else. +inline void plan_alone(FftPlanning& planning, const std::string& key) { + planning.held = export_fftw_wisdom(); + fftw_forget_wisdom(); + if (const std::string* known = known_wisdom(planning, key)) + static_cast(fftw_import_wisdom_from_string(known->c_str())); + else + for (const auto& entry : planning.measured) + static_cast(fftw_import_wisdom_from_string(entry.second.c_str())); + planning.alone = true; +} + +// End a transform planned alone: keep its own wisdom if it measured, note a reuse of a +// stored plan if not, then give the workspace back its wisdom, imported over the +// transform's so that a profile's plans keep their entries. Every seed begun alone +// ends here, planned or not. +inline void release_seed(const std::string& key, const bool planned) { + FftPlanning* planning = detail::planning; + if (planning == nullptr || !planning->alone) + return; + planning->alone = false; + std::string own; + if (planned) { + own = export_fftw_wisdom(); + const std::string* known = known_wisdom(*planning, key); + if (known != nullptr && !adds_wisdom(own, *known)) { + if (!planning->measured.contains(key)) + planning->reused.push_back(key); + } else if (planning->keep) { + planning->measured[key] = own; + } + } + fftw_forget_wisdom(); + if (!own.empty()) + static_cast(fftw_import_wisdom_from_string(own.c_str())); + if (!planning->held.empty()) + static_cast(fftw_import_wisdom_from_string(planning->held.c_str())); + planning->held.clear(); +} + // The flags a transform of `key` and `shape` on `threads` threads is seeded with, or // none to leave it to Kokkos-FFT's estimate plan: the policy, or the workspace's // planning (`FftPlanning`), which this records the transform in. Capture forgets the // wisdom before the seed and sets the planner's time limit; profile imports the -// transform's own wisdom. +// transform's own wisdom. A seed above estimate that the stored plans hold, or any +// that are kept, plans alone (`plan_alone`); a transform left at estimate that they +// hold is not seeded, its stored plan imported for Kokkos-FFT's estimate plan to take, +// as FFTW takes wisdom of any effort for an estimate plan. inline std::optional begin_seed(const std::string& key, const FftShape shape, const int threads) { FftPlanning* planning = detail::planning; - if (planning == nullptr || planning->mode == FftPlanning::Mode::legacy) { + if (planning == nullptr) { const unsigned effort = fftw_seed_effort(shape, threads); if (effort == FFTW_ESTIMATE) return std::nullopt; return effort; } - const char* group = shape == FftShape::planes ? "planes" - : threads > 1 ? "threaded_rows" - : "rows"; - planning->groups[key] = group; - if (planning->mode == FftPlanning::Mode::capture) { - const unsigned effort = fftw_effort_flag(planning->capture_effort); - if (effort == FFTW_ESTIMATE) - return std::nullopt; - fftw_forget_wisdom(); - fftw_set_timelimit(planning->time_limit > 0.0 ? planning->time_limit - : FFTW_NO_TIMELIMIT); - return effort; - } - const std::string& chosen = shape == FftShape::planes ? planning->planes - : threads > 1 ? planning->threaded_rows - : planning->rows; - const unsigned effort = fftw_effort_flag(chosen); - if (const auto found = planning->wisdom.find(key); found != planning->wisdom.end()) { - if (fftw_import_wisdom_from_string(found->second.c_str()) == 0) { - planning->missing.push_back(key); - return std::nullopt; + unsigned effort = FFTW_ESTIMATE; + if (planning->mode == FftPlanning::Mode::legacy) { + effort = fftw_seed_effort(shape, threads); + } else { + const char* group = shape == FftShape::planes ? "planes" + : threads > 1 ? "threaded_rows" + : "rows"; + planning->groups[key] = group; + if (planning->mode == FftPlanning::Mode::capture) { + const unsigned captured = fftw_effort_flag(planning->capture_effort); + if (captured == FFTW_ESTIMATE) + return std::nullopt; + fftw_forget_wisdom(); + fftw_set_timelimit(planning->time_limit > 0.0 ? planning->time_limit + : FFTW_NO_TIMELIMIT); + return captured; + } + const std::string& chosen = shape == FftShape::planes ? planning->planes + : threads > 1 ? planning->threaded_rows + : planning->rows; + effort = fftw_effort_flag(chosen); + if (const auto found = planning->wisdom.find(key); + found != planning->wisdom.end()) { + if (fftw_import_wisdom_from_string(found->second.c_str()) == 0) { + planning->missing.push_back(key); + return std::nullopt; + } + return effort | FFTW_WISDOM_ONLY; } - return effort | FFTW_WISDOM_ONLY; } - if (effort == FFTW_ESTIMATE) + const auto stored = planning->stored.find(key); + if (effort == FFTW_ESTIMATE) { + if (stored != planning->stored.end()) { + import_beneath(stored->second); + planning->reused.push_back(key); + } return std::nullopt; + } + if (planning->keep || known_wisdom(*planning, key) != nullptr) + plan_alone(*planning, key); return effort; } -// Finish a seed begun with `flags`: a capture keeps the transform's wisdom and lifts -// the time limit; a profile's plan that no longer builds from its wisdom is recorded, -// and left to the estimate plan. Any other failure to plan is an error. +// Finish a seed begun with `flags`: a seed planned alone gives the workspace its +// wisdom back (`release_seed`); a capture keeps the transform's wisdom and lifts the +// time limit; a profile's plan that no longer builds from its wisdom is recorded, and +// left to the estimate plan. Any other failure to plan is an error. inline void end_seed(const std::string& key, const unsigned flags, const bool planned) { FftPlanning* planning = detail::planning; const bool capture = planning != nullptr && planning->mode == FftPlanning::Mode::capture; if (capture) fftw_set_timelimit(FFTW_NO_TIMELIMIT); + release_seed(key, planned); if (!planned) { if ((flags & FFTW_WISDOM_ONLY) != 0U && planning != nullptr) { planning->missing.push_back(key); @@ -370,31 +498,6 @@ inline PlanAlone time_fft_plan(const std::string& key, const std::string& wisdom return result; } -// FFTW's process-wide wisdom as text, for solprop's opt-in SOLPROP_FFTW_WISDOM: the -// Python adapter owns the file, its key and its lock, and calls these with the GIL -// held, so they never race the planner. Empty when the provider implements FFTW's API -// without its wisdom (MKL). Both first register the threaded solvers, as every seed -// does: they are part of the planner's configuration, whose signature heads exported -// wisdom and must match for an import. -inline std::string export_fftw_wisdom() { - fftw_init_threads(); - // FFTW allocates the text with plain malloc and documents free() for it: fftw_free - // pairs with fftw_malloc's aligned allocator, which on Windows is another heap. - const auto release = [](char* pointer) { std::free(pointer); }; - const std::unique_ptr text(fftw_export_wisdom_to_string(), - release); - return text ? std::string(text.get()) : std::string(); -} - -// Merge `text` into the process's wisdom. FFTW refuses, leaving its wisdom as it was, -// text that is not wisdom or that another FFTW version or configuration wrote (its -// preamble carries the planner's signature). Wisdom only chooses among FFTW's exact -// algorithms, so a plan taken from it computes the same transform. -inline bool import_fftw_wisdom(const std::string& text) { - fftw_init_threads(); - return fftw_import_wisdom_from_string(text.c_str()) != 0; -} - } // namespace solprop_native #else namespace solprop_native { diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index e35fae94..b90b6c05 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -224,8 +224,9 @@ native::CpuExecution cpu_execution_from(const nb::dict& values) { } // solprop SEP 46 phase 3: a workspace's FFT planning, as solprop_native's backend -// writes it: its mode, each group's effort, the profile's wisdom by plan key, and a -// capture's effort and time limit; absent, the plan policy (`legacy`). +// writes it: its mode, each group's effort, the profile's wisdom by plan key, a +// capture's effort and time limit, and the plans runs measured before with whether to +// keep new ones (ADR-076); absent, the plan policy (`legacy`). [[maybe_unused]] native::FftPlanning fft_planning_from(const nb::dict& values) { native::FftPlanning result; try { @@ -271,6 +272,11 @@ native::CpuExecution cpu_execution_from(const nb::dict& values) { } if (values.contains("time_limit")) result.time_limit = nb::cast(values["time_limit"]); + if (values.contains("stored")) + for (auto [key, text] : nb::cast(values["stored"])) + result.stored[nb::cast(key)] = nb::cast(text); + if (values.contains("keep")) + result.keep = nb::cast(values["keep"]); } catch (const nb::cast_error&) { throw std::invalid_argument("an FFT planning record has the wrong types"); } @@ -288,9 +294,17 @@ nb::dict fft_planning_dict(const native::FftPlanning& planning) { nb::list missing; for (const auto& key : planning.missing) missing.append(key); + nb::dict measured; + for (const auto& [key, text] : planning.measured) + measured[key.c_str()] = text; + nb::list reused; + for (const auto& key : planning.reused) + reused.append(key); result["groups"] = groups; result["captured"] = captured; result["missing"] = missing; + result["measured"] = measured; + result["reused"] = reused; return result; } @@ -2766,8 +2780,8 @@ NB_MODULE(_core, module_) { module_.def("scoped_block_time", &native::scoped_block_time); module_.def("shutdown_runtime", &shutdown_kokkos); module_.def("build_record", &build_record); - // FFTW's wisdom as text for solprop's SOLPROP_FFTW_WISDOM, whose file the adapter - // owns (solprop_native._wisdom). None where there is none to keep: a device build, + // FFTW's wisdom as text, for the plan store the adapter owns + // (solprop_native._wisdom). None where there is none to keep: a device build, // which plans with the device FFT, or a provider without FFTW's wisdom. module_.def("export_fftw_wisdom", []() -> nb::object { #if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) diff --git a/native/src/general.cpp b/native/src/general.cpp index b320a795..56b8abec 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -618,8 +618,10 @@ inline void seed_fftw_wisdom(const ExecutionSpace& space, Complex* input, // The policy has always tolerated a plane it cannot seed; a profile records it. if (seeded == nullptr && (*chosen & FFTW_WISDOM_ONLY) == 0U && (detail::planning == nullptr || - detail::planning->mode == FftPlanning::Mode::legacy)) + detail::planning->mode == FftPlanning::Mode::legacy)) { + release_seed(key, false); return; + } end_seed(key, *chosen, seeded != nullptr); if (seeded != nullptr) fftw_destroy_plan(seeded); diff --git a/native/src/solprop_native/_profile.py b/native/src/solprop_native/_profile.py index 7c8085c1..d26d7b5b 100644 --- a/native/src/solprop_native/_profile.py +++ b/native/src/solprop_native/_profile.py @@ -147,16 +147,14 @@ def planning( The row's plan efforts and the generation's wisdom (SEP 46 section 6.7), where the row's workspace settings hold (:meth:`workspace`); ``None`` keeps the plan policy: no profile, a row without planning, other process settings, or the - user's own ``SOLPROP_FFTW_PLAN`` or ``SOLPROP_FFTW_WISDOM``, which take - precedence. + user's own ``SOLPROP_FFTW_PLAN``, which takes precedence. + ``SOLPROP_FFTW_WISDOM`` only moves the plans runs measured (solprop ADR-076), + which the profile's winners take precedence over. """ row = self.row if row is None or self.generation is None or not row.planning: return None - if any( - environ.get(name, "").strip() - for name in ("SOLPROP_FFTW_PLAN", "SOLPROP_FFTW_WISDOM") - ): + if environ.get("SOLPROP_FFTW_PLAN", "").strip(): return None fitted = row.fitted_under assert self.found is not None diff --git a/native/src/solprop_native/_store.py b/native/src/solprop_native/_store.py index 190d4a9a..8cb6a6f2 100644 --- a/native/src/solprop_native/_store.py +++ b/native/src/solprop_native/_store.py @@ -11,6 +11,7 @@ machines// current # names the published generation .json # profile rows, their evidence and their FFT wisdom + wisdom.json # the FFT plans runs measured (solprop ADR-076) claim- # held while one process calibrates that class attempts.json # inconclusive attempts, so that auto waits a day problems//.json # stage-2 refinements @@ -371,11 +372,13 @@ def publish(self, generation: Generation) -> Path: return path def clear(self, fingerprint: str | None = None) -> int: - """Remove ``current``, every generation and every refinement; return a count. + """Remove ``current``, every generation and refinement, and the stored plans. - Never a live claim. `fingerprint` limits it to one machine and build; ``None`` - clears them all. A stage-2 refinement goes with its machine's profile, since - one fitted over the built-in table would otherwise still apply. + Return how many files went. The stored plans are ``wisdom.json``, the FFT plans + runs measured (``_wisdom``, solprop ADR-076). Never a live claim. + `fingerprint` limits it to one machine and build; ``None`` clears them all. A + stage-2 refinement goes with its machine's profile, since one fitted over the + built-in table would otherwise still apply. """ removed = 0 directories: list[Path] = [] diff --git a/native/src/solprop_native/_wisdom.py b/native/src/solprop_native/_wisdom.py index d4818969..268ce7f1 100644 --- a/native/src/solprop_native/_wisdom.py +++ b/native/src/solprop_native/_wisdom.py @@ -2,29 +2,37 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""Keep the native backend's FFTW wisdom in the file ``SOLPROP_FFTW_WISDOM`` names. - -Measured planning is most of a short one-thread run's construction: on an AMD EPYC 7763 -a 6400-sample real transform alone takes about 1.3 s to plan, against a run window of -one or two seconds. FFTW's wisdom records the plans a measurement chose, and a later -process that imports it plans the same transforms at once. Wisdom only chooses among -FFTW's exact algorithms, so it never changes what a transform computes; which algorithm -was chosen can change the rounding, as a new measurement can. - -Nothing is read or written without the variable. The native backend links its own FFTW, -whose wisdom another FFTW build cannot read, so it keeps a file of its own beside the -named one, ``.native-``. The key digests the FFTW library's wisdom preamble -(its version and the signature of its planner's configuration) and the processor's -model, so machines of different kinds sharing a home directory keep separate plans, and -a file from another FFTW is never offered to this one; FFTW refuses foreign wisdom by -its signature in any case. +"""Keep the FFTW plans the native backend's runs measure (solprop ADR-076). + +Measured planning is most of a short run's construction: on an AMD EPYC 7763 a +6400-sample real transform alone takes about 1.3 s to plan, and the threaded rows of a +multimode stage up to 12 s, against a run window of a few seconds. FFTW's wisdom records +the plans a measurement chose, and a later process that imports it plans the same +transforms at once. Wisdom only chooses among FFTW's exact algorithms, so it never +changes what a transform computes; which algorithm was chosen can change the rounding, +as a new measurement can. + +The plans are kept by transform, not as one blob of wisdom: each plan key (the +transform's kind, sizes, strides, placement, alignment and threads, as a calibration +keys its winners) holds the wisdom of that transform alone, measured in place by the +run that first planned it. A workspace is offered the plans it may use; a transform the +store holds plans from its own wisdom only, and one it lacks is measured alone and +added (``FftPlanning`` in ``fft_plan.hpp``). A calibration profile's winners keep +precedence: a key the profile holds is never read from or written to this store. + +By default the file is ``wisdom.json`` in the calibration store's directory of this +machine and build (``/machines//``), so different machines and +builds never share plans and ``solprop calibrate --clear`` removes them with the +profile. ``SOLPROP_FFTW_WISDOM`` names another file, beside which this build's is kept +as ``.native-``; ``SOLPROP_CALIBRATION=off`` keeps no default file. +Every process also keeps the plans it measured in memory, for its later workspaces. Many processes of a scan may share one file. A reader never takes a lock: the file is only ever replaced whole, by an atomic rename. A writer takes an exclusive lock on -``.lock`` (on POSIX systems), merges the file's current wisdom into its own, and -replaces the file only when that adds something, so a run whose plans were all known -writes nothing. A file that cannot be read or written costs only the measurement: it is -reported in the log and the run goes on. +``.lock`` (on POSIX systems), merges the file's current plans with its own, and +replaces the file only when that adds a plan, so a run whose plans were all known +writes nothing. A file that cannot be named, read or written costs only the +measurement: it is reported in the log and the run goes on. """ from __future__ import annotations @@ -32,85 +40,104 @@ import contextlib import functools import hashlib +import json import logging import os -import platform import threading -from collections.abc import Generator +from collections.abc import Collection, Generator, Mapping from pathlib import Path +from typing import cast from . import _core +from ._store import cache_directory, mode try: import fcntl except ImportError: # pragma: no cover - Windows: the atomic replace alone applies fcntl = None -__all__ = ["digest", "load", "store", "wisdom_file"] +__all__ = ["FILE", "FORMAT", "digest", "plans", "remember", "store", "wisdom_file"] logger = logging.getLogger(__name__) ENVIRONMENT = "SOLPROP_FFTW_WISDOM" -"""The variable naming the wisdom file.""" +"""The variable naming another wisdom file.""" + +FILE = "wisdom.json" +"""The default file's name, in the store's directory of this machine and build.""" + +FORMAT = 1 +"""The file's format; a file of another format is not read.""" _LOCK = threading.Lock() -# Per wisdom file: the (mtime, size) of the version this process imported last, and the -# wisdom the process held right after it last loaded or stored that file. -_imported: dict[Path, tuple[int, int] | None] = {} -_known: dict[Path, str] = {} +# Per file: the (mtime, size) of the version this process read last, and its plans. +_read: dict[Path, tuple[tuple[int, int], dict[str, str]]] = {} +# The plans this process measured, for its later workspaces. +_measured: dict[str, str] = {} -def _processor(text: str | None = None) -> str: - """Name the processor model from `text`, by default Linux's ``/proc/cpuinfo``.""" - if text is None: - try: - text = Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace") - except OSError: # pragma: no cover - not Linux - return platform.processor() - wanted = ("model name", "cpu model", "CPU implementer", "CPU part") - fields = [ - line.split(":", 1)[1].strip() - for line in text.splitlines()[:64] - if ":" in line and line.split(":", 1)[0].strip() in wanted - ] - return " ".join(dict.fromkeys(fields)) or platform.processor() +@functools.cache +def _keeps_wisdom() -> bool: + """Whether the build keeps FFTW wisdom: not a device build or a provider without.""" + return _core.export_fftw_wisdom() is not None @functools.cache -def _key() -> str | None: - """Digest what decides whether stored plans suit this process, once. +def machine_key() -> str: + """Return this machine and build's fingerprint digest, the profile's (SEP 46 §5).""" + import solprop_native as package - ``None`` when the build keeps no FFTW wisdom. - """ - wisdom = _core.export_fftw_wisdom() - if wisdom is None: - return None - text = "\n".join((wisdom.split("\n", 1)[0], platform.machine(), _processor())) - return hashlib.blake2b(text.encode(), digest_size=6).hexdigest() + found = package.PROFILE.fingerprint + if found is not None: + return found.digest + # SOLPROP_CALIBRATION=off reads no profile, and so took no fingerprint. + from ._fingerprint import build_identity, fingerprint + from ._topology import read_topology + return fingerprint( + read_topology(), + build_identity( + [Path(entry) for entry in package.__path__], + package._STEM, # pyright: ignore[reportPrivateUsage] - same package + ), + ).digest -def wisdom_file(named: str | None = None) -> Path | None: - """Return this build's wisdom file for the named one, or ``None`` without one. + +def wisdom_file(environ: Mapping[str, str] = os.environ) -> Path | None: + """Return this machine and build's plan file, or ``None`` without one. Parameters ---------- - named : str, optional - The file ``SOLPROP_FFTW_WISDOM`` names; ``None`` reads the variable. + environ : Mapping[str, str] + The environment. Returns ------- pathlib.Path or None - ``.native-`` beside the named file; ``None`` when nothing is named or - the build keeps no FFTW wisdom (a device build, or a provider without it). + ``.native-`` beside the file ``SOLPROP_FFTW_WISDOM`` names; + otherwise ``wisdom.json`` in the calibration store's directory of this machine + and build. ``None`` under ``SOLPROP_CALIBRATION=off`` without the variable, on + a build that keeps no FFTW wisdom (a device build, or a provider without it), + and, with a logged warning, when the name cannot be used. """ - named = os.environ.get(ENVIRONMENT) if named is None else named - if not named: - return None - key = _key() - if key is None: + named = environ.get(ENVIRONMENT, "").strip() + try: + if not named and mode(environ) == "off": + return None + if not _keeps_wisdom(): + return None + key = machine_key() + if named: + base = Path(named).expanduser() + if not base.name or base.name in {".", ".."}: + raise ValueError(f"{named!r} names no file") + return base.with_name(f"{base.name}.native-{key}") + return cache_directory(environ) / "machines" / key / FILE + except (OSError, RuntimeError, ValueError) as error: + logger.warning( + "solprop_native: cannot keep FFTW plans (%s); planning without them", error + ) return None - base = Path(named).expanduser() - return base.with_name(f"{base.name}.native-{key}") def _version(path: Path) -> tuple[int, int] | None: @@ -122,45 +149,76 @@ def _version(path: Path) -> tuple[int, int] | None: return stat.st_mtime_ns, stat.st_size -def _import(path: Path, text: str) -> bool: - """Merge `text` into FFTW's wisdom; report and refuse what FFTW refuses.""" - if _core.import_fftw_wisdom(text): - return True - logger.warning("solprop_native: %s is not this FFTW's wisdom; ignoring it", path) - return False - - -def load(path: Path | None) -> bool: - """Import the wisdom file before planning, unless this process already holds it. +def _parse(path: Path, text: str) -> dict[str, str]: + """Return the plans of a file's `text`; report and refuse anything else.""" + try: + data = json.loads(text) + if not isinstance(data, dict): + raise ValueError("not a mapping") + values = cast(dict[str, object], data) + if values.get("format") != FORMAT: + raise ValueError(f"format {values.get('format')!r}, expected {FORMAT}") + found = values.get("plans") + if not isinstance(found, dict) or not all( + isinstance(key, str) and isinstance(plan, str) + for key, plan in cast(dict[object, object], found).items() + ): + raise ValueError("its plans are not text by key") + except ValueError as error: + logger.warning("solprop_native: ignoring the FFTW plans in %s: %s", path, error) + return {} + return dict(cast(dict[str, str], found)) + + +def _load(path: Path) -> dict[str, str]: + """Return the file's plans, read again only when it changed; hold `_LOCK`.""" + try: + version = _version(path) + if version is None: + return {} + cached = _read.get(path) + if cached is not None and cached[0] == version: + return cached[1] + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + logger.warning("solprop_native: cannot read FFTW plans: %s", error) + return {} + found = _parse(path, text) + _read[path] = (version, found) + return found + + +def plans(path: Path | None) -> tuple[dict[str, str], bool]: + """Return the plans a workspace is offered, and whether the file supplied any. Parameters ---------- path : pathlib.Path or None - This build's wisdom file (:func:`wisdom_file`); ``None`` does nothing. + This build's plan file (:func:`wisdom_file`); ``None`` offers this process's + own plans alone. Returns ------- + dict + Each plan's wisdom by key: the file's, under those this process measured. bool - Whether the file's wisdom is in force. + Whether the file held any plan. """ - if path is None: - return False with _LOCK: - try: - version = _version(path) - if version is None: - return False - if _imported.get(path) == version: - return True - text = path.read_text(encoding="ascii") - except (OSError, UnicodeDecodeError) as error: - logger.warning("solprop_native: cannot read FFTW wisdom: %s", error) - return False - if not _import(path, text): - return False - _imported[path] = version - _known[path] = _core.export_fftw_wisdom() or "" - return True + found = _load(path) if path is not None else {} + return found | _measured, bool(found) + + +def remember(measured: Mapping[str, str]) -> None: + """Keep plans this process measured for its later workspaces. + + Parameters + ---------- + measured : Mapping[str, str] + Each plan's own wisdom by key. + """ + with _LOCK: + _measured.update(measured) @contextlib.contextmanager @@ -178,59 +236,72 @@ def _replace(path: Path, text: str) -> None: """Write `text` beside `path`, then rename it over `path` in one step.""" partial = path.parent / f".{path.name}.{os.getpid()}.{threading.get_ident()}" try: - partial.write_text(text, encoding="ascii") + partial.write_text(text, encoding="utf-8") partial.replace(path) finally: partial.unlink(missing_ok=True) -def store(path: Path | None) -> bool: - """Merge the plans this process measured into the wisdom file. +def store( + path: Path | None, measured: Mapping[str, str], profiled: Collection[str] = () +) -> bool: + """Merge plans a run measured into the file. Parameters ---------- path : pathlib.Path or None - This build's wisdom file (:func:`wisdom_file`); ``None`` does nothing. + This build's plan file (:func:`wisdom_file`); ``None`` does nothing. + measured : Mapping[str, str] + Each plan's own wisdom by key, as the workspace measured it. + profiled : Collection[str] + The keys a calibration profile holds, which are never stored here. Returns ------- bool Whether the file was replaced. """ - if path is None: + new = {key: plan for key, plan in measured.items() if key not in profiled} + if path is None or not new: return False with _LOCK: - wisdom = _core.export_fftw_wisdom() or "" - if wisdom == _known.get(path): - return False try: with _exclusive(path): - stored = path.read_text(encoding="ascii") if path.exists() else "" - if stored: - _import(path, stored) - merged = _core.export_fftw_wisdom() or "" - changed = merged != stored - if changed: - _replace(path, merged) version = _version(path) + current = ( + _parse(path, path.read_text(encoding="utf-8")) + if version is not None + else {} + ) + merged = current | new + changed = merged != current + if changed: + record = {"format": FORMAT, "plans": merged} + _replace(path, json.dumps(record, indent=0, sort_keys=True)) + version = _version(path) except (OSError, UnicodeDecodeError) as error: - logger.warning("solprop_native: cannot store FFTW wisdom: %s", error) + logger.warning("solprop_native: cannot store FFTW plans: %s", error) return False - _known[path] = merged - _imported[path] = version + if version is not None: + _read[path] = (version, merged) return changed -def digest() -> str | None: - """Return a short digest of the wisdom in force, for the run's metadata. +def digest(applied: Mapping[str, str]) -> str | None: + """Return a short digest of a workspace's plans, for its run's metadata. + + Parameters + ---------- + applied : Mapping[str, str] + The wisdom of each plan the workspace built from wisdom or measured, by key. Returns ------- str or None - The first sixteen hex characters of a BLAKE2b over FFTW's exported wisdom; - ``None`` on a build without FFTW wisdom. + The first sixteen hex characters of a BLAKE2b over the plans; ``None`` on a + build without FFTW wisdom. """ - wisdom = _core.export_fftw_wisdom() - if wisdom is None: + if not _keeps_wisdom(): return None - return hashlib.blake2b(wisdom.encode(), digest_size=8).hexdigest() + text = json.dumps(dict(applied), sort_keys=True) + return hashlib.blake2b(text.encode(), digest_size=8).hexdigest() diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 1048e4a6..832e3ef4 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -16,6 +16,7 @@ import warnings from collections.abc import Callable, Mapping, Sequence from importlib.metadata import version +from pathlib import Path from types import TracebackType from typing import Any, ClassVar, Literal, Protocol, cast @@ -363,10 +364,12 @@ def __init__( # solprop SEP 23: the far-field tables are formed once, which refuses a # geometry the far-field statistics do not admit before anything is built. self._far_field = FarFieldTables(problem) - # solprop's opt-in SOLPROP_FFTW_WISDOM: the plans a run of this build measured - # before are imported before planning, and new ones are kept after it. - wisdom = _wisdom.wisdom_file() - self._wisdom_loaded = _wisdom.load(wisdom) + # solprop ADR-076: the plans runs of this machine and build measured before + # are offered to the workspace, and those it measures are kept after it. + wisdom = _wisdom.wisdom_file() if fft_planning is None else None + stored, self._wisdom_loaded = ( + _wisdom.plans(wisdom) if fft_planning is None else ({}, False) + ) self._bound_buckets: np.ndarray | None = None # solprop SEP 46: the workspace's CPU execution record, the process's (the # calibration's where it holds, else the built-in table) unless one is given, @@ -378,7 +381,7 @@ def __init__( _native_problem(problem, self._terms, self._flow, self._z_tables) | _low_storage_payload(problem, device=_on_device()) | {"cpu_execution": (cpu_execution or _package.EXECUTION).native()} - | _planning_payload(fft_planning) + | _planning_payload(fft_planning, stored) ) self._applied_execution = ExecutionRecord.from_native( self._native_state.cpu_execution @@ -388,20 +391,32 @@ def __init__( # 6.6): the workspace is built again with its record. refined = _refined_record(problem, self._native_state) if refined is not None: + stored |= _keep_plans(self._native_state, wisdom) self._native_state.close() self._native_state = _core.State( _native_problem(problem, self._terms, self._flow, self._z_tables) | _low_storage_payload(problem, device=_on_device()) | {"cpu_execution": refined[0].native()} - | _planning_payload(fft_planning) + | _planning_payload(fft_planning, stored) ) self._execution_source = refined[1] self._applied_execution = ExecutionRecord.from_native( self._native_state.cpu_execution ) self._native_state.set_crossing_workers(thread_budget().threads) - _wisdom.store(wisdom) - self._wisdom_digest: str | None = None + if fft_planning is None: + _keep_plans(self._native_state, wisdom) + self._wisdom_digest = ( + _wisdom.digest( + _applied_plans( + self._native_state, + stored, + fft_planning if fft_planning is not None else _package.PLANNING, + ) + ) + if not _on_device() + else None + ) if self._z_tables is not None: self._z_tables.upload(self._native_state) _upload_flux_table(self._native_state, problem) @@ -1980,8 +1995,6 @@ def metadata(self) -> dict[str, object]: """ facts = _build_facts() work = super().metadata() - if self._wisdom_digest is None: - self._wisdom_digest = _wisdom.digest() if self._adams is None and self._low_storage is None: work["source_evaluations"] = self._native_state.rk_source_evaluations march = self._adams @@ -4483,12 +4496,58 @@ def _refined_record( return None -def _planning_payload(given: Mapping[str, object] | None) -> dict[str, object]: - """Return the problem's ``fft_planning``: the one given, the profile's, or none.""" - chosen = given if given is not None else _package.PLANNING - if chosen is None or _on_device(): +def _planning_payload( + given: Mapping[str, object] | None, stored: Mapping[str, str] +) -> dict[str, object]: + """Return the problem's ``fft_planning``: the one given, or the run's own. + + A run's own is the profile's, or the plan policy's, offered the plans runs measured + before (`stored`) and keeping those it measures (solprop ADR-076); one given, a + calibration's, plans on its own. + """ + if _on_device(): + return {} + if given is not None: + return {"fft_planning": dict(given)} + chosen = _package.PLANNING or {"mode": "legacy"} + return {"fft_planning": dict(chosen) | {"stored": dict(stored), "keep": True}} + + +def _keep_plans(state: _core.State, path: Path | None) -> dict[str, str]: + """Keep the plans a workspace measured, in this process and in `path`; return them. + + A key the profile holds is never kept: its winner takes precedence. + """ + if _on_device(): return {} - return {"fft_planning": dict(chosen)} + measured = cast(dict[str, str], state.fft_planning["measured"]) + profiled = cast(Mapping[str, str], (_package.PLANNING or {}).get("wisdom", {})) + kept = {key: plan for key, plan in measured.items() if key not in profiled} + if kept: + _wisdom.remember(kept) + _wisdom.store(path, kept) + return kept + + +def _applied_plans( + state: _core.State, + stored: Mapping[str, str], + planning: Mapping[str, object] | None, +) -> dict[str, str]: + """Return the wisdom of each plan a workspace built from wisdom or measured. + + `planning` is the one it was given, or the profile's; `stored` the plans offered. + """ + found = state.fft_planning + profiled = cast(Mapping[str, str], (planning or {}).get("wisdom", {})) + missing = set(cast(list[str], found["missing"])) + applied = { + key: profiled[key] + for key in cast(dict[str, str], found["groups"]) + if key in profiled and key not in missing + } + applied |= {key: stored[key] for key in cast(list[str], found["reused"])} + return applied | cast(dict[str, str], found["measured"]) def _planning_record(state: _core.State) -> dict[str, object]: diff --git a/native/tests/test_profile.py b/native/tests/test_profile.py index 1d7b7fb0..48c878b9 100644 --- a/native/tests/test_profile.py +++ b/native/tests/test_profile.py @@ -440,7 +440,8 @@ def test_a_rows_fft_planning_applies_where_its_workspace_settings_do( } # The user's own planning wins; other process settings keep the policy. assert chosen.planning(Process(4, "spin"), {"SOLPROP_FFTW_PLAN": "patient"}) is None - assert chosen.planning(Process(4, "spin"), {"SOLPROP_FFTW_WISDOM": "w"}) is None + # A wisdom file only moves the plans runs measured: the profile still applies. + assert chosen.planning(Process(4, "spin"), {"SOLPROP_FFTW_WISDOM": "w"}) == applied assert chosen.planning(Process(4, "passive"), {}) is None assert chosen.planning(Process(2, "spin"), {}) is None # A row without planning, or with malformed efforts, keeps the policy too. diff --git a/native/tests/test_store.py b/native/tests/test_store.py index bd03d4ad..0ee4b850 100644 --- a/native/tests/test_store.py +++ b/native/tests/test_store.py @@ -204,6 +204,15 @@ def test_clearing_forgets_the_machines_refinements(tmp_path: Path) -> None: assert store.refinement("def", "d2") is None +def test_clearing_removes_the_stored_plans(tmp_path: Path) -> None: + # The plans runs measured are this machine and build's too (solprop ADR-076). + store = Store(tmp_path) + store.publish(_generation()) + (store.directory("abc") / "wisdom.json").write_text("{}", encoding="utf-8") + assert store.clear("abc") == 3 # its current, its generation and its plans + assert not (store.directory("abc") / "wisdom.json").exists() + + def test_a_claim_is_exclusive_until_released(tmp_path: Path) -> None: store = Store(tmp_path) first = store.claim("abc", "cls", time.time() + 60) diff --git a/native/tests/test_wisdom.py b/native/tests/test_wisdom.py index 8fad3e27..014691f1 100644 --- a/native/tests/test_wisdom.py +++ b/native/tests/test_wisdom.py @@ -2,77 +2,88 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""The native backend keeps FFTW wisdom in a shared file only when one is named.""" +"""The native backend keeps the FFTW plans its runs measure, by default (ADR-076).""" from __future__ import annotations +import json import logging import threading +from dataclasses import dataclass from pathlib import Path +from typing import cast import pytest from conftest import PhaseACase +import solprop_native from solprop_native import NativeBackend, _core, _wisdom -_PREAMBLE = "(fftw-test fftw_wisdom #x1 #x2 #x3 #x4" - - -class _FakeFftw: - """FFTW's wisdom as a set of entry lines under one preamble.""" - - def __init__(self, *entries: str) -> None: - self.entries: set[str] = set(entries) - self.exports = 0 - - def export_fftw_wisdom(self) -> str: - self.exports += 1 - body = "".join(f" ({entry})\n" for entry in sorted(self.entries)) - return f"{_PREAMBLE}\n{body})\n" - - def import_fftw_wisdom(self, text: str) -> bool: - lines = text.splitlines() - if not lines or lines[0] != _PREAMBLE or lines[-1] != ")": - return False - self.entries |= {line.strip()[1:-1] for line in lines[1:-1]} - return True +_KEY = "machine-and-build" @pytest.fixture -def fake(monkeypatch: pytest.MonkeyPatch) -> _FakeFftw: - """Give the wisdom module a fake FFTW and forget what it knew of every file.""" - fftw = _FakeFftw("plan a") - monkeypatch.setattr(_wisdom, "_core", fftw) - monkeypatch.setattr(_wisdom, "_imported", {}) - monkeypatch.setattr(_wisdom, "_known", {}) - return fftw - - -def test_nothing_is_kept_unless_a_file_is_named( - monkeypatch: pytest.MonkeyPatch, fake: _FakeFftw +def fresh(monkeypatch: pytest.MonkeyPatch) -> None: + """Forget what the wisdom module read and measured; give it a fixed machine key.""" + monkeypatch.setattr(_wisdom, "_read", {}) + monkeypatch.setattr(_wisdom, "_measured", {}) + monkeypatch.setattr(_wisdom, "machine_key", lambda: _KEY) + monkeypatch.setattr(_wisdom, "_keeps_wisdom", lambda: True) + + +def _plans(path: Path) -> dict[str, str]: + """Return a plan file's plans.""" + data = json.loads(path.read_text(encoding="utf-8")) + assert data["format"] == _wisdom.FORMAT + return data["plans"] + + +@pytest.mark.usefixtures("fresh") +def test_the_default_file_is_the_machines_in_the_store(tmp_path: Path) -> None: + environ = {"SOLPROP_CACHE_DIR": str(tmp_path), "SOLPROP_CALIBRATION": "auto"} + path = _wisdom.wisdom_file(environ) + assert path == tmp_path / "machines" / _KEY / _wisdom.FILE + assert _wisdom.wisdom_file(environ | {"SOLPROP_CALIBRATION": "use"}) == path + + +@pytest.mark.usefixtures("fresh") +def test_off_keeps_no_default_file_but_a_named_one(tmp_path: Path) -> None: + environ = {"SOLPROP_CACHE_DIR": str(tmp_path), "SOLPROP_CALIBRATION": "off"} + assert _wisdom.wisdom_file(environ) is None + named = environ | {_wisdom.ENVIRONMENT: str(tmp_path / "wisdom")} + assert _wisdom.wisdom_file(named) == tmp_path / f"wisdom.native-{_KEY}" + + +@pytest.mark.usefixtures("fresh") +def test_a_named_file_is_keyed_beside_it(tmp_path: Path) -> None: + # pathlib expands ``~`` from the process's HOME. + with pytest.MonkeyPatch.context() as patch: + patch.setenv("HOME", str(tmp_path)) + found = _wisdom.wisdom_file({_wisdom.ENVIRONMENT: "~/other"}) + assert found == tmp_path / f"other.native-{_KEY}" + + +@pytest.mark.usefixtures("fresh") +@pytest.mark.parametrize("named", [".", "..", "~nouser-solprop-test/w"]) +def test_an_unusable_name_warns_and_keeps_nothing( + named: str, caplog: pytest.LogCaptureFixture ) -> None: - monkeypatch.delenv(_wisdom.ENVIRONMENT, raising=False) - assert _wisdom.wisdom_file() is None - assert _wisdom.wisdom_file("") is None - assert not _wisdom.load(None) - assert not _wisdom.store(None) - assert fake.exports == 0 + with caplog.at_level(logging.WARNING, logger=_wisdom.__name__): + assert _wisdom.wisdom_file({_wisdom.ENVIRONMENT: named}) is None + assert "cannot keep FFTW plans" in caplog.text -def test_the_native_file_is_keyed_beside_the_named_one( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path +@pytest.mark.usefixtures("fresh") +def test_a_store_without_a_home_warns_and_keeps_nothing( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: - monkeypatch.setenv(_wisdom.ENVIRONMENT, str(tmp_path / "wisdom")) - path = _wisdom.wisdom_file() - if _core.export_fftw_wisdom() is None: - assert path is None - return - assert path is not None - assert path.parent == tmp_path - assert path.name.startswith("wisdom.native-") - assert path == _wisdom.wisdom_file(str(tmp_path / "wisdom")) - monkeypatch.setenv("HOME", str(tmp_path)) - assert _wisdom.wisdom_file("~/other") == tmp_path / f"other.{path.suffix[1:]}" + def homeless() -> Path: + raise RuntimeError("Could not determine home directory.") + + monkeypatch.setattr(Path, "home", staticmethod(homeless)) + with caplog.at_level(logging.WARNING, logger=_wisdom.__name__): + assert _wisdom.wisdom_file({"SOLPROP_CALIBRATION": "auto"}) is None + assert "Could not determine home directory" in caplog.text def test_a_build_without_fftw_wisdom_keeps_no_file( @@ -84,142 +95,299 @@ def export_fftw_wisdom() -> None: return None monkeypatch.setattr(_wisdom, "_core", _Device()) - _wisdom._key.cache_clear() # pyright: ignore[reportPrivateUsage] + _wisdom._keeps_wisdom.cache_clear() # pyright: ignore[reportPrivateUsage] try: - assert _wisdom.wisdom_file(str(tmp_path / "wisdom")) is None - assert _wisdom.digest() is None + assert _wisdom.wisdom_file({"SOLPROP_CACHE_DIR": str(tmp_path)}) is None + assert _wisdom.digest({}) is None finally: - _wisdom._key.cache_clear() # pyright: ignore[reportPrivateUsage] + _wisdom._keeps_wisdom.cache_clear() # pyright: ignore[reportPrivateUsage] -def test_the_processor_is_named_from_its_model_lines() -> None: - processor = _wisdom._processor # pyright: ignore[reportPrivateUsage] - x86 = "processor\t: 0\nmodel name\t: Example CPU @ 2.60GHz\nflags\t: fpu\n" - assert processor(x86) == "Example CPU @ 2.60GHz" - arm = "CPU implementer\t: 0x41\nCPU part\t: 0xd0c\nCPU part\t: 0xd0c\n" - assert processor(arm) == "0x41 0xd0c" - assert isinstance(processor("nothing to see\n"), str) - assert isinstance(processor(), str) +def test_the_machine_key_is_the_profiles_fingerprint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _wisdom.machine_key.cache_clear() + try: + key = _wisdom.machine_key() + assert len(key) == 24 + found = solprop_native.PROFILE.fingerprint + if found is not None: + assert key == found.digest + # Under SOLPROP_CALIBRATION=off the package took none, and it is computed. + monkeypatch.setattr( + solprop_native, "PROFILE", solprop_native.PROFILE.__class__(mode="off") + ) + _wisdom.machine_key.cache_clear() + assert _wisdom.machine_key() == key + finally: + _wisdom.machine_key.cache_clear() -def test_wisdom_round_trips_and_an_unchanged_run_writes_nothing( - fake: _FakeFftw, tmp_path: Path -) -> None: - path = tmp_path / "nested" / "wisdom.native-key" - assert not _wisdom.load(path) - assert _wisdom.store(path) - assert path.read_text(encoding="ascii") == fake.export_fftw_wisdom() +@pytest.mark.usefixtures("fresh") +def test_plans_round_trip_and_an_unchanged_run_writes_nothing(tmp_path: Path) -> None: + path = tmp_path / "nested" / "wisdom.json" + assert _wisdom.plans(path) == ({}, False) + assert _wisdom.store(path, {"r2c:a": "(a)"}) + assert _plans(path) == {"r2c:a": "(a)"} version = path.stat().st_mtime_ns - # Nothing planned since: no lock, no read, no write. - assert not _wisdom.store(path) - assert path.stat().st_mtime_ns == version - # A process that knows the file already does not read it again. - assert _wisdom.load(path) - # A fresh process imports the file. - _wisdom._imported.clear() # pyright: ignore[reportPrivateUsage] - _wisdom._known.clear() # pyright: ignore[reportPrivateUsage] - fake.entries = set() - assert _wisdom.load(path) - assert fake.entries == {"plan a"} - assert not _wisdom.store(path) - - -def test_a_writer_merges_what_others_stored(fake: _FakeFftw, tmp_path: Path) -> None: - path = tmp_path / "wisdom.native-key" - other = _FakeFftw("plan b") - path.write_text(other.export_fftw_wisdom(), encoding="ascii") - assert _wisdom.store(path) - assert fake.entries == {"plan a", "plan b"} - merged = _FakeFftw() - assert merged.import_fftw_wisdom(path.read_text(encoding="ascii")) - assert merged.entries == {"plan a", "plan b"} - # Our plans are in the file now: a merge adds nothing, and nothing is written. - _wisdom._known.clear() # pyright: ignore[reportPrivateUsage] - version = path.stat().st_mtime_ns - assert not _wisdom.store(path) + # Nothing new: no write. + assert not _wisdom.store(path, {"r2c:a": "(a)"}) + assert not _wisdom.store(path, {}) assert path.stat().st_mtime_ns == version + assert _wisdom.plans(path) == ({"r2c:a": "(a)"}, True) + # This process's own plans are offered too, over the file's, and alone without one. + _wisdom.remember({"c2r:b": "(b)"}) + assert _wisdom.plans(path) == ({"r2c:a": "(a)", "c2r:b": "(b)"}, True) + assert _wisdom.plans(None) == ({"c2r:b": "(b)"}, False) + assert not _wisdom.store(None, {"c2r:b": "(b)"}) + + +@pytest.mark.usefixtures("fresh") +def test_a_writer_merges_what_others_stored(tmp_path: Path) -> None: + path = tmp_path / "wisdom.json" + path.write_text( + json.dumps({"format": 1, "plans": {"r2c:a": "(a)"}}), encoding="utf-8" + ) + assert _wisdom.store(path, {"c2r:b": "(b)"}) + assert _plans(path) == {"r2c:a": "(a)", "c2r:b": "(b)"} + # A plan measured again, since its wisdom no longer sufficed, replaces it. + assert _wisdom.store(path, {"r2c:a": "(a2)"}) + assert _plans(path)["r2c:a"] == "(a2)" assert not list(tmp_path.glob(".*")) -def test_a_writer_waits_for_the_lock(fake: _FakeFftw, tmp_path: Path) -> None: - path = tmp_path / "wisdom.native-key" +@pytest.mark.usefixtures("fresh") +def test_a_profiles_keys_are_never_stored(tmp_path: Path) -> None: + path = tmp_path / "wisdom.json" + assert not _wisdom.store(path, {"r2c:a": "(a)"}, profiled={"r2c:a"}) + assert not path.exists() + assert _wisdom.store(path, {"r2c:a": "(a)", "c2r:b": "(b)"}, profiled={"r2c:a"}) + assert _plans(path) == {"c2r:b": "(b)"} + + +@pytest.mark.usefixtures("fresh") +def test_a_writer_waits_for_the_lock(tmp_path: Path) -> None: + path = tmp_path / "wisdom.json" if _wisdom.fcntl is None: # pragma: no cover - Windows: there is no writer lock pytest.skip("no writer lock on this platform") - writer = threading.Thread(target=_wisdom.store, args=(path,)) + writer = threading.Thread(target=_wisdom.store, args=(path, {"r2c:a": "(a)"})) with _wisdom._exclusive(path): # pyright: ignore[reportPrivateUsage] writer.start() writer.join(0.2) assert writer.is_alive() assert not path.exists() writer.join() - assert path.read_text(encoding="ascii") == fake.export_fftw_wisdom() + assert _plans(path) == {"r2c:a": "(a)"} + + +@pytest.mark.usefixtures("fresh") +@pytest.mark.parametrize( + "text", + [ + "not json at all", + "[1, 2]", + json.dumps({"format": 0, "plans": {}}), + json.dumps({"format": 1, "plans": {"r2c:a": 3}}), + ], +) +def test_a_malformed_file_is_reported_ignored_and_replaced( + text: str, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + path = tmp_path / "wisdom.json" + path.write_text(text, encoding="utf-8") + with caplog.at_level(logging.WARNING, logger=_wisdom.__name__): + assert _wisdom.plans(path) == ({}, False) + assert _wisdom.store(path, {"r2c:a": "(a)"}) + assert "ignoring the FFTW plans" in caplog.text + assert _plans(path) == {"r2c:a": "(a)"} -def test_foreign_or_unreadable_wisdom_is_reported_and_ignored( - fake: _FakeFftw, tmp_path: Path, caplog: pytest.LogCaptureFixture +@pytest.mark.usefixtures("fresh") +def test_an_unreadable_or_unwritable_file_is_reported( + tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - foreign = tmp_path / "foreign" - foreign.write_text("not wisdom at all", encoding="ascii") - with caplog.at_level(logging.WARNING, logger=_wisdom.__name__): - assert not _wisdom.load(foreign) - # A writer replaces it with this process's wisdom. - assert _wisdom.store(foreign) - assert "is not this FFTW's wisdom" in caplog.text - assert foreign.read_text(encoding="ascii") == fake.export_fftw_wisdom() binary = tmp_path / "binary" binary.write_bytes(b"\xff\xfe") - unwritable = tmp_path / "a file" / "wisdom" + unwritable = tmp_path / "a file" / "wisdom.json" unwritable.parent.write_text("", encoding="ascii") - caplog.clear() with caplog.at_level(logging.WARNING, logger=_wisdom.__name__): - assert not _wisdom.load(binary) - assert not _wisdom.store(unwritable) - assert "cannot read FFTW wisdom" in caplog.text - assert "cannot store FFTW wisdom" in caplog.text + assert _wisdom.plans(binary) == ({}, False) + assert not _wisdom.store(unwritable, {"r2c:a": "(a)"}) + assert "cannot read FFTW plans" in caplog.text + assert "cannot store FFTW plans" in caplog.text +@pytest.mark.usefixtures("fresh") def test_a_failed_replace_leaves_no_partial_file( - fake: _FakeFftw, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - path = tmp_path / "wisdom.native-key" + path = tmp_path / "wisdom.json" def refuse(self: Path, target: Path) -> Path: raise PermissionError(f"cannot replace {target}") monkeypatch.setattr(Path, "replace", refuse) - assert not _wisdom.store(path) + assert not _wisdom.store(path, {"r2c:a": "(a)"}) assert not path.exists() assert sorted(one.name for one in tmp_path.iterdir()) == [f"{path.name}.lock"] -def test_the_digest_follows_the_wisdom(fake: _FakeFftw) -> None: - before = _wisdom.digest() +@pytest.mark.usefixtures("fresh") +def test_the_digest_follows_the_plans() -> None: + before = _wisdom.digest({"r2c:a": "(a)"}) assert before is not None assert len(before) == 16 - fake.entries.add("plan c") - assert _wisdom.digest() != before + assert _wisdom.digest({"r2c:a": "(a)", "c2r:b": "(b)"}) != before -def test_a_backend_keeps_and_reuses_its_plans( - phase_a_case: PhaseACase, monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - problem, grid = phase_a_case +@pytest.fixture +def measured(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Measure every transform, keep plans under `tmp_path`; return the file.""" + if _core.export_fftw_wisdom() is None: + pytest.skip("this build keeps no FFTW wisdom") + monkeypatch.setenv("SOLPROP_FFTW_PLAN", "measure") monkeypatch.setenv(_wisdom.ENVIRONMENT, str(tmp_path / "wisdom")) + monkeypatch.setattr(_wisdom, "_read", {}) + monkeypatch.setattr(_wisdom, "_measured", {}) + _core.forget_fftw_wisdom() path = _wisdom.wisdom_file() + assert path is not None + return path + + +@dataclass(frozen=True) +class _Planned: + """What a workspace's planning did with its plans.""" + + measured: dict[str, str] + reused: list[str] + groups: dict[str, str] + missing: list[str] + + +def _planning(backend: NativeBackend) -> _Planned: + found = backend._native_state.fft_planning # pyright: ignore[reportPrivateUsage] + return _Planned( + measured=cast(dict[str, str], found["measured"]), + reused=cast(list[str], found["reused"]), + groups=cast(dict[str, str], found["groups"]), + missing=cast(list[str], found["missing"]), + ) + + +def test_a_second_run_of_a_shape_plans_without_measuring( + phase_a_case: PhaseACase, measured: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + problem, grid = phase_a_case first = NativeBackend(problem, grid=grid) try: metadata = first.metadata() + found = _planning(first) assert metadata["wisdom_loaded"] is False - if path is None: - assert metadata["wisdom_digest"] is None - return assert isinstance(metadata["wisdom_digest"], str) - assert path.is_file() - assert _core.import_fftw_wisdom(path.read_text(encoding="ascii")) + assert found.measured + assert found.reused == [] + kept = _plans(measured) + assert kept == found.measured + finally: + first.close() + # A new process: nothing of this one's plans in memory or in FFTW. + monkeypatch.setattr(_wisdom, "_read", {}) + monkeypatch.setattr(_wisdom, "_measured", {}) + _core.forget_fftw_wisdom() + second = NativeBackend(problem, grid=grid) + try: + metadata = second.metadata() + found = _planning(second) + assert metadata["wisdom_loaded"] is True + assert metadata["wisdom_digest"] is not None + assert found.measured == {} + assert set(found.reused) == set(kept) + finally: + second.close() + assert _plans(measured) == kept + + +def test_off_still_reuses_a_processs_own_plans( + phase_a_case: PhaseACase, measured: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + problem, grid = phase_a_case + monkeypatch.delenv(_wisdom.ENVIRONMENT) + assert _wisdom.wisdom_file() is None # the tests run under off + first = NativeBackend(problem, grid=grid) + try: + assert _planning(first).measured + finally: + first.close() + second = NativeBackend(problem, grid=grid) + try: + assert _planning(second).measured == {} + assert _planning(second).reused + assert second.metadata()["wisdom_loaded"] is False + finally: + second.close() + assert not measured.exists() + + +def test_a_profiles_winner_takes_precedence_over_stored_plans( + phase_a_case: PhaseACase, measured: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + problem, grid = phase_a_case + first = NativeBackend(problem, grid=grid) + try: + kept = dict(_planning(first).measured) + finally: + first.close() + winner, *others = sorted(kept) + # A profile holding one of the transforms, at measure for the rest: its own wisdom + # plans it, the stored plans the rest, and its key is never stored. + monkeypatch.setenv("SOLPROP_FFTW_PLAN", "") + monkeypatch.setattr( + solprop_native, + "PLANNING", + { + "mode": "profile", + "efforts": {"rows": "measure", "threaded_rows": "measure"}, + "wisdom": {winner: kept[winner]}, + }, + ) + measured.write_text( + json.dumps({"format": 1, "plans": {key: kept[key] for key in others}}), + encoding="utf-8", + ) + monkeypatch.setattr(_wisdom, "_read", {}) + monkeypatch.setattr(_wisdom, "_measured", {}) + second = NativeBackend(problem, grid=grid) + try: + found = _planning(second) + assert found.missing == [] + assert winner in found.groups + assert winner not in found.reused + assert found.measured == {} + assert set(found.reused) == set(others) + finally: + second.close() + assert sorted(_plans(measured)) == others + + +def test_an_estimate_run_takes_the_stored_plans_without_seeding( + phase_a_case: PhaseACase, measured: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A transform left at estimate imports its stored plan for its estimate plan.""" + problem, grid = phase_a_case + first = NativeBackend(problem, grid=grid) + try: + kept = sorted(_planning(first).measured) finally: first.close() + monkeypatch.setenv("SOLPROP_FFTW_PLAN", "estimate") + monkeypatch.setattr(_wisdom, "_read", {}) + monkeypatch.setattr(_wisdom, "_measured", {}) + _core.forget_fftw_wisdom() second = NativeBackend(problem, grid=grid) try: - assert second.metadata()["wisdom_loaded"] is True + found = _planning(second) + assert found.measured == {} + assert set(found.reused) == set(kept) finally: second.close() diff --git a/src/solprop/calibration/__init__.py b/src/solprop/calibration/__init__.py index c84d35dd..1c824dae 100644 --- a/src/solprop/calibration/__init__.py +++ b/src/solprop/calibration/__init__.py @@ -269,9 +269,12 @@ def import_profile(path: Path, environ: Mapping[str, str] | None = None) -> bool def clear(environ: Mapping[str, str] | None = None) -> int: - """Forget this machine and build's profiles in the user's store. + """Forget this machine and build's profiles and stored FFT plans. - A calibration in progress keeps its claim. Read-only directories are not touched. + The user's store loses the profiles, their refinements and the plans runs measured + (``wisdom.json``, solprop ADR-076), and the file ``SOLPROP_FFTW_WISDOM`` keeps for + this build, when the variable is set, goes too. A calibration in progress keeps + its claim. Read-only directories are not touched. Parameters ---------- @@ -284,6 +287,26 @@ def clear(environ: Mapping[str, str] | None = None) -> int: The files removed. """ found = choice(environ) - if found.fingerprint is None: - return 0 - return _native.store.Store(store_directory(environ)).clear(found.fingerprint.digest) + removed = 0 + digest = None if found.fingerprint is None else found.fingerprint.digest + if digest is not None: + removed = _native.store.Store(store_directory(environ)).clear(digest) + named = (os.environ if environ is None else environ).get("SOLPROP_FFTW_WISDOM", "") + if not named.strip(): + return removed + try: + if digest is None: + # SOLPROP_CALIBRATION=off reads no profile, but runs still keep the named + # file, under the same fingerprint (solprop_native._wisdom.machine_key). + build = _native.fingerprint.build_identity( + _native.packages(), _native.variant() + ) + digest = _native.fingerprint.fingerprint( + _native.topology.read_topology(), build + ).digest + base = Path(named.strip()).expanduser() + base.with_name(f"{base.name}.native-{digest}").unlink() + removed += 1 + except OSError, ValueError, RuntimeError: + pass + return removed diff --git a/tests/backends/test_execution_record_cost.py b/tests/backends/test_execution_record_cost.py index 9494dc62..03a95bb5 100644 --- a/tests/backends/test_execution_record_cost.py +++ b/tests/backends/test_execution_record_cost.py @@ -7,10 +7,10 @@ import pytest -def test_the_wisdom_digest_is_exported_once_per_backend( +def test_the_wisdom_digest_is_taken_once_per_backend( monkeypatch, soliton_problem, soliton_grid ): - """Every plan is built at construction, so the digest is taken at the first record. + """Every plan is built at construction, so the digest is taken there, once. Exporting FFTW's wisdom at every plane was most of the record's cost; the record the output keeps is unchanged. @@ -20,9 +20,9 @@ def test_the_wisdom_digest_is_exported_once_per_backend( calls = [] digest = wisdom.digest - def counted(): + def counted(applied): calls.append(1) - return digest() + return digest(applied) monkeypatch.setattr(wisdom, "digest", counted) problem = soliton_problem(soliton_grid, 1, save_planes=[0.01]) @@ -31,6 +31,5 @@ def counted(): records = [backend.metadata() for _ in range(3)] finally: backend.close() - assert len(calls) == 1 + assert len(calls) <= 1 # none on a device build assert records[0] == records[1] == records[2] - assert records[0]["wisdom_digest"] == digest() diff --git a/tests/calibration/test_profiles.py b/tests/calibration/test_profiles.py index 91d6d4c5..864dc5cf 100644 --- a/tests/calibration/test_profiles.py +++ b/tests/calibration/test_profiles.py @@ -99,6 +99,35 @@ def test_no_profile_means_the_built_in_table(environ: dict[str, str]) -> None: assert "profile: none (the built-in table applies)" in off +def test_clearing_removes_the_plans_runs_measured( + environ: dict[str, str], tmp_path: Path +) -> None: + """The stored FFT plans go with the profile, and a named file's too (ADR-076).""" + found = calibration.choice(environ) + assert found.fingerprint is not None + digest = found.fingerprint.digest + default = calibration.store_directory(environ) / "machines" / digest / "wisdom.json" + default.parent.mkdir(parents=True) + default.write_text("{}", encoding="utf-8") + named = tmp_path / "plans" + kept = named.with_name(f"plans.native-{digest}") + kept.write_text("{}", encoding="utf-8") + other = named.with_name("plans.native-another") + other.write_text("{}", encoding="utf-8") + assert calibration.clear(environ | {"SOLPROP_FFTW_WISDOM": str(named)}) == 2 + assert not default.exists() + assert not kept.exists() + assert other.exists() + # Nothing left to remove, and an unusable name is passed over. + assert calibration.clear(environ | {"SOLPROP_FFTW_WISDOM": "."}) == 0 + # Under off, runs still keep the named file, and clearing still finds it. + kept.write_text("{}", encoding="utf-8") + off = environ | {"SOLPROP_CALIBRATION": "off", "SOLPROP_FFTW_WISDOM": str(named)} + assert calibration.clear(off) == 1 + assert not kept.exists() + assert other.exists() + + def test_a_published_profile_is_read_shown_exported_and_cleared( environ: dict[str, str], tmp_path: Path ) -> None: