From 5a0323a601dd90a2c7127be4eda3968d07da0ae6 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:05:15 +0000 Subject: [PATCH 001/122] Read the transverse edge once per position, by a replay's next step A plane the step that reached it already read crossed the state, reduced it and only then found nothing to record. A replay decided its reads from dz_init, which it never updates: a short one read a step late, past the spacing, and a long one at every step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/adams_march.cpp | 7 ++- src/solprop/backends/driver.py | 32 +++++++++-- src/solprop/transverse_monitor.py | 29 ++++++++-- tests/test_transverse_reads.py | 90 +++++++++++++++++++++++++++++-- 4 files changed, 144 insertions(+), 14 deletions(-) diff --git a/native/src/adams_march.cpp b/native/src/adams_march.cpp index fdc5c110..3846707c 100644 --- a/native/src/adams_march.cpp +++ b/native/src/adams_march.cpp @@ -551,8 +551,13 @@ Result run(const Policy& policy, State& state, Engine& engine, const Bounds& bou result.reason = Reason::reached; return result; } + // A replay's next step is its schedule's (`StepDriver._next_step`). + const double ahead = + !bounds.replay + ? state.proposal + : (state.cursor < n_steps ? schedule.steps[state.cursor] : 0.0); if ((bounds.host_step > 0 && accepted == bounds.host_step) || - read_due(bounds, state.z, state.proposal)) { + read_due(bounds, state.z, ahead)) { result.reason = Reason::host_step; return result; } diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index 19147855..1f908e05 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -1996,16 +1996,38 @@ def _read_transverse(self, monitor: BoundaryMonitor, *, plane: bool) -> None: if edge is None: return # Read before the next step would carry past the read (`TransverseEdge.due`). - due = edge.due(self._z, self._proposal) - if not (plane or edge.every_step or due): + due = edge.due(self._z, self._next_step()) + # A plane the step that reached it already read has nothing to add. + read = (plane or due) and edge.wants_read(self._z) + # A collar's inward current accumulates at every committed step. + accumulate = edge.every_step and not plane + if not (read or accumulate): return state = np.asarray(self._observed_field(self._require_state())[0]) - if edge.every_step and not plane: - # A collar's inward current accumulates at every committed step. + if accumulate: edge.step(state, self._z) - if plane or due: + if read: edge.read(state, self._z) + def _next_step(self) -> float: + """Return the step the march takes next, m. + + A replay takes its schedule's and never updates the proposal, which stays + ``dz_init``: read from that, an edge read comes early or late by the difference. + + Returns + ------- + float + The schedule's next step on a replay, zero past its end, and otherwise the + controller's proposal. + """ + schedule = self._problem.stepper.schedule + if schedule is None: + return self._proposal + if self._schedule_index >= schedule.n_steps: + return 0.0 + return float(schedule.steps[self._schedule_index]) + def _transverse_capped(self, proposal: float) -> float: """Return the proposal, held to the transverse edge's read spacing (SEP 40 5.3). diff --git a/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py index a3ccd0c3..9048c601 100644 --- a/src/solprop/transverse_monitor.py +++ b/src/solprop/transverse_monitor.py @@ -276,6 +276,7 @@ class TransverseEdge: _returned_peak: float = field(default=0.0, init=False) _inverse: np.ndarray | None = field(default=None, init=False) _read_rows: np.ndarray | None = field(default=None, init=False) + _kappa_order: np.ndarray = field(init=False) _workers: int = field(default=1, init=False) def __post_init__(self) -> None: @@ -292,6 +293,7 @@ def __post_init__(self) -> None: else: self._weights = np.asarray(space.spatial_weights, dtype=np.float64) self._kappa = np.sqrt(np.asarray(space.kperp_sq, dtype=np.float64)) + self._kappa_order = np.argsort(self._kappa) self._columns = np.asarray(space.spectral_weights, dtype=np.float64) self._peak = [0.0] * len(self._faces) self._peak_raw = [0.0] * len(self._faces) @@ -675,7 +677,7 @@ def _spacing(self, columns: Float[np.ndarray, " n_columns"]) -> float: """ if self._noise_columns is not None: columns = np.maximum(columns - self._noise_columns, 0.0) - order = np.argsort(self._kappa) + order = self._kappa_order tail = np.cumsum(columns[order][::-1])[::-1] / self._launch above = np.nonzero(tail > self.budget**2)[0] kappa_q = float(self._kappa[order][above[-1]]) if above.size else 0.0 @@ -728,6 +730,27 @@ def due(self, z: float, proposal: float = 0.0) -> bool: """ return z + proposal >= self._next - 1e-12 * max(abs(z), 1.0) + def wants_read(self, z: float) -> bool: + """Return whether a read at `z` would record anything. + + A saved plane reads the edge whether or not the step that reached it already + did, and a read at the position of the last one records nothing. Asking first + spares the caller the state it would otherwise hand over for nothing, which on + a device is a crossing of the whole state. + + Parameters + ---------- + z : float + The committed position, m. + + Returns + ------- + bool + False when nothing was launched, the edge is unwatched, or it was last + read at `z`. + """ + return self._launch > 0.0 and self._status != "unwatched" and z > self._last_z + def read( self, state: Complex[np.ndarray, "n_modes n_pol n_omega"], z: float ) -> None: @@ -745,13 +768,11 @@ def read( BoundaryError When a face's judged share exceeds ``stop_factor`` budgets. """ - if not self._launch > 0.0 or self._status == "unwatched": + if not self.wants_read(z): return nodes = self._node_power(state, read=True) weighted = self._weights * nodes gap = z - self._last_z - if gap <= 0.0: - return self._reads += 1 self._largest_gap = max(self._largest_gap, gap) # The ramp's removal over the interval, from each column's power at its start. diff --git a/tests/test_transverse_reads.py b/tests/test_transverse_reads.py index 7f638d87..68194957 100644 --- a/tests/test_transverse_reads.py +++ b/tests/test_transverse_reads.py @@ -34,6 +34,7 @@ import numpy as np import pytest +from solprop.backends.registry import available from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import BoundaryError, BoundaryWarning from solprop.grid import EnvelopeGrid, LineGrid @@ -43,7 +44,11 @@ from solprop.pulses import GaussianPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, Multistep, PIController, RungeKutta +from solprop.transverse_monitor import TransverseEdge +BACKENDS = ("numpy_ref", "native") +RELAXED = BoundaryCheck(budget=1.0, transverse_budget=1.0) +"""A check that reads as usual and judges nothing: these packets reach the edge.""" C = 299792458.0 WAVELENGTH = 800e-9 K = 2 * math.pi / WAVELENGTH @@ -101,7 +106,13 @@ def physical(self, space: LineGrid) -> np.ndarray: MISS = Packet(1e-3, 2560, 100e-6, 0.3, -400e-6, 8.18e-3) -def packet_run( +def _require(backend: str) -> None: + """Skip when `backend` isn't installed.""" + if backend not in available(): + pytest.skip(f"{backend} is not installed") + + +def packet_run( # noqa: PLR0913 - the settings these runs vary, each independent packet: Packet, *, integrator: Multistep | RungeKutta | None = None, @@ -109,11 +120,13 @@ def packet_run( check: BoundaryCheck | None = BoundaryCheck(), # noqa: B008 - immutable planes: list[float] | None = None, space: LineGrid | None = None, + dz_init: float = 1e-4, + backend: str = "numpy_ref", ) -> Simulation: """Return `packet` as a free-space run on `space` (its own line by default). ``rtol = 1e-5``, ``dz_init = 1e-4 L``, ``auto_init`` and ``dz_max = L``: the - settings of Part II's table of committed steps. + settings of Part II's table of committed steps. `dz_init` is in units of ``L``. """ length = packet.length grid = EnvelopeGrid.from_limits(length, WAVELENGTH, (790e-9, 810e-9), packet.window) @@ -130,10 +143,10 @@ def packet_run( controller=None if schedule is not None else PIController(rtol=1e-5, atol=0.0), save_planes=[length] if planes is None else planes, dz_max=length, - dz_init=length * 1e-4, + dz_init=length * dz_init, auto_init=True, integrator=RungeKutta() if integrator is None else integrator, - backend="numpy_ref", + backend=backend, schedule=schedule, boundary_check=check, ) @@ -382,6 +395,75 @@ def test_the_scaled_miss_is_read_far_above_the_stop() -> None: assert max(record["edge_max"]) > 1e3 * STOP +@pytest.mark.parametrize("backend", BACKENDS) +def test_a_plane_the_step_already_read_reduces_nothing_more( + backend: str, monkeypatch: pytest.MonkeyPatch +) -> None: + # Every step of this schedule is longer than the spacing, so each reads the edge, + # and a plane landing on one has nothing to add. Its read used to cross the state, + # synthesise it and only then find the gap zero: on a device, a whole state's copy + # for nothing. + _require(backend) + packet = REREVIEW + length = packet.length + planes = [length / 4, length / 2, length] + calls = {"nodes": 0} + node_power = TransverseEdge._node_power + + def counting(self: TransverseEdge, *args: Any, **keywords: Any) -> np.ndarray: + calls["nodes"] += 1 + return node_power(self, *args, **keywords) + + monkeypatch.setattr(TransverseEdge, "_node_power", counting) + schedule = FixedSchedule.uniform(0.0, length, length / 8, save_planes=planes) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + output = packet_run( + packet, + schedule=schedule, + planes=planes, + backend=backend, + check=RELAXED, + ).run(progress="none") + assert isinstance(output, PlaneStore) + assert edge_record(output)["reads"] == 8 + # The launch, then one for each of the eight steps and none for the three planes. + assert calls["nodes"] == 9 + + +@pytest.mark.parametrize("integrator", [RungeKutta(), Multistep()], ids=["rk", "adams"]) +@pytest.mark.parametrize("backend", BACKENDS) +def test_a_replay_reads_by_its_next_step_whatever_the_first_proposal( + backend: str, integrator: RungeKutta | Multistep +) -> None: + # A replay never updates the proposal, so `dz_init` used to stand in for the next + # step: a short one read a step late, past the spacing the reads promise, and a long + # one read at every step. Steps of L / 40 under the ceiling L / 16 read every other + # step whichever it is. + _require(backend) + packet = REREVIEW + length = packet.length + schedule = FixedSchedule.uniform(0.0, length, length / 40, save_planes=[length]) + records = {} + for proposal in (1e-4, 0.25): + output = packet_run( + packet, + schedule=schedule, + integrator=integrator, + dz_init=proposal, + backend=backend, + check=RELAXED, + ).run(progress="none") + assert isinstance(output, PlaneStore) + records[proposal] = edge_record(output) + short, long = records[1e-4], records[0.25] + assert short["reads"] == long["reads"] + assert short["largest_gap"] == long["largest_gap"] + # The spacing, relative to rounding in the position it is measured from. + assert short["largest_gap"] <= length / 16 * (1 + 1e-9) + assert short["reads"] < 40 + + def test_the_scaled_miss_on_its_own_committed_steps_is_coarse() -> None: # Part II's committed positions as a fixed schedule: every read misses the # crossing, as unmonitored, but the run reports coarse with its largest gap, From 3cf79a3523711a3f554581fce9ea94e32e4b581c Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:05:15 +0000 Subject: [PATCH 002/122] Reduce the split collar's occupancy per column, without atomics An atomic per element of the state took most of a monitor reading on a device: 184 ms on an NVIDIA H200 and 1.24 s on an AMD Instinct MI325X at 768 x 768 x 600, against steps of 239 and 224 ms. A runner's points now share the walk-off table too, which is a sixteenth of a state to upload. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/include/solprop_native/core.hpp | 17 ++++++ native/src/general.cpp | 77 ++++++++---------------- native/src/solprop_native/backend.py | 6 +- tests/backends/test_monitor_reduction.py | 68 ++++++++++++++++++++- 4 files changed, 113 insertions(+), 55 deletions(-) diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index 8477d5bd..7c8b33c3 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -95,6 +95,20 @@ struct SplitSums { } }; +/** The boundary monitor's occupancy of a state (solprop #440), one reduction value: its + energy per walk-off bucket. A device's reduction keeps one of these per thread in + team memory, with no atomics, so its size bounds the buckets (`SPLIT_RATE_BUCKETS`). + */ +inline constexpr std::size_t occupancy_bucket_limit = 128; +struct OccupancySums { + Kokkos::Array values{}; + KOKKOS_INLINE_FUNCTION OccupancySums& operator+=(const OccupancySums& other) { + for (std::size_t slot = 0; slot < occupancy_bucket_limit; ++slot) + values[slot] += other.values[slot]; + return *this; + } +}; + using ComplexView = Kokkos::View; /** The histogram the boundary monitor's occupancy reading adds into, one slot per walk-off bucket (solprop #440): duplicated per thread on the host, atomic on a @@ -979,6 +993,9 @@ void multiply_phase(const ExecutionSpace& execution_space, ConstComplexView valu template <> struct Kokkos::reduction_identity { KOKKOS_FUNCTION static solprop_native::MonitorSums sum() { return {}; } }; +template <> struct Kokkos::reduction_identity { + KOKKOS_FUNCTION static solprop_native::OccupancySums sum() { return {}; } +}; template <> struct Kokkos::reduction_identity { KOKKOS_FUNCTION static solprop_native::SplitSums sum() { return {}; } }; diff --git a/native/src/general.cpp b/native/src/general.cpp index 891d282f..a537a9a7 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -2876,8 +2876,7 @@ class GeneralWorkspace { // energy per bucket, which a reading returns for the crossing limit (solprop // #440); empty unbound. ByteView occupancy_buckets_; - RealView occupancy_; - OccupancyScatter occupancy_scatter_; + std::size_t occupancy_count_ = 0; [[nodiscard]] bool monitoring_split() const noexcept { return monitor_bound_ && problem_.split_step_absorber; } @@ -14717,47 +14716,27 @@ std::vector GeneralWorkspace::monitor_reading() { sums.values[3], sums.values[4], removed, found.values[1], found.values[2], found.values[3], found.values[4]}; - if (occupancy_.extent(0) != 0) { + if (occupancy_count_ != 0) { // The state's energy per walk-off bucket, beside the five: a state column is - // `mode * n_pol + pol`, and the buckets are by mode. + // `mode * n_pol + pol`, and the buckets are by mode. A reduction per column, whose + // per-thread histograms need no atomics: on a device an atomic per element took + // most of a reading, several steps' time on an AMD Instinct MI325X. const auto buckets = occupancy_buckets_; const auto n_pol = problem_.n_pol_state; - auto scatter = occupancy_scatter_; - // `contribute` adds the per-thread copies of a host build into the view: each - // reading starts the view, as `reset` starts the copies, from zero (a device - // build's atomic scatter adds into the view itself). - graph::deep_copy(execution_space_, occupancy_, 0.0); - scatter.reset(execution_space_); - if constexpr (Kokkos::SpaceAccessibility::accessible) { - // A host scatter finds its thread's copy at each `access`, which dominated this - // loop per element; a row takes it once (issue #453). A device's access is - // atomic. - launch::parallel_for( - "solprop_native.monitor.occupancy", - Kokkos::RangePolicy(execution_space_, 0, columns), - KOKKOS_LAMBDA(const std::size_t row) { - auto access = scatter.access(); - const std::size_t offset = (row / n_pol) * n; - const double weight = column_weights(row); - for (std::size_t index = 0; index < n; ++index) - access(buckets(offset + index)) += - weight * squared_abs(current(row, index)); - }); - } else { - launch::parallel_for( - "solprop_native.monitor.occupancy", - Range2(execution_space_, {0, 0}, {columns, n}), - KOKKOS_LAMBDA(const std::size_t row, const std::size_t index) { - auto access = scatter.access(); - access(buckets((row / n_pol) * n + index)) += - column_weights(row) * squared_abs(current(row, index)); - }); - } - Kokkos::Experimental::contribute(execution_space_, occupancy_, scatter); - const auto host = - Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, occupancy_); - reading.insert(reading.end(), host.data(), host.data() + host.extent(0)); + OccupancySums occupancy; + launch::parallel_reduce( + "solprop_native.monitor.occupancy", + Kokkos::RangePolicy(execution_space_, 0, columns), + KOKKOS_LAMBDA(const std::size_t row, OccupancySums& update) { + const std::size_t offset = (row / n_pol) * n; + const double weight = column_weights(row); + for (std::size_t index = 0; index < n; ++index) + update.values[buckets(offset + index)] += + weight * squared_abs(current(row, index)); + }, + Kokkos::Sum(occupancy)); + reading.insert(reading.end(), occupancy.values.data(), + occupancy.values.data() + occupancy_count_); } execution_space_.fence("solprop_native monitor reading"); return reading; @@ -14765,23 +14744,21 @@ std::vector GeneralWorkspace::monitor_reading() { // Bind the split collar's walk-off bucket of each (mode, frequency) bin (solprop // #440): from here on a monitor reading also returns the state's energy per bucket. -// The views are allocated at the first bind and kept. +// The table is allocated at the first bind and kept. void GeneralWorkspace::bind_occupancy(const std::vector& buckets, const std::size_t count) { require_open(); require_extent(buckets.size(), problem_.n_modes * problem_.n_omega, "occupancy buckets"); - if (count == 0 || count > 256) - throw std::invalid_argument("the occupancy needs between 1 and 256 buckets"); + if (count == 0 || count > occupancy_bucket_limit) + throw std::invalid_argument("the occupancy needs between 1 and 128 buckets"); for (const auto bucket : buckets) if (bucket >= count) throw std::invalid_argument("an occupancy bucket lies past the bucket count"); - if (occupancy_.extent(0) != count) { + if (occupancy_count_ == 0) occupancy_buckets_ = ByteView("solprop_native.monitor.occupancy_buckets", buckets.size()); - occupancy_ = RealView("solprop_native.monitor.occupancy", count); - occupancy_scatter_ = OccupancyScatter(occupancy_); - } + occupancy_count_ = count; auto host = Kokkos::create_mirror_view(occupancy_buckets_); for (std::size_t index = 0; index < buckets.size(); ++index) host(index) = buckets[index]; @@ -15274,8 +15251,7 @@ std::map GeneralWorkspace::base_memory_ledger() const // The boundary monitor's weights and accumulators, once a monitor is bound. {"monitor_weights", (monitor_weights_.size() + monitor_column_weights_.size() + - monitor_share_.size() + monitor_half_.size() + monitor_removed_.size() + - occupancy_.size()) * + monitor_share_.size() + monitor_half_.size() + monitor_removed_.size()) * real_bytes + occupancy_buckets_.size() + (monitor_split_sum_.size() + monitor_found_.size()) * sizeof(SplitSums)}, @@ -15519,9 +15495,8 @@ void GeneralWorkspace::close() noexcept { monitor_removed_ = {}; monitor_split_sum_ = {}; monitor_found_ = {}; - occupancy_scatter_ = {}; occupancy_buckets_ = {}; - occupancy_ = {}; + occupancy_count_ = 0; tableau_ = {}; omega_window_ = {}; omega_ = {}; diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 93719d46..19103f6d 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -329,6 +329,7 @@ def __init__( # before are imported before planning, and new ones are kept after it. wisdom = _wisdom.wisdom_file() self._wisdom_loaded = _wisdom.load(wisdom) + self._bound_buckets: np.ndarray | None = None self._native_state = _core.State( _native_problem(problem, self._terms, self._flow, self._z_tables) | _low_storage_payload(problem, device=_on_device()) @@ -1117,13 +1118,16 @@ def _bind_monitor(self, monitor: BoundaryMonitor | None) -> None: np.ascontiguousarray(monitor.weights, dtype=np.float64).reshape(-1), columns ) crossing = monitor.crossing - if crossing is not None: + # A runner's points share the table: uploading it again would move a sixteenth + # of a state per point. + if crossing is not None and crossing.buckets is not self._bound_buckets: # The split collar's crossing limit, read from the state's occupancy of # its walk-off buckets at every check (solprop #440). self._native_state.bind_occupancy( np.ascontiguousarray(crossing.buckets, dtype=np.uint8).reshape(-1), SPLIT_RATE_BUCKETS, ) + self._bound_buckets = crossing.buckets def _boundary_energies(self, state: _core.State) -> BoundaryEnergies: """Reduce the monitor's five numbers where the state lives (solprop SEP 21). diff --git a/tests/backends/test_monitor_reduction.py b/tests/backends/test_monitor_reduction.py index 904ef874..784cf84a 100644 --- a/tests/backends/test_monitor_reduction.py +++ b/tests/backends/test_monitor_reduction.py @@ -22,6 +22,7 @@ import dataclasses import math from collections.abc import Callable +from typing import Any, cast import numpy as np import pytest @@ -294,12 +295,73 @@ def test_a_monitor_s_own_transform_is_in_the_ledger( assert all(ledger[row] > 0 for row in rows), {row: ledger[row] for row in rows} +class _CountingState: + """A native state that counts its `bind_occupancy` calls.""" + + def __init__(self, state: object) -> None: + """Wrap `state`.""" + self.__dict__["_state"] = state + self.__dict__["binds"] = 0 + + def bind_occupancy(self, *args: object) -> None: + """Count, then bind.""" + self.__dict__["binds"] += 1 + self.__dict__["_state"].bind_occupancy(*args) + + def __getattr__(self, name: str) -> object: + """Hand everything else to the state.""" + return getattr(self.__dict__["_state"], name) + + +def test_a_second_point_keeps_its_bucket_table_and_reads_as_the_first() -> None: + # The table is a sixteenth of a state, and a runner's points share it: a second + # upload would move it per point. Nothing else may carry over. + _require("native") + simulation = probe("radial", "split_step", MARCHES["runge_kutta"]) + made = watched(simulation, "native") + try: + driver = cast("Any", as_driver(made)) + assert driver._bound_buckets is not None + first = reading(made) + counting = _CountingState(driver._native_state) + driver._native_state = counting + problem = simulation.translate() + driver.set_initial(with_copy(problem.initial.field0, simulation.grid.n_t), None) + monitor = simulation._boundary_monitor() + assert monitor is not None + driver.attach_monitor(monitor) + assert counting.binds == 0 + again = reading(made) + assert first.occupancy is not None and again.occupancy is not None + # The same state, read again on the same build: a structural repeat. + np.testing.assert_array_equal(again.occupancy, first.occupancy) + driver._native_state = counting.__dict__["_state"] + finally: + made.close() + + +@pytest.mark.parametrize("count", [0, 129]) +def test_an_occupancy_outside_its_reduction_is_refused(count: int) -> None: + _require("native") + simulation = probe("radial", "split_step", MARCHES["runge_kutta"]) + made = watched(simulation, "native") + try: + driver = cast("Any", as_driver(made)) + table = np.zeros( + math.prod(driver._problem.shape[1:2]) * simulation.grid.n_omega + ) + with pytest.raises(ValueError, match="between 1 and 128 buckets"): + driver._native_state.bind_occupancy(table.astype(np.uint8), count) + finally: + made.close() + + @pytest.mark.parametrize("route", ROUTE_NAMES) @pytest.mark.parametrize("backend", COMPILED) def test_successive_occupancies_are_the_reference_s(backend: str, route: str) -> None: - # Each reading of the state's energy per walk-off bucket stands alone: a host - # build's scatter added every reading into the last until the view was zeroed - # before each, and the third reading was three times the first's. + # Each reading of the state's energy per walk-off bucket stands alone. A host + # build's scatter once added every reading into the last, and the third read three + # times the first; a reduction starts from nothing, and this holds it there. _require(backend) simulation = probe(route, "split_step", MARCHES["runge_kutta"]) theirs, ours = (watched(simulation, name) for name in ("numpy_ref", backend)) From f6c975e8ddabdf077c175bf43bb7cf018d23a0a3 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:05:15 +0000 Subject: [PATCH 003/122] Sum energy, the peak and a low-storage step's norm with real reductions pack_energy summed every mode on one GPU thread, and took 53 of the 55 ms of an energy call on an NVIDIA H200 at 768 x 768 x 600. statistics_peak has the same shape. A low-storage step copied its whole table of partials to read one row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/general.cpp | 43 ++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/native/src/general.cpp b/native/src/general.cpp index a537a9a7..b5c2e02a 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -5353,10 +5353,21 @@ double GeneralWorkspace::low_storage_step(const double dz, const TrialScalars& s stages_ready_ = false; stash_is_current_ = false; execution_space_.fence("solprop_native low-storage step"); - const auto host = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, partials_); double total = 0.0; +#if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) + // One number crosses, not the whole of the partials' table. + const auto partials = partials_; + launch::parallel_reduce( + "solprop_native.low_storage.norm_total", + Kokkos::RangePolicy(execution_space_, 0, problem_.n_columns()), + KOKKOS_LAMBDA(const std::size_t column, double& sum) { + sum += partials(0, column); + }, + Kokkos::Sum(total)); +#else for (std::size_t column = 0; column < problem_.n_columns(); ++column) - total += host(0, column); + total += partials_(0, column); +#endif return total; } @@ -13890,15 +13901,13 @@ void GeneralWorkspace::separable_time_statistics(const std::size_t axis_peak, }); } if (peak != absent) - launch::parallel_for( + launch::parallel_reduce( "solprop_native.free.statistics_peak", - Kokkos::RangePolicy(execution_space_, 0, 1), - KOKKOS_LAMBDA(const std::size_t) { - double maximum = lowest; - for (std::size_t node = 0; node < n_nodes; ++node) - maximum = Kokkos::max(maximum, partials(node, 1)); - packed(peak) = maximum; - }); + Kokkos::RangePolicy(execution_space_, 0, n_nodes), + KOKKOS_LAMBDA(const std::size_t node, double& maximum) { + maximum = Kokkos::max(maximum, partials(node, 1)); + }, + Kokkos::Max(Kokkos::subview(packed, peak))); } std::vector GeneralWorkspace::aperture_statistics( @@ -13997,15 +14006,13 @@ std::vector GeneralWorkspace::spatial_statistics(const std::uint64_t fla partials(mode, 0) = intensity_factor * spectral_weights(mode) * dt / static_cast(n_t) * sum; }); - launch::parallel_for( + launch::parallel_reduce( "solprop_native.spatial.pack_energy", - Kokkos::RangePolicy(execution_space_, 0, 1), - KOKKOS_LAMBDA(const std::size_t) { - double sum = 0.0; - for (std::size_t mode = 0; mode < n_modes; ++mode) - sum += partials(mode, 0); - packed(energy_offset) = sum; - }); + Kokkos::RangePolicy(execution_space_, 0, n_modes), + KOKKOS_LAMBDA(const std::size_t mode, double& sum) { + sum += partials(mode, 0); + }, + Kokkos::Sum(Kokkos::subview(packed, energy_offset))); } if (spectrum_offset != absent) { // The coherent on-axis amplitude paired at each frequency with its closure. From 30c22722bbef09010d687c7eeb998230cd5d1c2e Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:05:15 +0000 Subject: [PATCH 004/122] Size the march's snapshot before copying it A first saved plane took the state and the whole history on the host to compare their size with the limit, then dropped them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/solprop_native/backend.py | 5 +++ src/solprop/backends/driver.py | 24 +++++++++++++ src/solprop/backends/multistep.py | 45 +++++++++++++++++++++++ src/solprop/driver/run.py | 30 ++++++++++------ tests/driver/test_resume_history.py | 53 ++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 10 deletions(-) diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 19103f6d..c8be1567 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -2021,6 +2021,11 @@ def value(self, slot: int) -> None: # --- persistence across a resume (solprop SEP 29 G2) ------------------------------ + def snapshot_nbytes(self) -> int: + """Return the bytes :meth:`snapshot_rows` would copy, copying nothing.""" + rows = len(self.native.adams_row_names()) + return rows * self.n_slots * math.prod(self.shape) * 2 * 8 + def snapshot_rows(self) -> dict[str, ComplexArray]: """Return host copies of the rows a resume needs to continue the history. diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index 1f908e05..ae583096 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -918,6 +918,30 @@ def march_snapshot(self) -> MarchSnapshot | None: return self._low_storage.snapshot() return None if self._adams is None else self._adams.snapshot() + def march_snapshot_nbytes(self, z: float) -> int | None: + """Return the bytes :meth:`march_snapshot` would copy at `z`, copying nothing. + + Parameters + ---------- + z : float + The position the snapshot is wanted at, m. + + Returns + ------- + int or None + The state and every engine row, or ``None`` where a snapshot would be + ``None`` or stand somewhere other than `z`. + + Raises + ------ + BackendError + If a low-storage step was abandoned in flight. + """ + self._refuse_in_flight() + if self._low_storage is not None or self._adams is None or self._z != z: + return None + return self._adams.snapshot_nbytes() + def march_export_refusal(self) -> str | None: """Return why this backend's Adams march cannot carry its history, or ``None``. diff --git a/src/solprop/backends/multistep.py b/src/solprop/backends/multistep.py index 38b9595f..9031d33c 100644 --- a/src/solprop/backends/multistep.py +++ b/src/solprop/backends/multistep.py @@ -1955,6 +1955,16 @@ def snapshot_rows(self) -> dict[str, np.ndarray]: """ ... + def snapshot_nbytes(self) -> int: + """Return the bytes :meth:`snapshot_rows` would copy, copying nothing. + + Returns + ------- + int + The size of every row. + """ + ... + def load_rows(self, rows: Mapping[str, np.ndarray], shape: tuple[int, ...]) -> None: """Install rows :meth:`snapshot_rows` returned, refusing rows it does not keep. @@ -2296,6 +2306,21 @@ def value(self, slot: int) -> _State: # --- persistence across a resume -------------------------------------------------- + def snapshot_nbytes(self) -> int: + """Return the bytes :meth:`snapshot_rows` would copy, copying nothing. + + Returns + ------- + int + The size of every row. + """ + assert self._store is not None + rows = [self._store, *self._weights] + if self.compensated: + assert self._states is not None and self._rates is not None + rows += [self._states, self._rates] + return sum(int(row.nbytes) for row in rows if row is not None) + def snapshot_rows(self) -> dict[str, np.ndarray]: """Return copies of the rows a resume needs to continue the history. @@ -3075,6 +3100,26 @@ def snapshot(self) -> MarchSnapshot | None: history_key=self._method.history_key, ) + def snapshot_nbytes(self) -> int | None: + """Return the bytes :meth:`snapshot` would copy, copying nothing. + + Returns + ------- + int or None + The state and every engine row, or ``None`` where :meth:`snapshot` returns + ``None``. + """ + if ( + self.export_refusal() is not None + or not self._history + or self._ahead is not None + ): + return None + state = ( + math.prod(self._backend._problem.shape) * np.dtype(np.complex128).itemsize + ) + return state + self._engine.snapshot_nbytes() + def export_refusal(self) -> str | None: """Return why this march cannot carry its history across a resume, or ``None``. diff --git a/src/solprop/driver/run.py b/src/solprop/driver/run.py index 67711735..1552632b 100644 --- a/src/solprop/driver/run.py +++ b/src/solprop/driver/run.py @@ -1199,6 +1199,7 @@ def __init__(self, output: Output, backend: Backend, policy: bool | None) -> Non """Hold what keeping a snapshot needs.""" self._save = getattr(output, "save_march", None) self._snapshot = getattr(backend, "march_snapshot", None) + self._snapshot_nbytes = getattr(backend, "march_snapshot_nbytes", None) self._policy = policy self._kept_z: float | None = None @@ -1220,7 +1221,11 @@ def keep(self, z: float, *, required: bool = False) -> None: """ if required and self._kept_z == z: return - if self._save is None or self._snapshot is None: + if ( + self._save is None + or self._snapshot is None + or self._snapshot_nbytes is None + ): if required: raise OutputError( "snapshot requires a backend and output with Adams history" @@ -1228,24 +1233,29 @@ def keep(self, z: float, *, required: bool = False) -> None: return if self._policy is False and not required: return - snapshot = self._snapshot() - if snapshot is None or snapshot.z != z: - if required: - raise OutputError( - "snapshot requires Adams history at the accepted endpoint" - ) - return if self._policy is None and not required: - if snapshot.nbytes > MARCH_SNAPSHOT_AUTO_BYTES: + # Before any copy: the snapshot over the limit is the state and the whole + # history, on the host. + nbytes = self._snapshot_nbytes(z) + if nbytes is None: + return + if nbytes > MARCH_SNAPSHOT_AUTO_BYTES: logger.info( "the Adams march's state is not kept for a resume: %d bytes is " "over the %d kept by default (resume_history=True keeps it)", - snapshot.nbytes, + nbytes, MARCH_SNAPSHOT_AUTO_BYTES, ) self._policy = False return self._policy = True + snapshot = self._snapshot() + if snapshot is None or snapshot.z != z: + if required: + raise OutputError( + "snapshot requires Adams history at the accepted endpoint" + ) + return self._save(snapshot) self._kept_z = z diff --git a/tests/driver/test_resume_history.py b/tests/driver/test_resume_history.py index e8d70690..84710e0a 100644 --- a/tests/driver/test_resume_history.py +++ b/tests/driver/test_resume_history.py @@ -22,6 +22,7 @@ import logging import math from types import SimpleNamespace +from typing import Any, cast import h5py import numpy as np @@ -34,7 +35,9 @@ from flow_parity import build as flow_case from march_bounds import TIGHTER, assert_in_envelope from solprop.backends import available +from solprop.backends.driver import StepDriver from solprop.backends.numpy_ref import NumpyRefBackend +from solprop.backends.registry import select from solprop.driver import Simulation from solprop.exceptions import OutputError, SolpropWarning from solprop.log import NullReporter @@ -258,6 +261,56 @@ def test_a_snapshot_over_the_default_size_is_kept_only_on_request( assert resume(asked).march is not None +@pytest.mark.parametrize("backend", ["numpy_ref", "native"]) +def test_a_snapshot_over_the_default_size_is_never_copied( + build_simulation, tmp_path, monkeypatch, backend +): + # The state and the whole history on the host, to learn a size that needs no copy. + if backend not in available(): + pytest.skip(f"{backend} is not installed") + monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) + + def refuse(self): + pytest.fail("a snapshot over the limit was taken") + + monkeypatch.setattr(StepDriver, "march_snapshot", refuse) + path = tmp_path / "run.h5" + soliton(build_simulation, backend)().run(path, progress="none") + assert resume(path).march is None + + +@pytest.mark.parametrize( + "integrator", [Multistep(), INTERACTION], ids=["exponential", "interaction"] +) +@pytest.mark.parametrize("backend", ["numpy_ref", "native"]) +def test_a_snapshot_s_size_is_known_without_it(build_simulation, backend, integrator): + # Exact: both count the same arrays' bytes. + if backend not in available(): + pytest.skip(f"{backend} is not installed") + simulation = soliton(build_simulation, backend, integrator=integrator)() + problem = simulation.translate() + engine = cast( + "Any", + select( + backend, + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ), + ) + try: + engine.set_initial(problem.initial.field0, None, z=problem.initial.z) + assert engine.march_snapshot_nbytes(0.0) is None + engine.propagate_to(0.5 * LENGTH) + snapshot = engine.march_snapshot() + assert snapshot is not None + assert engine.march_snapshot_nbytes(snapshot.z) == snapshot.nbytes + assert engine.march_snapshot_nbytes(0.5 * snapshot.z) is None + finally: + engine.close() + + @pytest.mark.medium def test_the_snapshot_is_rewritten_in_place(build_simulation, tmp_path): # HDF5 does not reclaim a deleted dataset's space: a snapshot recreated at every From b774315b1edacfcbfe010c7599161d1a568ce90f Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:05:15 +0000 Subject: [PATCH 005/122] Add benchmarks.device_split, a field-free point's split The method behind the device measurements of the boundary check, in the tree so that they can be repeated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- benchmarks/device_split.py | 257 +++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 benchmarks/device_split.py diff --git a/benchmarks/device_split.py b/benchmarks/device_split.py new file mode 100644 index 00000000..5f08446d --- /dev/null +++ b/benchmarks/device_split.py @@ -0,0 +1,257 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Split a field-free point of a Kerr scan: the march, the check, the crossings. + +One input of a delay scan through :class:`solprop.driver.SimulationRunner`, with no +fields kept: a fused-silica Kerr beam at 260 nm on fixed low-storage RK4IP steps, with +a stated input identity and no held plane. It runs with ``boundary_check=None`` and +with the default check, budgets relaxed so that nothing stops, and profiles one point +of each. Then it times what a plane can ask of a device: a state's copy to the host, an +observation and the common statistics. + +Usage:: + + uv run python -m benchmarks.device_split NX [--steps 8] [--planes 4] [--out FILE] + +It prints one JSON object per section (``record``, ``setup``, ``point`` twice, +``calls``), and writes them to ``FILE`` too when given. ``point`` names each function's +calls, own and cumulative seconds, and ``calls`` each crossing's minor page faults. +The profiler inflates Python-heavy work, so compare ``walls_s``, which it doesn't run +under. The native backend is required, and a device build is what it's for; on a CPU +build the crossings are plain copies. +""" + +from __future__ import annotations + +import argparse +import cProfile +import json +import platform +import pstats +import resource +import statistics +import subprocess +import sys +import time +import warnings +from pathlib import Path +from typing import Any + +import numpy as np + +from solprop.constants import EPS0, MU0, C +from solprop.driver import Simulation, SimulationSettings +from solprop.grid import EnvelopeGrid, FreeGrid +from solprop.monitor import BoundaryCheck +from solprop.nonlinear.responses import KerrResponse +from solprop.physdata.glasses import SellmeierGlass +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium +from solprop.stats import StatsSpec +from solprop.stepper import FixedSchedule, LowStorageRungeKutta, PIController + +__all__ = ["main"] + +STEP = 0.5e-6 +"""The fixed step, m.""" + +NAMED = ( + "_read_transverse", + "_node_power", + "_occupied_planes", + "_column_power", + "_spacing", + "_observed_field", + "_boundary_energies", + "_take_step_stats", + "_monitor_plane", + "configuration_hash", + "_hashed_record", + "_check_replaced_input", + "attach_monitor", + "replay_step", + "c_einsum", + "field_to_host", +) +"""The functions a point's profile reports by name.""" + +GLASS = SellmeierGlass( + name="malitson", + source="Malitson 1965", + wavelength_range=(0.21e-6, 3.71e-6), + citation="Malitson, JOSA 55, 1205 (1965), doi:10.1364/JOSA.55.001205", + poles=((0.6961663, 0.0684043), (0.4079426, 0.1162414), (0.8974794, 9.896161)), +) + + +def _shell(command: str) -> str: + return subprocess.run( + command, shell=True, capture_output=True, text=True, check=False + ).stdout.strip() + + +def _simulation( + nx: int, steps: int, planes: int, check: BoundaryCheck | None +) -> Simulation: + length = STEP * steps + half = 183e-6 * nx / 384 * 1.1 + grid = EnvelopeGrid.from_limits( + length, 260e-9, (143e-9, 600e-9), 200e-15, thg=False, fft_size="smooth" + ) + positions = [length * (index + 1) / planes for index in range(planes)] + settings = SimulationSettings( + spatial_absorber=False, + controller=PIController(rtol=1e-6, atol=1e-14), + save_planes=positions, + dz_max=length, + dz_init=STEP, + schedule=FixedSchedule.uniform(0.0, length, STEP, save_planes=positions), + integrator=LowStorageRungeKutta(), + backend="native", + boundary_check=check, + fields="none", + held_plane=False, + stats=StatsSpec(names=("energy",)), + ) + return Simulation.from_free( + grid, + FreeGrid(half, nx, half, nx, collar_fraction=0.0), + SpatialMedium(GLASS), + [KerrResponse(chi3=2.2e-22 * MU0 * EPS0 * C**2, thg=False)], + SpatialPulse(GaussianPulse(wavelength=260e-9, fwhm=30e-15, energy=1e-7), 40e-6), + settings=settings, + ) + + +def _profile_summary(profile: cProfile.Profile, top: int = 30) -> dict[str, Any]: + rows, named = [], {} + # `Stats.stats` is pstats' documented table, untyped in its stubs. + table: dict[Any, Any] = pstats.Stats(profile).stats # pyright: ignore[reportAttributeAccessIssue] + for (path, line, function), (_, calls, own, cumulative, _) in table.items(): + where = f"{function}@{Path(path).name}:{line}" + rows.append((own, cumulative, calls, where)) + if any(name in function for name in NAMED): + named[where] = [calls, round(own, 3), round(cumulative, 3)] + rows.sort(reverse=True) + return { + "named": named, + "top_own": [[round(a, 3), round(b, 3), c, d] for a, b, c, d in rows[:top]], + } + + +def _timed(call: Any, repeats: int) -> dict[str, float]: + times, faults = [], 0 + for _ in range(repeats): + before = resource.getrusage(resource.RUSAGE_SELF).ru_minflt + started = time.perf_counter() + value = call() + times.append(time.perf_counter() - started) + faults = resource.getrusage(resource.RUSAGE_SELF).ru_minflt - before + del value + return { + "median_ms": round(1e3 * statistics.median(times), 2), + "first_ms": round(1e3 * times[0], 2), + "minor_faults": faults, + } + + +def _calls(backend: Any) -> dict[str, Any]: + specs = { + name: StatsSpec(names=(name,)) + for name in ( + "energy", + "beam_spectrum", + "absorbed_power", + "peak_intensity", + "beam_radius", + ) + } + return { + "crossings": { + "field_to_host": _timed(backend._native_state.field_to_host, 3), + "observe_field": _timed(lambda: backend.observe(field=True), 3), + "observe_free": _timed(lambda: backend.observe(field=False), 3), + }, + "statistics": { + name: _timed(lambda spec=spec: backend.stats(spec), 5) + for name, spec in specs.items() + }, + } + + +def main(argv: list[str] | None = None) -> None: + """Run the split and print its sections. + + Parameters + ---------- + argv : list of str or None, optional + The arguments, by default the process's. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("nx", type=int, help="transverse samples per axis") + parser.add_argument("--steps", type=int, default=8) + parser.add_argument("--planes", type=int, default=4) + parser.add_argument("--out", type=Path, default=None) + arguments = parser.parse_args(argv) + warnings.simplefilter("ignore") + sink = arguments.out.open("w", encoding="utf-8") if arguments.out else None + + def emit(section: str, **values: Any) -> None: + line = json.dumps({"section": section, "nx": arguments.nx, **values}) + sys.stdout.write(line + "\n") + sys.stdout.flush() + if sink is not None: + sink.write(line + "\n") + sink.flush() + + import solprop_native # noqa: PLC0415 - loaded first by nothing else here + + emit( + "record", + cpu=_shell("lscpu | grep 'Model name' | cut -d: -f2 | xargs"), + cores=_shell("nproc"), + uptime=_shell("uptime"), + machine=platform.machine(), + transparent_hugepages=_shell("cat /sys/kernel/mm/transparent_hugepage/enabled"), + commit=_shell("git rev-parse --short HEAD"), + device=solprop_native.build_info().device_backend, + ) + started = time.perf_counter() + base = _simulation(arguments.nx, arguments.steps, arguments.planes, None) + built = time.perf_counter() + problem = base.translate() + translated = time.perf_counter() + launch = np.array(problem.initial.field0)[0] + emit( + "setup", + from_free_s=round(built - started, 2), + translate_s=round(translated - built, 2), + field_GiB=round(launch.nbytes / 2**30, 3), + shape=list(launch.shape), + ) + relaxed = BoundaryCheck(budget=1.0, transverse_budget=1.0) + for name, check in (("off", None), ("on", relaxed)): + simulation = _simulation(arguments.nx, arguments.steps, arguments.planes, check) + with simulation.runner() as runner: + walls = [] + for point in range(2): + field = launch * np.exp(1j * point) + started = time.perf_counter() + runner.run(field, progress="none", input_identity=f"point-{point}") + walls.append(round(time.perf_counter() - started, 3)) + field = launch * np.exp(0.5j) + profile = cProfile.Profile() + profile.enable() + runner.run(field, progress="none", input_identity="profiled") + profile.disable() + emit("point", check=name, walls_s=walls, **_profile_summary(profile)) + if check is None: + emit("calls", **_calls(runner.backend)) + if sink is not None: + sink.close() + + +if __name__ == "__main__": + main() From e7ec3a47499dca9e390601d665565e2886091b9c Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:22:28 +0000 Subject: [PATCH 006/122] Say how the edge's reads and the occupancy work now Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/check-waste.fixed.md | 1 + docs/dev/backends.md | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 changelog.d/check-waste.fixed.md diff --git a/changelog.d/check-waste.fixed.md b/changelog.d/check-waste.fixed.md new file mode 100644 index 00000000..457a65c7 --- /dev/null +++ b/changelog.d/check-waste.fixed.md @@ -0,0 +1 @@ +The default boundary check no longer does work that nothing uses. A saved plane the step that reached it already read takes no second read, which on a device was a copy of the whole state. A fixed schedule's reads of the transverse edge now follow its next step rather than `dz_init`, which read one step late with a short `dz_init`, past the spacing the reads promise, and at every step with a long one. The split collar's occupancy reduces per column, without an atomic per element, which took most of a monitor reading on a GPU. `energy` and `peak_intensity` no longer sum on one GPU thread, an Adams run over the snapshot limit takes no snapshot to learn its size, and a runner's points share the walk-off table rather than upload it each. diff --git a/docs/dev/backends.md b/docs/dev/backends.md index a2ec086e..a9c5c480 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -246,7 +246,9 @@ adds what each accepted step's split takes of the power it finds, `P (1 − e^{ accumulator the next reading drains, so the removal is exact on unchecked steps too, and keeps the energy and the guards' and collar's energies of that power halfway through the factor, `P e^{−αh/2}`, which the reading returns beside the five (`BoundaryEnergies.found`, -kernel schema 43) and the monitor judges the occupancies on. The +kernel schema 43) and the monitor judges the occupancies on. The split collar's crossing limit +reads the state's energy per walk-off bucket at each check, in the same reduction style: one +histogram per thread and no atomics, which is why its bucket count has a compiled limit. The input-noise floor is read on the host, once, at the launch. ## 2. `numpy_ref`: the oracle @@ -453,15 +455,17 @@ A backend whose `StepDriver.reads_transverse_edge` is set hands the monitor's `TransverseEdge` its observed field at the edge's reads, at saved planes, and with a requested collar at every committed step. The read spacing is a ceiling on the step, and a step is not made to land on a read: the edge is read at the last committed position -from which the next proposal would carry past the next read (`TransverseEdge.due`), so +from which the next step, a replay's scheduled one or else the controller's proposal, would +carry past the next read (`TransverseEdge.due`), so reads stay at most a spacing apart without shortening the steps between them (landing on each read cost 10-35 % more steps on the suite's line and planar cases). Every solprop backend sets it; `numpy_ref` keeps its state on the host and steps from there, under the driver's ceiling, as mlx does from its widened device state. The native Adams march takes the next read and the spacing as the call's `read_at` and `read_spacing` bounds: an adaptive step is capped at the spacing, and the call returns at the committed -position from which the next proposal would pass the read, so the host reads the state +position from which the next step would pass the read, so the host reads the state there. With a collar the host step is one, so the march returns at every committed step. +A saved plane reads the edge unless the step that reached it already did (`TransverseEdge.wants_read`), as a second read at one position records nothing. A native device build reads the edge the same way, copying its state to the host at each read; it is compiled, not qualified on a device. A backend that does not set it reports `unwatched` and refuses a requested collar by name. The edge's reductions run on the From c4e423cfea3bd738c292f98169b29a3120c1534d Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:38:14 +0000 Subject: [PATCH 007/122] Cross a large state through a ring of pinned chunks A copy to fresh pageable memory takes its first-touch page faults inside the driver, one thread at a time: 1.8 GiB/s on an NVIDIA H200 and 7.9 GiB/s on an AMD Instinct MI325X, for a 5.27 GiB state, against about 52 GiB/s pinned. A bare vendor test of this scheme reached 33 and 50 GiB/s into a fresh huge-page buffer. Field, stage, held-plane and Adams-row reads and field uploads now take it above 256 MiB on a device build. Only the first such crossing allocates the ring, so a field-free run holds none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/CMakeLists.txt | 3 +- native/include/solprop_native/core.hpp | 5 +- native/include/solprop_native/crossing.hpp | 81 +++++++++ native/src/bindings.cpp | 42 +++-- native/src/crossing.cpp | 188 +++++++++++++++++++++ native/src/general.cpp | 96 ++++++++--- native/src/solprop_native/_core.pyi | 1 + native/src/solprop_native/backend.py | 3 +- native/tests/native/test_core.cpp | 5 +- native/tests/native/test_crossing.cpp | 80 +++++++++ tests/backends/test_native_gpu.py | 59 +++++++ 11 files changed, 520 insertions(+), 43 deletions(-) create mode 100644 native/include/solprop_native/crossing.hpp create mode 100644 native/src/crossing.cpp create mode 100644 native/tests/native/test_crossing.cpp diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 6e5aeca0..be68d3a4 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -845,7 +845,7 @@ if(SOLPROP_NATIVE_ENABLE_COVERAGE) install(TARGETS solprop_native_labels LIBRARY DESTINATION solprop_native) endif() -add_library(solprop_native_core STATIC src/core.cpp src/general.cpp +add_library(solprop_native_core STATIC src/core.cpp src/crossing.cpp src/general.cpp src/general_modal.cpp src/coupled_flow.cpp src/dispersive.cpp src/far_field.cpp src/adams_march.cpp) # solprop SEP 39: the Adams march's decisions round as solprop's Python does, so their @@ -976,6 +976,7 @@ if(BUILD_TESTING) tests/native/test_adams_march.cpp tests/native/test_adams_phi.cpp tests/native/test_core.cpp + tests/native/test_crossing.cpp tests/native/test_envelope_plasma.cpp tests/native/test_fft.cpp tests/native/test_fp.cpp diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index 7c8b33c3..e9a465e1 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -824,7 +824,7 @@ class GeneralState { /** Release the held plane's volume, for a run that holds none. */ void release_plane(); /** The held plane's state on the host: the one copy a checkpoint takes. */ - [[nodiscard]] std::vector held_to_host() const; + void held_to_host(std::span destination) const; /** The resident Adams history (solprop SEP 16 phase 3b, solprop ADR-043). */ void adams_allocate(std::size_t slots, bool compensated, bool interpolate = true); void adams_compensation_start(std::size_t slot, const std::vector& rate); @@ -941,7 +941,10 @@ class GeneralState { [[nodiscard]] std::vector field_to_host() const; /** The field into `destination`, of the field's size: one copy from the space. */ void field_to_host(std::span destination) const; + /** The threads a large crossing takes (`crossing.hpp`), from solprop's budget. */ + void set_crossing_workers(std::size_t workers) noexcept; [[nodiscard]] std::vector stage_to_host(std::size_t stage) const; + void stage_to_host(std::size_t stage, std::span destination) const; /** * The last evaluation's projected polarisation spectrum on the fine rule, or on the * embedded one (finishing SEP 6's dispersive products there first), a diagnostic. diff --git a/native/include/solprop_native/crossing.hpp b/native/include/solprop_native/crossing.hpp new file mode 100644 index 00000000..f71439ad --- /dev/null +++ b/native/include/solprop_native/crossing.hpp @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 John C. Travers and contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include + +/** A state-sized crossing between a device and the host, and the host memory it lands + in. + + A driver's copy to fresh pageable memory takes its first-touch page faults inside + the copy, one thread at a time: a 5.27 GiB state crossed an NVIDIA H200's PCIe Gen 5 + x16 at 1.8 GiB/s and an AMD Instinct MI325X's link at 7.9 GiB/s, against about 52 + GiB/s pinned on both. So a large crossing goes through a ring of pinned chunks, one + per host thread, each thread moving its chunks between the link and the ordinary + memory, which it touches itself. That reached 33 GiB/s and 50 GiB/s into a fresh + huge-page buffer on those two cards (`docs/dev/benchmarks.md`). + + A pinned mirror of the whole state wouldn't help: a returned array owns its memory, + so the copy out of the mirror takes the same faults, and it would hold another state. + */ +namespace solprop_native::crossing { + +/** A ring chunk, in bytes: a worker's unit of work and its pinned memory. */ +inline constexpr std::size_t chunk_bytes = std::size_t{64} << 20; + +/** The fewest bytes worth a ring. Below this a plain copy is a small part of a step, + and a run that only ever makes small crossings holds no pinned memory. */ +inline constexpr std::size_t ring_threshold = std::size_t{256} << 20; + +/** The most workers a ring takes, in bytes `workers * chunk_bytes` held pinned. */ +inline constexpr std::size_t worker_limit = 24; + +/** Whether a crossing of `bytes` takes a ring on this build: never on a host build, + whose memory space is the host's. */ +[[nodiscard]] bool takes_ring(std::size_t bytes) noexcept; + +/** Frees what `host_buffer` returned. */ +struct BufferDeleter { + void operator()(double* pointer) const noexcept; +}; + +/** Uninitialised doubles for a returned array to own. */ +using HostBuffer = std::unique_ptr; + +/** Return `count` uninitialised doubles for a crossing to land in, aligned for huge + pages and asking for them where the buffer is large enough to use them on a device + build. */ +[[nodiscard]] HostBuffer host_buffer(std::size_t count); + +/** The pinned chunks and the workers that move a crossing through them. Allocated at + the first crossing, so that a run that keeps no field holds nothing. */ +class Ring { +public: + /** Take `workers` threads from the next allocation on, at most `worker_limit`. + Without this the ring asks the scheduler, which an OpenMP runtime's binding can + mislead. */ + void set_workers(std::size_t workers) noexcept; + /** Copy `bytes` of memory-space data to host memory. The caller has fenced. */ + void to_host(void* host, const void* memory, std::size_t bytes); + /** Copy `bytes` of host memory into the memory space. The caller has fenced. */ + void from_host(void* memory, const void* host, std::size_t bytes); + /** The pinned bytes held, for the memory ledger. */ + [[nodiscard]] std::size_t bytes() const noexcept; + /** Free the chunks. */ + void release() noexcept; + +private: + using Chunk = Kokkos::View; + void allocate(); + template void run(std::size_t bytes, const Move& move); + std::vector chunks_; + std::size_t workers_ = 0; +}; + +} // namespace solprop_native::crossing diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index f3884db5..c2b121e2 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -5,6 +5,7 @@ #include "helpers.hpp" #include "solprop_native/adams_march.hpp" #include "solprop_native/core.hpp" +#include "solprop_native/crossing.hpp" #include "solprop_native/fft_plan.hpp" #include "solprop_native/lagrange_basis.hpp" #include "solprop_native/stage_tables.hpp" @@ -815,11 +816,11 @@ ComplexOutput complex_output(const std::vector& values, } /** A complex array owning `storage`, uninitialised doubles a native copy filled. */ -ComplexOutput complex_buffer_output(std::unique_ptr storage, +ComplexOutput complex_buffer_output(native::crossing::HostBuffer storage, std::vector shape) { auto* pointer = storage.release(); nb::capsule owner(pointer, [](void* value) noexcept { - const std::unique_ptr held(static_cast(value)); + const native::crossing::HostBuffer held(static_cast(value)); }); return ComplexOutput(reinterpret_cast*>(pointer), shape.size(), shape.data(), owner); @@ -1376,6 +1377,10 @@ class BoundState { return general_state_->aperture_statistics(flags, apertures, exponent, regions, intensity_factor); } + void set_crossing_workers(const std::size_t workers) noexcept { + if (general_state_) + general_state_->set_crossing_workers(workers); + } void field_to_host(const std::span destination) { require_open(); if (general_state_) @@ -1517,11 +1522,12 @@ class BoundState { fault.after = after; march_fault_ = fault; } - std::vector stage_to_host(const std::size_t stage) { + void stage_to_host(const std::size_t stage, + const std::span destination) { require_open(); if (!general_state_) throw std::logic_error("stage observation requires the general native state"); - return general_state_->stage_to_host(stage); + general_state_->stage_to_host(stage, destination); } std::vector projected_to_host(const bool coarse) { require_open(); @@ -2313,13 +2319,15 @@ NB_MODULE(_core, module_) { .def("held_to_host", [](BoundState& self) { auto& state = self.low_storage_state(); - std::vector values; + const auto count = self.elements(); + auto storage = native::crossing::host_buffer(2 * count); { const nb::gil_scoped_release release; - values = state.held_to_host(); + state.held_to_host( + {reinterpret_cast(storage.get()), count}); } - return complex_output(values, - {self.columns(), values.size() / self.columns()}); + return complex_buffer_output(std::move(storage), + {self.columns(), count / self.columns()}); }) // solprop SEP 16 phase 3b: the Adams history resident in the engine. .def( @@ -2937,11 +2945,17 @@ NB_MODULE(_core, module_) { } return real_output(std::move(values), 1); }) + .def( + "set_crossing_workers", + [](BoundState& self, const std::size_t workers) { + self.set_crossing_workers(workers); + }, + "workers"_a) .def("field_to_host", [](BoundState& self) { // The state crosses once, straight into the returned array's memory. const auto count = self.elements(); - auto storage = std::make_unique_for_overwrite(2 * count); + auto storage = native::crossing::host_buffer(2 * count); { const nb::gil_scoped_release release; self.field_to_host( @@ -2953,13 +2967,15 @@ NB_MODULE(_core, module_) { .def( "stage_to_host", [](BoundState& self, const std::size_t stage) { - std::vector values; + const auto count = self.elements(); + auto storage = native::crossing::host_buffer(2 * count); { const nb::gil_scoped_release release; - values = self.stage_to_host(stage); + self.stage_to_host( + stage, {reinterpret_cast(storage.get()), count}); } - return complex_output(values, - {self.columns(), values.size() / self.columns()}); + return complex_buffer_output(std::move(storage), + {self.columns(), count / self.columns()}); }, "stage"_a) .def( diff --git a/native/src/crossing.cpp b/native/src/crossing.cpp new file mode 100644 index 00000000..73baea40 --- /dev/null +++ b/native/src/crossing.cpp @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2026 John C. Travers and contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "solprop_native/crossing.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#endif + +namespace solprop_native::crossing { +namespace { + +#if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) +constexpr bool device_build = true; +#else +constexpr bool device_build = false; +#endif + +/** The size huge pages come in, which a large buffer is aligned to. */ +constexpr std::size_t huge_page = std::size_t{2} << 20; + +// Kokkos holds its copies to one thread at a time per instance, so the workers call the +// vendor's synchronous copy, which is safe from many threads: it is what was measured. +void device_read(void* destination, const void* source, const std::size_t bytes) { +#if defined(KOKKOS_ENABLE_CUDA) + if (cudaMemcpy(destination, source, bytes, cudaMemcpyDeviceToHost) != cudaSuccess) + throw std::runtime_error("a state's copy to the host failed"); +#elif defined(KOKKOS_ENABLE_HIP) + if (hipMemcpy(destination, const_cast(source), bytes, hipMemcpyDeviceToHost) != + hipSuccess) + throw std::runtime_error("a state's copy to the host failed"); +#else + std::memcpy(destination, source, bytes); +#endif +} + +void device_write(void* destination, const void* source, const std::size_t bytes) { +#if defined(KOKKOS_ENABLE_CUDA) + if (cudaMemcpy(destination, source, bytes, cudaMemcpyHostToDevice) != cudaSuccess) + throw std::runtime_error("a state's copy to the device failed"); +#elif defined(KOKKOS_ENABLE_HIP) + if (hipMemcpy(destination, const_cast(source), bytes, hipMemcpyHostToDevice) != + hipSuccess) + throw std::runtime_error("a state's copy to the device failed"); +#else + std::memcpy(destination, source, bytes); +#endif +} + +/** The device a new thread must select: the choice is per thread on both vendors. */ +int current_device() { +#if defined(KOKKOS_ENABLE_CUDA) + return Kokkos::Cuda{}.cuda_device(); +#elif defined(KOKKOS_ENABLE_HIP) + return Kokkos::HIP{}.hip_device(); +#else + return 0; +#endif +} + +void select_device([[maybe_unused]] const int device) { +#if defined(KOKKOS_ENABLE_CUDA) + if (cudaSetDevice(device) != cudaSuccess) + throw std::runtime_error("a crossing's worker could not select its device"); +#elif defined(KOKKOS_ENABLE_HIP) + if (hipSetDevice(device) != hipSuccess) + throw std::runtime_error("a crossing's worker could not select its device"); +#endif +} + +/** The host threads this process may run on, at most `worker_limit`. */ +std::size_t worker_count() { +#if defined(__linux__) + cpu_set_t allowed; + CPU_ZERO(&allowed); + if (sched_getaffinity(0, sizeof(allowed), &allowed) == 0) + return std::clamp(static_cast(CPU_COUNT(&allowed)), 1, + worker_limit); +#endif + return std::clamp(std::thread::hardware_concurrency(), 1, worker_limit); +} + +} // namespace + +bool takes_ring(const std::size_t bytes) noexcept { + return device_build && bytes >= ring_threshold; +} + +void BufferDeleter::operator()(double* pointer) const noexcept { std::free(pointer); } + +HostBuffer host_buffer(const std::size_t count) { + const std::size_t bytes = count * sizeof(double); +#if defined(__linux__) + if (takes_ring(bytes)) { + const std::size_t rounded = (bytes + huge_page - 1) / huge_page * huge_page; + auto* pointer = static_cast(std::aligned_alloc(huge_page, rounded)); + if (pointer == nullptr) + throw std::bad_alloc{}; + // Advice only: a kernel that refuses it still gives ordinary pages. + static_cast(madvise(pointer, rounded, MADV_HUGEPAGE)); + return HostBuffer(pointer); + } +#endif + auto* pointer = static_cast(std::malloc(std::max(bytes, sizeof(double)))); + if (pointer == nullptr) + throw std::bad_alloc{}; + return HostBuffer(pointer); +} + +void Ring::set_workers(const std::size_t workers) noexcept { + workers_ = std::clamp(workers, 1, worker_limit); +} + +void Ring::allocate() { + if (!chunks_.empty()) + return; + const auto workers = workers_ != 0 ? workers_ : worker_count(); + chunks_.reserve(workers); + for (std::size_t worker = 0; worker < workers; ++worker) + chunks_.emplace_back(Kokkos::view_alloc("solprop_native.crossing.chunk", + Kokkos::WithoutInitializing), + chunk_bytes); +} + +template void Ring::run(const std::size_t bytes, const Move& move) { + allocate(); + const int device = current_device(); + std::atomic cursor{0}; + std::atomic failed{false}; + std::string failure; + std::atomic_flag reporting = ATOMIC_FLAG_INIT; + const auto work = [&](const std::size_t worker) { + try { + select_device(device); + auto* chunk = chunks_[worker].data(); + while (!failed.load(std::memory_order_relaxed)) { + const std::size_t first = cursor.fetch_add(chunk_bytes); + if (first >= bytes) + break; + move(chunk, first, std::min(chunk_bytes, bytes - first)); + } + } catch (const std::exception& error) { + failed.store(true); + if (!reporting.test_and_set()) + failure = error.what(); + } + }; + std::vector workers; + workers.reserve(chunks_.size() - 1); + for (std::size_t worker = 1; worker < chunks_.size(); ++worker) + workers.emplace_back(work, worker); + work(0); + for (auto& worker : workers) + worker.join(); + if (failed.load()) + throw std::runtime_error(failure); +} + +void Ring::to_host(void* host, const void* memory, const std::size_t bytes) { + run(bytes, [&](std::byte* chunk, const std::size_t first, const std::size_t size) { + device_read(chunk, static_cast(memory) + first, size); + std::memcpy(static_cast(host) + first, chunk, size); + }); +} + +void Ring::from_host(void* memory, const void* host, const std::size_t bytes) { + run(bytes, [&](std::byte* chunk, const std::size_t first, const std::size_t size) { + std::memcpy(chunk, static_cast(host) + first, size); + device_write(static_cast(memory) + first, chunk, size); + }); +} + +std::size_t Ring::bytes() const noexcept { return chunks_.size() * chunk_bytes; } + +void Ring::release() noexcept { chunks_.clear(); } + +} // namespace solprop_native::crossing diff --git a/native/src/general.cpp b/native/src/general.cpp index b5c2e02a..5ac30ede 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -5,6 +5,7 @@ #include "solprop_native/adams_phi.hpp" #include "solprop_native/core.hpp" #include "solprop_native/coupled_flow.hpp" +#include "solprop_native/crossing.hpp" #include "solprop_native/dispersive.hpp" #include "solprop_native/envelope_plasma.hpp" #include "solprop_native/far_field.hpp" @@ -860,16 +861,22 @@ void ionisation_rates_within(double* values, const std::size_t live, // A host span as an unmanaged view of a state-shaped memory-space view, for one copy // between them: no staging mirror, no element loop. A span whose data is not aligned // as `Complex` (a NumPy buffer need not be) is staged in an aligned vector first. -void upload_span(const ExecutionSpace& execution_space, const ComplexView2& destination, +void upload_span(const ExecutionSpace& execution_space, crossing::Ring& ring, + const ComplexView2& destination, const std::span source) { if (destination.size() != source.size()) throw std::invalid_argument("fixed problem array has the wrong extent"); if (reinterpret_cast(source.data()) % alignof(Complex) != 0) { const std::vector aligned(source.begin(), source.end()); - upload_span(execution_space, destination, aligned); + upload_span(execution_space, ring, destination, aligned); execution_space.fence("solprop_native unaligned upload"); return; } + if (crossing::takes_ring(source.size_bytes())) { + execution_space.fence("solprop_native upload"); + ring.from_host(destination.data(), source.data(), source.size_bytes()); + return; + } const Kokkos::View> host(source.data(), destination.extent(0), destination.extent(1)); @@ -2094,7 +2101,7 @@ class GeneralWorkspace { double low_storage_step(double dz, const TrialScalars& scalars, bool rebuild_table); void hold_plane(); void release_plane(); - std::vector held_to_host() const; + void held_to_host(std::span destination) const; void low_storage_flows_formed(bool rebuild_table); void low_storage_separable_pass(std::size_t pass, double dz); void low_storage_separable_input(std::size_t pass); @@ -2178,7 +2185,12 @@ class GeneralWorkspace { std::vector monitor_reading(); std::vector field_to_host() const; void field_to_host(std::span destination) const; + void cross_to_host(const ComplexView2& source, std::span destination) const; + void set_crossing_workers(const std::size_t workers) noexcept { + crossing_ring_.set_workers(workers); + } std::vector stage_to_host(std::size_t stage) const; + void stage_to_host(std::size_t stage, std::span destination) const; std::vector projected_to_host(bool coarse); std::map dispersive_path() const; std::map memory_ledger() const; @@ -2877,6 +2889,8 @@ class GeneralWorkspace { // #440); empty unbound. ByteView occupancy_buckets_; std::size_t occupancy_count_ = 0; + // A large crossing's pinned chunks, once one has been made (`crossing.hpp`). + mutable crossing::Ring crossing_ring_; [[nodiscard]] bool monitoring_split() const noexcept { return monitor_bound_ && problem_.split_step_absorber; } @@ -4662,7 +4676,7 @@ void GeneralWorkspace::set_initial(const std::span field, "initial field"); if (!noise.empty()) require_extent(noise.size(), field.size(), "noise"); - upload_span(execution_space_, current_, field); + upload_span(execution_space_, crossing_ring_, current_, field); if (noise.empty()) { if (problem_.modified_noise) graph::deep_copy(execution_space_, noise_, Complex{}); @@ -4671,7 +4685,7 @@ void GeneralWorkspace::set_initial(const std::span field, if (!problem_.modified_noise) throw std::invalid_argument( "a native noise field is valid only for modified noise"); - upload_span(execution_space_, noise_, noise); + upload_span(execution_space_, crossing_ring_, noise_, noise); has_noise_ = true; } mask(current_); @@ -5844,18 +5858,14 @@ void GeneralWorkspace::release_plane() { held_ready_ = false; } -std::vector GeneralWorkspace::held_to_host() const { +void GeneralWorkspace::held_to_host(const std::span destination) const { require_open(); if (!held_ready_) throw std::logic_error("no field-free plane is held"); + require_extent(destination.size(), problem_.n_columns() * problem_.n_omega, + "held field destination"); execution_space_.fence("solprop_native held_to_host"); - const auto host = - Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, held_plane_); - std::vector result(problem_.n_columns() * problem_.n_omega); - for (std::size_t column = 0; column < problem_.n_columns(); ++column) - for (std::size_t index = 0; index < problem_.n_omega; ++index) - result[column * problem_.n_omega + index] = host(column, index); - return result; + cross_to_host(held_plane_, destination); } // --- solprop SEP 16 phase 3b: the Adams history resident in the engine --------------- @@ -6400,6 +6410,10 @@ void GeneralWorkspace::adams_rows_to_host(const std::string& name, throw std::invalid_argument("the Adams history row '" + name + "' has the wrong extent"); execution_space_.fence("solprop_native Adams rows_to_host"); + if (crossing::takes_ring(size * sizeof(Complex))) { + crossing_ring_.to_host(destination, view.data(), size * sizeof(Complex)); + return; + } const auto host = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, view); const auto* source = host.data(); for (std::size_t index = 0; index < size; ++index) @@ -14923,6 +14937,21 @@ std::vector GeneralWorkspace::field_to_host() const { // One copy from the memory space into the caller's buffer: no staging mirror and no // element loop, so a large state crosses once (a device's one transfer). +void GeneralWorkspace::cross_to_host(const ComplexView2& source, + const std::span destination) const { + if (crossing::takes_ring(destination.size_bytes())) { + crossing_ring_.to_host(destination.data(), source.data(), destination.size_bytes()); + return; + } + const Kokkos::View> + host(destination.data(), source.extent(0), source.extent(1)); + // On the stage's instance: a compact problem's team, which the default instance's + // whole-runtime copy would end and restart. + graph::deep_copy(execution_space_, host, source); + execution_space_.fence("solprop_native crossing"); +} + void GeneralWorkspace::field_to_host(const std::span destination) const { require_open(); if (!initialized_) @@ -14930,29 +14959,35 @@ void GeneralWorkspace::field_to_host(const std::span destination) const require_extent(destination.size(), problem_.n_columns() * problem_.n_omega, "field destination"); execution_space_.fence("solprop_native field_to_host"); - const Kokkos::View> - host(destination.data(), problem_.n_columns(), problem_.n_omega); - // On the stage's instance: a compact problem's team, which the default instance's - // whole-runtime copy would end and restart. - graph::deep_copy(execution_space_, host, current_); - execution_space_.fence("solprop_native field_to_host copy"); + cross_to_host(current_, destination); } std::vector GeneralWorkspace::stage_to_host(const std::size_t stage) const { + std::vector result(problem_.n_columns() * problem_.n_omega); + stage_to_host(stage, result); + return result; +} + +void GeneralWorkspace::stage_to_host(const std::size_t stage, + const std::span destination) const { require_open(); if (!stages_ready_) throw std::logic_error("no trial stages have been evaluated"); if (stage >= problem_.n_stages) throw std::out_of_range("stage index is outside the tableau"); + require_extent(destination.size(), problem_.n_columns() * problem_.n_omega, + "stage destination"); execution_space_.fence("solprop_native stage_to_host"); const auto selected = Kokkos::subview(stages_, stage, Kokkos::ALL, Kokkos::ALL); + if (crossing::takes_ring(destination.size_bytes())) { + crossing_ring_.to_host(destination.data(), selected.data(), + destination.size_bytes()); + return; + } const auto host = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, selected); - std::vector result(problem_.n_columns() * problem_.n_omega); for (std::size_t column = 0; column < problem_.n_columns(); ++column) for (std::size_t index = 0; index < problem_.n_omega; ++index) - result[column * problem_.n_omega + index] = host(column, index); - return result; + destination[column * problem_.n_omega + index] = host(column, index); } std::vector GeneralWorkspace::projected_to_host(const bool coarse) { @@ -15263,6 +15298,7 @@ std::map GeneralWorkspace::base_memory_ledger() const occupancy_buckets_.size() + (monitor_split_sum_.size() + monitor_found_.size()) * sizeof(SplitSums)}, {"statistics_flux_magnitude", statistics_magnitude_.size() * real_bytes}, + {"host_crossing_ring", crossing_ring_.bytes()}, {"statistics_outputs", (statistics_total_power_.size() + statistics_on_axis_.size() + statistics_packed_.size()) * real_bytes}, @@ -15504,6 +15540,7 @@ void GeneralWorkspace::close() noexcept { monitor_found_ = {}; occupancy_buckets_ = {}; occupancy_count_ = 0; + crossing_ring_.release(); tableau_ = {}; omega_window_ = {}; omega_ = {}; @@ -15618,9 +15655,9 @@ void GeneralState::hold_plane() { workspace_->hold_plane(); } void GeneralState::release_plane() { workspace_->release_plane(); } -std::vector GeneralState::held_to_host() const { +void GeneralState::held_to_host(const std::span destination) const { const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->held_to_host(); + workspace_->held_to_host(destination); } void GeneralState::adams_allocate(const std::size_t slots, const bool compensated, const bool interpolate) { @@ -15849,10 +15886,19 @@ std::vector GeneralState::field_to_host() const { const CpuTeamScope team(workspace_->cpu_team()); return workspace_->field_to_host(); } +void GeneralState::set_crossing_workers(const std::size_t workers) noexcept { + if (workspace_) + workspace_->set_crossing_workers(workers); +} void GeneralState::field_to_host(const std::span destination) const { const CpuTeamScope team(workspace_->cpu_team()); workspace_->field_to_host(destination); } +void GeneralState::stage_to_host(const std::size_t stage, + const std::span destination) const { + const CpuTeamScope team(workspace_->cpu_team()); + workspace_->stage_to_host(stage, destination); +} std::vector GeneralState::stage_to_host(const std::size_t stage) const { const CpuTeamScope team(workspace_->cpu_team()); return workspace_->stage_to_host(stage); diff --git a/native/src/solprop_native/_core.pyi b/native/src/solprop_native/_core.pyi index 5e84c2a0..554d919b 100644 --- a/native/src/solprop_native/_core.pyi +++ b/native/src/solprop_native/_core.pyi @@ -285,6 +285,7 @@ class State: def bind_occupancy(self, buckets: npt.NDArray[np.uint8], count: int) -> None: ... def monitor_reading(self) -> npt.NDArray[np.float64]: ... def field_to_host(self) -> npt.NDArray[np.complex128]: ... + def set_crossing_workers(self, workers: int) -> None: ... def stage_to_host(self, stage: int) -> npt.NDArray[np.complex128]: ... def projected_to_host(self, coarse: bool = False) -> npt.NDArray[np.complex128]: ... @property diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index c8be1567..2bf5050d 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -23,7 +23,7 @@ import numpy.typing as npt from solprop._helpers import Helpers from solprop._interrupts import committing -from solprop._process import acquire_host_blas +from solprop._process import acquire_host_blas, thread_budget from solprop.backends.driver import StepDriver, reached, replay_tolerance from solprop.backends.exponential import ( CarrierReference, @@ -334,6 +334,7 @@ def __init__( _native_problem(problem, self._terms, self._flow, self._z_tables) | _low_storage_payload(problem, device=_on_device()) ) + self._native_state.set_crossing_workers(thread_budget().threads) _wisdom.store(wisdom) self._wisdom_digest: str | None = None if self._z_tables is not None: diff --git a/native/tests/native/test_core.cpp b/native/tests/native/test_core.cpp index 12798b2c..2d51cce7 100644 --- a/native/tests/native/test_core.cpp +++ b/native/tests/native/test_core.cpp @@ -2612,11 +2612,12 @@ TEST(GeneralState, LowStoragePassesMatchTheHostInteractionPictureStep) { "weighted_residual", "recycled_stage", "stage_inverse", "held_plane"}) EXPECT_EQ(ledger.at(row), 0U) << row; // The field-free plane held in the memory space, one volume, copied back once. - EXPECT_THROW(static_cast(state.held_to_host()), std::logic_error); + EXPECT_THROW(state.held_to_host(std::span{}), std::logic_error); state.hold_plane(); EXPECT_EQ(state.memory_ledger().at("held_plane"), field); - const auto held = state.held_to_host(); const auto current = state.field_to_host(); + std::vector held(current.size()); + state.held_to_host(held); for (std::size_t index = 0; index < held.size(); ++index) EXPECT_EQ(held[index], current[index]); } diff --git a/native/tests/native/test_crossing.cpp b/native/tests/native/test_crossing.cpp new file mode 100644 index 00000000..46e92eae --- /dev/null +++ b/native/tests/native/test_crossing.cpp @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 John C. Travers and contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "solprop_native/crossing.hpp" + +#include + +#include +#include +#include +#include + +namespace { + +using solprop_native::crossing::chunk_bytes; +using solprop_native::crossing::host_buffer; +using solprop_native::crossing::Ring; +using solprop_native::crossing::ring_threshold; +using solprop_native::crossing::takes_ring; + +std::vector counting(const std::size_t size) { + std::vector values(size); + std::iota(values.begin(), values.end(), std::uint8_t{1}); + return values; +} + +// A lossless copy is an exact structural contract (SEP 25), so these compare bits. On a +// host build the workers' copies are plain ones; a device's run in the device tests. +class RingSizes : public ::testing::TestWithParam {}; + +TEST_P(RingSizes, CrossesEveryByteBothWays) { + const auto source = counting(GetParam()); + std::vector down(source.size(), 0), up(source.size(), 0); + Ring ring; + ring.to_host(down.data(), source.data(), source.size()); + ring.from_host(up.data(), down.data(), down.size()); + EXPECT_EQ(down, source); + EXPECT_EQ(up, source); +} + +INSTANTIATE_TEST_SUITE_P(AroundAChunk, RingSizes, + ::testing::Values(std::size_t{1}, chunk_bytes - 1, chunk_bytes, + chunk_bytes + 1, 3 * chunk_bytes + 17)); + +TEST(Ring, HoldsNothingUntilItsFirstCrossingAndAfterItsRelease) { + Ring ring; + EXPECT_EQ(ring.bytes(), 0U); + const auto source = counting(16); + std::vector copy(16); + ring.to_host(copy.data(), source.data(), source.size()); + const auto held = ring.bytes(); + EXPECT_GE(held, chunk_bytes); + EXPECT_EQ(held % chunk_bytes, 0U); + ring.to_host(copy.data(), source.data(), source.size()); + EXPECT_EQ(ring.bytes(), held); + ring.release(); + EXPECT_EQ(ring.bytes(), 0U); +} + +TEST(Ring, TakesNoTurnOnAHostBuildOrBelowItsThreshold) { + EXPECT_FALSE(takes_ring(ring_threshold - 1)); +#if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) + EXPECT_TRUE(takes_ring(ring_threshold)); +#else + EXPECT_FALSE(takes_ring(ring_threshold)); +#endif +} + +TEST(HostBuffer, GivesWritableMemoryOfEitherSize) { + for (const std::size_t count : {std::size_t{1}, ring_threshold / sizeof(double)}) { + auto buffer = host_buffer(count); + ASSERT_NE(buffer.get(), nullptr); + buffer[0] = 1.0; + buffer[count - 1] = 2.0; + EXPECT_EQ(buffer[count - 1], 2.0); + } +} + +} // namespace diff --git a/tests/backends/test_native_gpu.py b/tests/backends/test_native_gpu.py index 4c83c1dc..db74ccac 100644 --- a/tests/backends/test_native_gpu.py +++ b/tests/backends/test_native_gpu.py @@ -4,11 +4,15 @@ """Focused CUDA/HIP bring-up checks for the optional native backend.""" +from typing import Any, cast + import numpy as np import pytest +from benchmarks.device_split import _simulation from bounds import assert_within, ladder_bound from solprop.backends.numpy_ref import NumpyRefBackend +from solprop.backends.registry import select from solprop.stepper import FixedSchedule pytestmark = pytest.mark.gpu @@ -80,3 +84,58 @@ def test_same_card_launch_geometry_is_repeatable( assert first_summary.n_rejected == second_summary.n_rejected bound = ladder_bound("native", 16 * np.finfo(float).eps, soliton_grid.n_to, 7) assert_within(second, first, bound, what="repeat launch") + + +def test_a_large_state_crosses_both_ways_through_the_ring_bit_for_bit( + accelerator_backend, +): + # The ring needs 256 MiB; 192^2 x 600 is 354 MiB. A lossless copy is exact (SEP 25). + del accelerator_backend + simulation = _simulation(192, 2, 1, None) + problem = simulation.translate() + engine = cast( + "Any", + select( + "native", + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ), + ) + try: + rng = np.random.default_rng(53) + shape = problem.shape + field = rng.standard_normal(shape) + 1j * rng.standard_normal(shape) + assert field.nbytes >= 2**28 + assert engine.memory_ledger()["host_crossing_ring"] == 0 + engine.set_initial(field, None, z=problem.initial.z) + assert engine.memory_ledger()["host_crossing_ring"] > 0 + back = engine._native_state.field_to_host().reshape(shape) + assert back.tobytes() == field.tobytes() + observed = engine.observe(field=True).require_field() + assert observed.tobytes() == field.tobytes() + finally: + engine.close() + + +def test_a_small_state_holds_no_pinned_ring(accelerator_backend): + del accelerator_backend + simulation = _simulation(32, 2, 1, None) + problem = simulation.translate() + engine = cast( + "Any", + select( + "native", + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ), + ) + try: + engine.set_initial(problem.initial.field0, None, z=problem.initial.z) + engine.observe(field=True) + assert engine.memory_ledger()["host_crossing_ring"] == 0 + finally: + engine.close() From eb067a2a6b17db51130feda86d03894e50df2a10 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:38:14 +0000 Subject: [PATCH 008/122] Check an observed state's finiteness with a threaded sum A mask of the whole state, on one thread, took twice the copy of it on a CPU and 0.73 s at 768 x 768 x 600 on an NVIDIA H200's host. A sum holding a NaN or an infinity isn't finite, so only one that overflows needs a second look. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/_process.py | 36 ++++++++++++++++++++++++- src/solprop/backends/protocol.py | 34 ++++++++++++++++++++++- tests/backends/test_backend_protocol.py | 28 +++++++++++++++++++ tests/test_process.py | 21 +++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/solprop/_process.py b/src/solprop/_process.py index 48d0fd2f..1689be4a 100644 --- a/src/solprop/_process.py +++ b/src/solprop/_process.py @@ -16,12 +16,14 @@ import contextlib import functools +import itertools import os import platform import sys import threading import warnings -from collections.abc import Iterator, Sequence +from collections.abc import Callable, Iterator, Sequence +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path from typing import Any @@ -615,6 +617,38 @@ def thread_count(name: str) -> int | None: return count +PARALLEL_BYTES = 2**26 +"""The fewest bytes a host pass over a state threads (:func:`parallel_rows`).""" + + +def parallel_rows[T](n_rows: int, nbytes: int, work: Callable[[slice], T]) -> list[T]: + """Run `work` over slices covering `n_rows` rows, in threads if it is worth it. + + For a NumPy pass over a state too large for one thread's page faults and memory + bandwidth: ufuncs release the GIL, and a state's rows are independent. + + Parameters + ---------- + n_rows : int + The rows to cover; positive. + nbytes : int + The bytes the pass reads. Below :data:`PARALLEL_BYTES` one call takes every row. + work : callable + Called with each slice of rows; it must touch nothing the others do. + + Returns + ------- + list + Each call's result, in row order. + """ + workers = min(thread_budget().threads, n_rows) + if nbytes < PARALLEL_BYTES or workers <= 1: + return [work(slice(0, n_rows))] + edges = [n_rows * index // workers for index in range(workers + 1)] + with ThreadPoolExecutor(workers) as pool: + return list(pool.map(work, (slice(a, b) for a, b in itertools.pairwise(edges)))) + + def thread_budget() -> ThreadBudget: """Return how many threads this process may run at once (ADR-052). diff --git a/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py index ed481b83..0698a8a5 100644 --- a/src/solprop/backends/protocol.py +++ b/src/solprop/backends/protocol.py @@ -34,6 +34,7 @@ import numpy as np from jaxtyping import Bool, Complex, Float, Int +from solprop._process import parallel_rows from solprop.exceptions import BackendError, BoundaryError, BoundaryWarning from solprop.log import ProgressReporter from solprop.monitor import BoundaryMonitor @@ -316,6 +317,37 @@ def names(self) -> tuple[str, ...]: return tuple(sorted(self.values)) +def _all_finite(state: Complex[np.ndarray, "*shape n_omega"]) -> bool: + """Return whether every entry of a complex state is finite, allocating no mask. + + A sum holding a NaN or an infinity isn't finite, so a finite sum settles it in one + pass over the rows. Only a sum that overflows takes the second look, which allows + for finite entries too large to add. + + Parameters + ---------- + state : numpy.ndarray + A contiguous complex state. + + Returns + ------- + bool + True when no entry is a NaN or an infinity. + """ + if state.size == 0: + return True + rows = state.reshape(-1, state.shape[-1]) + + def finite(selected: slice) -> bool: + part = rows[selected] + # Per thread: a sum that overflows is what the second look is for. + with np.errstate(over="ignore", invalid="ignore"): + total = np.sum(part) + return bool(np.isfinite(total)) or bool(np.all(np.isfinite(part))) + + return all(parallel_rows(rows.shape[0], state.nbytes, finite)) + + @dataclass(frozen=True, eq=False) class Observation: """The state at a save plane, on the host. @@ -394,7 +426,7 @@ def __post_init__(self) -> None: state = np.ascontiguousarray(self.field, dtype=np.complex128) state.setflags(write=False) object.__setattr__(self, "field", state) - if not np.all(np.isfinite(state)): + if not _all_finite(state): bad = int(np.sum(~np.isfinite(state))) msg = ( f"field has {bad} of {state.size} entries that are not finite at " diff --git a/tests/backends/test_backend_protocol.py b/tests/backends/test_backend_protocol.py index 546000da..ef4cc0f1 100644 --- a/tests/backends/test_backend_protocol.py +++ b/tests/backends/test_backend_protocol.py @@ -2,9 +2,12 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +from types import SimpleNamespace + import numpy as np import pytest +from solprop import _process from solprop.backends import ( Backend, BackendHistory, @@ -151,6 +154,31 @@ def test_a_diverged_field_is_refused_rather_than_observed(): Observation(0.1, spoiled, None, history(1), 1, 0) +@pytest.mark.parametrize( + "bad", + [np.nan, np.inf, -np.inf, complex(0.0, np.inf), complex(np.nan, 1.0)], + ids=["nan", "inf", "minus-inf", "imaginary-inf", "real-nan"], +) +@pytest.mark.parametrize("threaded", [False, True], ids=["one-call", "threads"]) +def test_any_non_finite_entry_in_any_row_is_found(bad, threaded, monkeypatch): + if threaded: + monkeypatch.setattr(_process, "PARALLEL_BYTES", 0) + monkeypatch.setattr( + _process, "thread_budget", lambda: SimpleNamespace(threads=3) + ) + spoiled = np.ones((1, 8, 1, 4), complex) + spoiled[0, 7, 0, 3] = bad + with pytest.raises(BackendError, match="1 of 32 entries"): + Observation(0.1, spoiled, None, history(1), 1, 0) + + +def test_finite_entries_too_large_to_add_are_still_finite(): + # Their sum overflows, which alone doesn't make an entry infinite. + huge = np.full((1, 2, 1, 4), 1e308 + 0j) + plane = Observation(0.1, huge, None, history(1), 1, 0) + assert plane.require_field().shape == huge.shape + + def test_a_noise_phase_that_does_not_match_the_field_is_refused(): with pytest.raises(BackendError, match="noise_phase has shape"): Observation( diff --git a/tests/test_process.py b/tests/test_process.py index 572f2cea..4b71fb80 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -8,6 +8,7 @@ import sys import warnings from pathlib import Path +from types import SimpleNamespace import pytest @@ -496,3 +497,23 @@ def test_a_small_load_time_blas_width_is_fine_elsewhere(monkeypatch): with warnings.catch_warnings(): warnings.simplefilter("error") _process._warn_of_load_time_blas_width() + + +def test_parallel_rows_covers_each_row_once_in_threads_when_it_pays(monkeypatch): + monkeypatch.setattr(_process, "PARALLEL_BYTES", 100) + monkeypatch.setattr(_process, "thread_budget", lambda: SimpleNamespace(threads=3)) + slices = _process.parallel_rows(10, 100, lambda part: part) + assert slices == [slice(0, 3), slice(3, 6), slice(6, 10)] + # Fewer rows than threads: a thread per row, and none left idle. + assert _process.parallel_rows(2, 100, lambda part: part) == [ + slice(0, 1), + slice(1, 2), + ] + + +def test_parallel_rows_takes_one_call_when_threads_would_not_pay(monkeypatch): + monkeypatch.setattr(_process, "PARALLEL_BYTES", 100) + monkeypatch.setattr(_process, "thread_budget", lambda: SimpleNamespace(threads=3)) + assert _process.parallel_rows(10, 99, lambda part: part) == [slice(0, 10)] + monkeypatch.setattr(_process, "thread_budget", lambda: SimpleNamespace(threads=1)) + assert _process.parallel_rows(10, 100, lambda part: part) == [slice(0, 10)] From 34a5ed5e86c513280cd6ab57586ce5b6c0a44a4f Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:38:14 +0000 Subject: [PATCH 009/122] Convert a kept plane in threads, straight into the store spectrum_to_optics allocated two state-sized arrays and took most of a saved plane once its crossing was fast. An in-memory store now converts into its own row, and its copy goes. Stored bits are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/fourier.py | 19 ++++++++-- src/solprop/output/memory.py | 58 ++++++++++++++++++++++++------ src/solprop/output/schema.py | 66 +++++++++++++++++++++++++++++++++-- tests/output/test_handlers.py | 45 +++++++++++++++++++++++- tests/test_fourier.py | 12 +++++++ 5 files changed, 183 insertions(+), 17 deletions(-) diff --git a/src/solprop/fourier.py b/src/solprop/fourier.py index c5beba97..0a70a328 100644 --- a/src/solprop/fourier.py +++ b/src/solprop/fourier.py @@ -469,7 +469,10 @@ def _origin_shift(n_omega: int) -> Float[np.ndarray, " n_omega"]: def spectrum_to_optics( - x_omega: Num[np.ndarray, "*batch n_omega"], *, kind: GridKind + x_omega: Num[np.ndarray, "*batch n_omega"], + *, + kind: GridKind, + out: Num[np.ndarray, "*batch n_omega"] | None = None, ) -> Num[np.ndarray, "*batch n_omega"]: """Convert an internal spectrum to the optics convention about the window centre. @@ -499,6 +502,9 @@ def spectrum_to_optics( Stored spectrum (raw forward DFT), shape ``(..., n_omega)``. kind : {'field', 'envelope'} Spectral layout of ``x_omega``. + out : numpy.ndarray or None, optional + Where to write the result, complex and the shape of `x_omega`; a new array by + default. For a store's room, or for rows converted separately. Returns ------- @@ -525,7 +531,16 @@ def spectrum_to_optics( """ values = np.asarray(x_omega) _check_spectrum(values, kind) - return _origin_shift(values.shape[-1]) * to_optics(values) + shift = _origin_shift(values.shape[-1]) + if out is not None: + # The ufunc's own conjugation (`to_optics`), writing where it's told. + np.conj(values, out=out) + return np.multiply(shift, out, out=out) + conjugated = to_optics(values) + if np.iscomplexobj(conjugated): + # One state-sized allocation rather than two. + return np.multiply(shift, conjugated, out=conjugated) + return shift * conjugated def spectrum_to_internal( diff --git a/src/solprop/output/memory.py b/src/solprop/output/memory.py index c8cef4d8..13a1db91 100644 --- a/src/solprop/output/memory.py +++ b/src/solprop/output/memory.py @@ -32,7 +32,7 @@ from jaxtyping import Complex from solprop.exceptions import OutputError -from solprop.fourier import spectrum_to_internal +from solprop.fourier import GridKind, spectrum_to_internal from solprop.output.schema import ( HISTORY_FIELDS, STEP_STATS_INDEX, @@ -41,6 +41,7 @@ SavedPlane, StepHistory, StepStats, + stored_spectrum, ) __all__ = ["INITIAL_BLOCK", "MemoryOutput"] @@ -286,6 +287,46 @@ def _expand(self) -> None: self._capacity *= 2 logger.debug("memory output grew to room for %d planes", self._capacity) + def _make_room(self, plane: SavedPlane) -> None: + """Grow the blocks so that they have room for the next plane. + + Parameters + ---------- + plane : SavedPlane + The next plane. The base class has checked it against the run's layout. + """ + if self._capacity == 0: + self._allocate(plane, plane.field) + elif self._n_saved == self._capacity: + self._expand() + if ( + plane.field is not None + and self._n_fields == 0 + and not self._fields.shape[0] + ): + self._allocate_fields(plane, plane.field) + + def _stored_field( + self, plane: SavedPlane, kind: GridKind + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Convert a plane's field straight into its room in the block. + + Parameters + ---------- + plane : SavedPlane + The plane, which carries a field. + kind : {'field', 'envelope'} + The grid's spectral layout. + + Returns + ------- + numpy.ndarray + The block's row that now holds it. + """ + assert plane.field is not None + self._make_room(plane) + return stored_spectrum(plane.field, kind, self._fields[self._n_fields]) + def _append( self, plane: SavedPlane, @@ -298,20 +339,15 @@ def _append( plane : SavedPlane The plane, whose field is still in the internal convention. stored : numpy.ndarray or None - That field converted to the optics convention; ``None`` on a field-free - plane, which stores none. + That field converted to the optics convention, normally its row of the + block already; ``None`` on a field-free plane, which stores none. """ - # The base class has already checked the plane against the run's layout. - if self._capacity == 0: - self._allocate(plane, stored) - elif self._n_saved == self._capacity: - self._expand() + self._make_room(plane) index = self._n_saved self._z[index] = plane.z if stored is not None: - if self._n_fields == 0 and self._fields.shape[0] == 0: - self._allocate_fields(plane, stored) - self._fields[self._n_fields] = stored + if stored.base is not self._fields: + self._fields[self._n_fields] = stored if self._noise_phase is not None: self._noise_phase[self._n_fields] = plane.noise_phase self._n_fields += 1 diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py index f5c94b3e..4a18b1b4 100644 --- a/src/solprop/output/schema.py +++ b/src/solprop/output/schema.py @@ -69,6 +69,7 @@ from solprop import fourier from solprop._display import Displayed, engineering, line, summarise +from solprop._process import parallel_rows from solprop.exceptions import MaterialError, OutputError from solprop.grid import EnvelopeGrid, TimeGrid from solprop.modes.material import JsonValue @@ -126,6 +127,7 @@ "refuse_earlier_absorbed_power", "resolve_metadata_write", "serialise", + "stored_spectrum", ] logger = logging.getLogger(__name__) @@ -4226,6 +4228,41 @@ def resolved_sha256(self) -> str: return hashlib.sha256(self.resolved.encode("utf-8")).hexdigest() +def stored_spectrum( + field: Complex[np.ndarray, "*shape n_omega"], + kind: fourier.GridKind, + out: Complex[np.ndarray, "*shape n_omega"] | None = None, +) -> Complex[np.ndarray, "*shape n_omega"]: + """Return :func:`solprop.fourier.spectrum_to_optics` of a large field, in threads. + + One thread's conjugation and sign flip cost more than the state's whole crossing + from a device, and a state's rows are independent. The bits are the same. + + Parameters + ---------- + field : numpy.ndarray + A contiguous spectrum in the internal convention. + kind : {'field', 'envelope'} + The grid's spectral layout. + out : numpy.ndarray or None, optional + Contiguous room to write it to; a new array by default. + + Returns + ------- + numpy.ndarray + `out`, holding the spectrum in the optics convention. + """ + target = np.empty_like(field) if out is None else out + rows = field.reshape(-1, field.shape[-1]) + written = target.reshape(-1, field.shape[-1]) + + def convert(selected: slice) -> None: + fourier.spectrum_to_optics(rows[selected], kind=kind, out=written[selected]) + + parallel_rows(rows.shape[0], field.nbytes, convert) + return target + + class PlaneStore(Displayed, ABC): """The shared half of an output handler: one save protocol for every backing store. @@ -4715,12 +4752,12 @@ def save(self, plane: SavedPlane) -> None: metadata = self._require_metadata() if self._checkpoint_pending and self._terminal_checkpoint is not None: plane = _prepend_checkpoint_history(self._terminal_checkpoint.plane, plane) + self._check_layout(plane, plane.field) stored = ( None if plane.field is None - else fourier.spectrum_to_optics(plane.field, kind=metadata.grid.kind) + else self._stored_field(plane, metadata.grid.kind) ) - self._check_layout(plane, stored) self._append(plane, stored) self._n_saved += 1 if self._terminal_checkpoint is not None: @@ -5046,6 +5083,28 @@ def __contains__(self, key: object) -> bool: return False return True + def _stored_field( + self, plane: SavedPlane, kind: fourier.GridKind + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return a plane's field as it is stored, in the optics convention. + + A handler with room for it already converts it there (`MemoryOutput`). + + Parameters + ---------- + plane : SavedPlane + The plane, which carries a field. + kind : {'field', 'envelope'} + The grid's spectral layout. + + Returns + ------- + numpy.ndarray + The converted field. + """ + assert plane.field is not None + return stored_spectrum(plane.field, kind) + def _check_layout( self, plane: SavedPlane, @@ -5058,7 +5117,8 @@ def _check_layout( plane : SavedPlane The plane being appended. stored : numpy.ndarray or None - Its field in the optics convention; ``None`` on a field-free plane. + Its field, in any convention, as only its shape is read; ``None`` on a + field-free plane. Raises ------ diff --git a/tests/output/test_handlers.py b/tests/output/test_handlers.py index a7f91070..9e982b0e 100644 --- a/tests/output/test_handlers.py +++ b/tests/output/test_handlers.py @@ -9,13 +9,14 @@ import signal import subprocess import sys +from types import SimpleNamespace from typing import Any import h5py import numpy as np import pytest -from solprop import fourier +from solprop import _process, fourier from solprop.backends.registry import REMOVED_BACKENDS from solprop.exceptions import OutputError from solprop.grid import EnvelopeGrid, FieldGrid @@ -168,6 +169,48 @@ def test_both_handlers_satisfy_the_protocol(factory, tmp_path): assert (handler.n_saved, handler.metadata, handler.closed) == (0, None, False) +def test_a_store_converts_a_plane_in_threads_to_the_same_bits( + grid, metadata, monkeypatch +): + plane = make_plane(grid, 0) + expected = fourier.spectrum_to_optics(plane.field, kind=grid.kind) + monkeypatch.setattr(_process, "PARALLEL_BYTES", 0) + monkeypatch.setattr(_process, "thread_budget", lambda: SimpleNamespace(threads=3)) + handler = MemoryOutput() + handler.open(metadata) + handler.save(plane) + # A lossless structural contract: only the order of the rows' work differs. + assert handler["field"][0].tobytes() == expected.tobytes() + handler.close() + + +def test_a_store_keeps_its_own_copy_of_a_plane(grid, metadata): + plane = make_plane(grid, 0) + expected = fourier.spectrum_to_optics(plane.field, kind=grid.kind) + handler = MemoryOutput() + handler.open(metadata) + handler.save(plane) + # A run goes on stepping the state an observation may have been a view of. + assert not np.shares_memory(handler["field"], plane.field) + assert np.array_equal(handler["field"][0], expected) + handler.close() + + +def test_a_store_grows_past_its_block_with_every_plane_intact(grid, metadata): + # Each plane's room is taken before it is converted, so growing must come first. + planes = [make_plane(grid, index) for index in range(5)] + handler = MemoryOutput(block=1) + handler.open(metadata) + for plane in planes: + handler.save(plane) + for index, plane in enumerate(planes): + assert np.array_equal( + handler["field"][index], + fourier.spectrum_to_optics(plane.field, kind=grid.kind), + ) + handler.close() + + def test_the_two_handlers_store_the_same_planes_bitwise(grid, metadata, tmp_path): memory = MemoryOutput() file = HDF5Output(tmp_path / "run.h5") diff --git a/tests/test_fourier.py b/tests/test_fourier.py index 4a6fd2b1..64f81186 100644 --- a/tests/test_fourier.py +++ b/tests/test_fourier.py @@ -86,6 +86,18 @@ def test_the_spectrum_helpers_add_the_alternating_sign_to_the_conjugation(kind): ) +@pytest.mark.parametrize("kind", KINDS) +def test_a_spectrum_converted_into_room_is_the_same_bits(kind): + spectrum = make_spectrum(kind, 16, seed=5) + room = np.empty_like(spectrum) + written = fourier.spectrum_to_optics(spectrum, kind=kind, out=room) + assert written is room + # A lossless structural contract: the same two ufuncs on the same numbers. + assert room.tobytes() == fourier.spectrum_to_optics(spectrum, kind=kind).tobytes() + sign = np.array([(-1.0) ** k for k in range(spectrum.size)]) + assert room.tobytes() == (sign * fourier.to_optics(spectrum)).tobytes() + + @pytest.mark.parametrize("kind", KINDS) def test_a_flat_optics_spectrum_is_a_pulse_at_the_window_centre(kind): # The whole point of the origin shift: a spectrum that is flat and real in the From 1e5d2fc373dcbc9a304becee9220a22b5dad1079 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:55:40 +0000 Subject: [PATCH 010/122] Keep the ring's device tests to the ring, and note the change Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/host-crossing.fixed.md | 1 + tests/backends/test_native_gpu.py | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 changelog.d/host-crossing.fixed.md diff --git a/changelog.d/host-crossing.fixed.md b/changelog.d/host-crossing.fixed.md new file mode 100644 index 00000000..25df1374 --- /dev/null +++ b/changelog.d/host-crossing.fixed.md @@ -0,0 +1 @@ +On a GPU build, a large state now crosses to and from the host through a ring of pinned chunks into huge-page memory, rather than taking its page faults one thread at a time inside the driver's copy. In a bare copy of a 5.27 GiB state that scheme reached 33 GiB/s against 1.8 GiB/s on an NVIDIA H200, and 50 GiB/s against 7.9 GiB/s on an AMD Instinct MI325X. A run that keeps no field allocates no ring, and the ring is listed in `memory_ledger` as `host_crossing_ring`. A saved plane's finiteness check and its conversion to the stored convention now run on the process's threads, and `MemoryOutput` converts straight into its own block rather than copying. Stored fields are unchanged to the bit. diff --git a/tests/backends/test_native_gpu.py b/tests/backends/test_native_gpu.py index db74ccac..f930f5b2 100644 --- a/tests/backends/test_native_gpu.py +++ b/tests/backends/test_native_gpu.py @@ -86,6 +86,8 @@ def test_same_card_launch_geometry_is_repeatable( assert_within(second, first, bound, what="repeat launch") +# The probe's band reaches below the glass's tabulated range, which isn't the point here. +@pytest.mark.filterwarnings("ignore::solprop.exceptions.MaterialRangeWarning") def test_a_large_state_crosses_both_ways_through_the_ring_bit_for_bit( accelerator_backend, ): @@ -119,6 +121,7 @@ def test_a_large_state_crosses_both_ways_through_the_ring_bit_for_bit( engine.close() +@pytest.mark.filterwarnings("ignore::solprop.exceptions.MaterialRangeWarning") def test_a_small_state_holds_no_pinned_ring(accelerator_backend): del accelerator_backend simulation = _simulation(32, 2, 1, None) From 0b64ad02d13c53a6e87cb6c5826e7a3a8c506464 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 12:56:21 +0000 Subject: [PATCH 011/122] Fit the comment to the line limit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/backends/test_native_gpu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/backends/test_native_gpu.py b/tests/backends/test_native_gpu.py index f930f5b2..c5e8cecf 100644 --- a/tests/backends/test_native_gpu.py +++ b/tests/backends/test_native_gpu.py @@ -86,7 +86,7 @@ def test_same_card_launch_geometry_is_repeatable( assert_within(second, first, bound, what="repeat launch") -# The probe's band reaches below the glass's tabulated range, which isn't the point here. +# The probe's band reaches below the glass's tabulated range: not the point here. @pytest.mark.filterwarnings("ignore::solprop.exceptions.MaterialRangeWarning") def test_a_large_state_crosses_both_ways_through_the_ring_bit_for_bit( accelerator_backend, From 19a86e56a721703a2044a32631e200935f49f295 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 13:05:20 +0000 Subject: [PATCH 012/122] Give the keeper's failure test the size query it now reads Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/output/test_snapshot_failures.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/output/test_snapshot_failures.py b/tests/output/test_snapshot_failures.py index 625b19b8..526340d3 100644 --- a/tests/output/test_snapshot_failures.py +++ b/tests/output/test_snapshot_failures.py @@ -335,6 +335,7 @@ def test_missing_backend_history_stops_clearly(working): keeper.keep(0.125, required=True) keeper._save = lambda state: None keeper._snapshot = lambda: None + keeper._snapshot_nbytes = lambda z: None with pytest.raises(OutputError, match="accepted endpoint"): keeper.keep(0.125, required=True) From 6b0cb25b2e79b2db6de519796fb894e9774abc91 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 13:20:13 +0000 Subject: [PATCH 013/122] Document the rows' helpers and narrow the handlers' test fields Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/backends/protocol.py | 12 ++++++++++++ src/solprop/output/schema.py | 7 +++++++ tests/output/test_handlers.py | 3 +++ 3 files changed, 22 insertions(+) diff --git a/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py index 0698a8a5..fc065cc3 100644 --- a/src/solprop/backends/protocol.py +++ b/src/solprop/backends/protocol.py @@ -339,6 +339,18 @@ def _all_finite(state: Complex[np.ndarray, "*shape n_omega"]) -> bool: rows = state.reshape(-1, state.shape[-1]) def finite(selected: slice) -> bool: + """Return whether the selected rows are finite. + + Parameters + ---------- + selected : slice + The rows. + + Returns + ------- + bool + True when none holds a NaN or an infinity. + """ part = rows[selected] # Per thread: a sum that overflows is what the second look is for. with np.errstate(over="ignore", invalid="ignore"): diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py index 4a18b1b4..c25420a1 100644 --- a/src/solprop/output/schema.py +++ b/src/solprop/output/schema.py @@ -4257,6 +4257,13 @@ def stored_spectrum( written = target.reshape(-1, field.shape[-1]) def convert(selected: slice) -> None: + """Convert the selected rows into their room. + + Parameters + ---------- + selected : slice + The rows. + """ fourier.spectrum_to_optics(rows[selected], kind=kind, out=written[selected]) parallel_rows(rows.shape[0], field.nbytes, convert) diff --git a/tests/output/test_handlers.py b/tests/output/test_handlers.py index 9e982b0e..4b854d57 100644 --- a/tests/output/test_handlers.py +++ b/tests/output/test_handlers.py @@ -173,6 +173,7 @@ def test_a_store_converts_a_plane_in_threads_to_the_same_bits( grid, metadata, monkeypatch ): plane = make_plane(grid, 0) + assert plane.field is not None expected = fourier.spectrum_to_optics(plane.field, kind=grid.kind) monkeypatch.setattr(_process, "PARALLEL_BYTES", 0) monkeypatch.setattr(_process, "thread_budget", lambda: SimpleNamespace(threads=3)) @@ -186,6 +187,7 @@ def test_a_store_converts_a_plane_in_threads_to_the_same_bits( def test_a_store_keeps_its_own_copy_of_a_plane(grid, metadata): plane = make_plane(grid, 0) + assert plane.field is not None expected = fourier.spectrum_to_optics(plane.field, kind=grid.kind) handler = MemoryOutput() handler.open(metadata) @@ -204,6 +206,7 @@ def test_a_store_grows_past_its_block_with_every_plane_intact(grid, metadata): for plane in planes: handler.save(plane) for index, plane in enumerate(planes): + assert plane.field is not None assert np.array_equal( handler["field"][index], fourier.spectrum_to_optics(plane.field, kind=grid.kind), From 5e4493a2d6aac4303a97d3427a78d70e5142d45c Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 13:24:30 +0000 Subject: [PATCH 014/122] SEP 44 draft: read the transverse edge where the state lives Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/roadmap.md | 5 + docs/dev/seps/index.md | 2 + docs/dev/seps/sep-0044-native-edge-read.md | 272 +++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 docs/dev/seps/sep-0044-native-edge-read.md diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 8ecc1c3d..1e803b0c 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -89,3 +89,8 @@ The [SEP index](seps/index.md) lists every proposal and its current status. subtraction, a weak-response limit and local temporal integration. Its ensemble, data and integration questions must be settled before acceptance; shared-level ladders, vector saturation and repeated-window histories remain future work. + +- [SEP 44 — Reading the transverse edge where the state lives](seps/sep-0044-native-edge-read.md): + the boundary check's transverse reductions move into native memory on the radial, Cartesian, + line and planar routes, on CPU and device builds, so that a field-free native run crosses no + state-sized data. Judging stays in Python, and a requested collar's reductions move too. \ No newline at end of file diff --git a/docs/dev/seps/index.md b/docs/dev/seps/index.md index fa76b40c..6c101a9d 100644 --- a/docs/dev/seps/index.md +++ b/docs/dev/seps/index.md @@ -35,6 +35,7 @@ Proposals that are active, in draft, accepted, in progress or deferred. | [17](sep-0017-gas-two-photon-ionisation.md) | Broadband two-photon continuum response beside strong-field ionisation | Accepted | Feature | | [21](archive/sep-0021-absorbing-boundaries.md) | Temporal boundaries: wider windows, absorption and the escaped field | Final | Change | | [32](sep-0032-raman-maxwell-bloch.md) | Raman Maxwell–Bloch dynamics | Draft | Feature | +| [44](sep-0044-native-edge-read.md) | Reading the transverse edge where the state lives | Draft | Change | SEP 13, the standalone numerical prerequisite for SEP 12, SEP 14, which carried it to the compiled backends, and SEP 16, which unified the multistep family and made it the @@ -96,6 +97,7 @@ sep-0010-low-rank-raman sep-0011-finite-density-gas-index sep-0017-gas-two-photon-ionisation sep-0032-raman-maxwell-bloch +sep-0044-native-edge-read template archive/index ``` diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md new file mode 100644 index 00000000..37761834 --- /dev/null +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -0,0 +1,272 @@ +# SEP 44 — Reading the transverse edge where the state lives + +| | | +|---|---| +| **SEP** | 44 | +| **Title** | Reading the transverse edge where the state lives | +| **Author** | John Travers | +| **Drafting agent** | Claude Opus 5.5 | +| **Status** | Draft | +| **Review status** | Not reviewed | +| **Reviewers** | — | +| **Type** | Change | +| **Created** | 2026-11-04 | +| **Resolution** | — | +| **Requires** | [SEP 40](archive/sep-0040-transverse-absorbing-boundaries.md) | +| **Supersedes** | — | +| **Superseded-by** | — | +| **Implementation** | — | +| **Manual pages** | — | + +## Part I — Proposal + +### 1. Abstract + +The boundary check's transverse edge (SEP 40) is reduced in Python on a host copy of the +state, on every backend. On a device that copy and those reductions take far longer than the +march they watch. This SEP moves the edge's reductions into the native memory space on the +radial, Cartesian, line and planar routes, on CPU and device builds, so that a field-free +native run crosses no state-sized data. Native returns energies per node and per mode, and +judging stays in Python and shared with `numpy_ref`. A requested collar's strip and inward +current move too, unchanged in meaning. Success is judged by parity with `numpy_ref` to +rounding, no state crossing in a field-free run, and a check that costs about a fifth of the +march rather than eleven times it. + +### 2. Motivation and scope + +A field-free point of a Kerr scan at 768 x 768 x 600 (5.27 GiB), through +`SimulationRunner` with fixed low-storage RK4IP steps, measured on `7a224ea5` +(issue [#385](https://github.com/LupoLab/solprop/issues/385)): + +| 8 steps, 4 planes | NVIDIA H200 | AMD Instinct MI325X | +|---|---:|---:| +| `boundary_check=None` | 5.9 s | 4.2 s | +| Default check | 70 s | 29 s | +| 13 crossings of the state | 37.6 s | 6.1 s | +| Single-thread host reductions | 20.8 s | 5.1 s | +| Temporal monitor's occupancy | 1.7 s | 11.2 s | + +A longer window with 51 steps and 17 planes on the H200, at 384 x 384 x 600, goes from 3.95 s to +49.8 s. So the default check multiplies a scan's time by about twelve on the card a large scan +is rented for, and the guide tells users to turn it off, which gives up the check of the time +window as well. + +[#510](https://github.com/LupoLab/solprop/issues/510) and +[#502](https://github.com/LupoLab/solprop/issues/502) remove the wasted reads, the occupancy's +atomics and most of the crossing's cost without touching the seam. This SEP removes what is +left, which is structural: the state has to reach the host for each read at all. + +**In scope.** The default read and the launch on all four routes; a requested collar's strip, +inward current and rate; the seam they need; the removal of `READ_RESOLUTION`; and the bug in +[#512](https://github.com/LupoLab/solprop/issues/512). + +**Out of scope.** Judging, warnings, stops, status, the run's record and the read spacing, which +stay in Python. Whether `L/16` should bind on light far from the edge +([#396](https://github.com/LupoLab/solprop/issues/396)), a numerics decision that this makes less +pressing. Whether the inward current survives +([#388](https://github.com/LupoLab/solprop/issues/388)). `mlx`, which keeps its host read. Moving +the march itself ([#110](https://github.com/LupoLab/solprop/issues/110)). + +### 3. Context + +[`TransverseEdge`](https://github.com/LupoLab/solprop/blob/e49925d1/src/solprop/transverse_monitor.py) +holds the geometry, the reductions and the judgement together, and +[`StepDriver._read_transverse`](https://github.com/LupoLab/solprop/blob/e49925d1/src/solprop/backends/driver.py#L1985) +hands it `_observed_field`, which is the identity on `numpy_ref` and a whole state's copy on +native. A read with no collar: + +1. scans every frequency plane for energy, `_occupied_planes`; +2. synthesises the kept planes, with a transverse inverse transform or, radially, a dense product on + the edge's rows; +3. sums each face, and reads the column energies for the spacing and the safety ramp's ledger; +4. judges. + +The design page says this is left to a measurement (`docs/dev/backends.md`, "The transverse edge is +read on the host"), and SEP 40 §7.7 sets a 2 % limit that the measurements above exceed by two orders +of magnitude. [ADR-057](../decisions.md) and `_boundary_energies` are the pattern for a reduction that +runs where the state lives, with `numpy_ref`'s host form as the reference. + +**Issues.** This fixes [#385](https://github.com/LupoLab/solprop/issues/385) for CUDA and HIP, +[#396](https://github.com/LupoLab/solprop/issues/396), +[#512](https://github.com/LupoLab/solprop/issues/512) and the time-window item of +[#505](https://github.com/LupoLab/solprop/issues/505). It needs +[#510](https://github.com/LupoLab/solprop/issues/510) first, and bears on +[#388](https://github.com/LupoLab/solprop/issues/388), +[#386](https://github.com/LupoLab/solprop/issues/386) and +[#225](https://github.com/LupoLab/solprop/issues/225). + +### 4. Theory + +Nothing here changes what is measured. With $\tilde E_j(\omega_p)$ the state's column $j$ at +frequency plane $p$ and $E_n(\omega_p)$ its synthesis at node $n$, a read forms + +$$ +u_n = \sum_p |E_n(\omega_p)|^2, \qquad +c_m = \sum_{\text{pol}} \sum_p |\tilde E_{m,\text{pol}}(\omega_p)|^2, +$$ + +unweighted, summed over polarisation, with no Nyquist weighting. A face's share is +$\sum_{n \in F} w_n u_n / U$, with $w_n$ the node quadrature and $U = \sum_n w_n u_n$ at the launch, +and the spacing and the ramp's ledger read $\gamma_m c_m$, with $\gamma_m$ the column quadrature. All +of the masks and weights are the edge's, and neither $u_n$ nor $c_m$ depends on them. + +**Removing `READ_RESOLUTION`.** A read leaves out the planes holding together at most $10^{-3}$ of the +warning, so by Parseval a face reads low by at most $10^{-9}$ of the launch energy at the default budget. +With no planes left out, the reference and native both form the sums above exactly, and a face's reading +can only rise, by at most that. No test or verdict at the default budget is near it, and the maintainer has +decided this removal. + +**The inward current.** $J = \mathrm{Im}(E^* \partial_s E)/k$, rectified per frequency and per line +before it is summed, as SEP 40 defines it. Rectifying after summing would be a different quantity. Its +trapezoid in $z$ is unchanged. + +**Contraction.** $\mathrm{Re}\,v\,\mathrm{Im}\,s - \mathrm{Im}\,v\,\mathrm{Re}\,s$ can't cancel +exactly on any build, and no hard-mask contract rests on it, so it takes no strict block (ADR-049). Its +parity bound is $\varepsilon \sum |v||s|$ times the line's measure over $k\,U$. + +### 5. Proposed interface + +Nothing public changes, except that `solprop.transverse_monitor.READ_RESOLUTION` goes. A read's +numbers can rise by at most the bound above. `memory_ledger` gains a `transverse_edge` row. + +```text +StepDriver._edge_reading(state, *, full) -> EdgeReading # host by default +EdgeReading(nodes, columns, strip, current) +EdgeBinding(rows, matrix, lines) # from the edge, at attach +``` + +### 6. Implementation strategies + +**Option A: only a faster crossing.** [#502](https://github.com/LupoLab/solprop/issues/502)'s ring takes +a crossing from 2.9 s to about 0.2 s on an H200. It leaves the host's reductions and a host volume per read, +and on a CPU it changes nothing. Worth having for a saved plane, not enough here. + +**Option B: native forms each face's sum.** Fewest numbers cross. But native would have to hold each face's +masks, weights and a reducer per route, and a parity failure could lie in any of them. + +**Option C, recommended: native forms $u_n$ and $c_m$ and returns them.** About 6 MiB per read at 768 x 768, +with $u_n$ only on the edge's rows. Every mask, weight, face and judgement stays in Python, and is the same +code on every backend. Native adds two reductions and carries no geometry beyond the row list. Corners, which +count on two faces, come out right by construction. + +**Option D: native also judges.** Returning to Python per read costs microseconds against a read's tens of +milliseconds, and a stop needs Python anyway. Not worth moving. + +**Python.** `TransverseEdge` keeps geometry, judgement, `_spacing`, the ramp's ledger, `resume` and +`summary`. `_node_power`, `_column_power`, `_lines`, `_strip_inward` and `_onset_current` move to a +`HostEdgeReduction`, deep-copyable because a lockstep batch copies its monitors, which `numpy_ref` and `mlx` +use through the default `_edge_reading`. + +**Launch.** It needs no state crossing. `_input_floor` is already a host array, so the noise's reading stays +the host reducer's. `_judge_ramp_band` needs `state - noise`: `set_initial` computes those clean column +powers before it adds the draw, as $\sum_\text{active} |f|^2 + \sum_\text{inactive} |n|^2$, because a supplied +realisation isn't masked. One corner keeps a single copy: a resume with no monitor record. + +**Native, Cartesian, line and planar.** `separable_load_current`, then a node-energy pass over the edge's rows +and a column-energy pass on the state. `separable_load_current`'s scale makes the scratch equal the host's +synthesis, the scratch is dead at a committed position on all three marches, and a device fences before reading. +The kernel takes `plane_size` and `planes`, so it has no route branch. Planar's node count is `n_y`, not +`n_nodes`. + +**Native, radial.** A GEMM of the edge's rows of `_inverse` into `statistics_time_`, which is idle between steps. +`modal_complex_` can't serve, because the retained path relies on its non-crop bins staying zero. The scale includes +`pad_scale`, which the adapter supplies. + +**Collar.** Python computes each line's strides, window, inward bin range and onset triple, captured by value, +so native does no tie-breaking. The strip uses a persistent padded transform per axis. The current accumulates in +the commit path that `finish` and `adams_exponential_accept` share, into a device view, as the split collar's +removal does, and the next reading drains it. Judging happens only at reads, so `steps.append(1)` in the native +march's bounds can go, and a collar run stops returning to Python at every step. A Cartesian collar still pays one +transverse inverse per commit, as it does now on the host. + +**Reusing the read's transform as the next step's first pass.** Provable on the chunked route and with input +noise, but not on the Kerr volume route, whose first pass is a rank-3 inverse on spectral scratch, and that is +the route this SEP is mainly for. It's left out unless a CPU measurement asks for it (Q2). + +**Kernel contract.** New `StepDriver` members, so a schema bump under today's rule. `KernelProblem` doesn't change. + +**Cost.** Estimated, not measured: 30 to 50 ms a read at 768 x 768 x 600 on a data-centre GPU, against 4.3 s today +on an H200 and against a step of about 230 ms. On CPU a read now transforms every plane, where the host read +transformed a fifth to a third of them, but it loses the copy and the host's single-thread scan. Its net effect has +to be measured (Q2). + +### 7. Verification and validation + +- **Same-state parity.** New `tests/backends/test_edge_reading.py`: native's reading of a state against + `HostEdgeReduction` on that state's own host copy, which removes trajectory rounding and leaves the budgets of + `tests/bounds.py`. Over route, scheme and march, with adversarial probes: energy in one (polarisation, last + frequency) bin, an anisotropic box with light in one corner, `n_pol = 2`, `n_to != n_t` radially, a planar + `p != 0`, a small node chunk, and mixed inward and outward frequencies that tell per-row rectification from + rectifying a sum. +- **No interference.** A read forced at every commit, on a matched schedule, across the low-storage march with and + without its stage register, Runge-Kutta with FSAL, the split and in-stage collars and both Adams formulations. + Final fields agree with the unread run within the rounding budget. +- **Whole runs.** `tests/backends/test_transverse_backends.py` extended to every route and to collars: the same stop, + read count and record as `numpy_ref`, including `current`, `strip_max` and `returned_max`. +- **No crossing.** A test that fails on `_observed_field` during a field-free watched run: none allowed, and one for a + resume with no record. A collar run's native calls are far fewer than its commits. +- **Device logic on CPU.** Both the team and host kernels compile everywhere, with `#if` only at the point of use, + so that CPU, ASan and UBSan run the device-shaped logic. A GTest beside them. +- **By hand on CUDA and HIP.** `pytest -m gpu` with the above mirrored, a probe above the device GEMM's shape, a + volume-route probe and a collar. No CI lane runs these + ([#354](https://github.com/LupoLab/solprop/issues/354)). +- **Benchmarks.** `python -m benchmarks.device_split` on an NVIDIA H200 and an AMD Instinct MI325X, against the table + in §2, and `python -m benchmarks ab` on `kerr_beam_192`, `kerr_beam_384`, `line`, `planar` and + `radial_filament_256` against 0.1.0 on an AMD EPYC 7763. Targets: under 1.25 times the check-off point on the longer + window above, and no state crossing. + +### 8. Documentation + +`docs/dev/backends.md`, "The transverse edge is read on the host"; `docs/guide/setup/running.md`, "The boundary check on a +free-space scan", whose advice to pass `boundary_check=None` goes; `docs/guide/results/output.md` and +`docs/guide/free-space/model.md`, which say the check reads on the host; `docs/dev/kernel-contract.md`; and a ledger +entry in `docs/dev/benchmarks.md`. No gallery. + +### 9. Compatibility and impact + +`READ_RESOLUTION`, public since 0.2's development, goes. Native's readings rise by at most $10^{-9}$ of the launch at the +default budget against the current behaviour, and match `numpy_ref` to rounding. A resumed collar run changes, because +[#512](https://github.com/LupoLab/solprop/issues/512) is fixed. The record gains `strip_floor`. The output schema's own +compatibility is whatever [#508](https://github.com/LupoLab/solprop/pull/508) settles. + +### 10. Alternatives considered + +Options A, B and D in §6. **Keeping plane skipping on native:** an extra pass and a transform plan per kept count, for +a saving a device doesn't need. **Reading at a stage's physical field:** between the inverse and the first crop of pass 3 +the scratch holds the predictor at $z + \Delta z$, which isn't the committed state the monitor's contract names +(`docs/dev/backends.md`, on reading what a step computed). **Retiring the current to spare the collar's transform:** a +physics decision, left to [#388](https://github.com/LupoLab/solprop/issues/388). + +### 11. Phases + +1. **Python split.** `EdgeReading`, `EdgeBinding`, `HostEdgeReduction`; `READ_RESOLUTION` goes; clean column powers; + [#512](https://github.com/LupoLab/solprop/issues/512). Native inherits the host path. Verify: the edge's tests, adapted + to pass readings. +2. **Native read, no collar.** All four routes on CPU, with same-state parity and the no-crossing test. Native falls back + to the host path when a collar is requested. +3. **Collar, called per step.** Lines, windows, plans, and `_edge_current`, with the host step still one, which pins parity + on every route. +4. **Collar, accumulated.** The commit hook; the host step goes. Verify the march's traces are unchanged. +5. **Device qualification.** CUDA and HIP compile, `pytest -m gpu`, and the timings of §7 on two data-centre GPUs. +6. **Documents and ledger.** §8, then `Final`. + +### 12. Open questions + +- **Q1.** Whether `Simulation.estimate` should count a collar's strip buffers, as it does the held plane. Recommended: yes. +- **Q2.** Whether a CPU read, with every plane now transformed, stays within SEP 40's 2 % on a long run. If not, the + read-as-first-pass reuse of §6 comes back, on routes where it's provable. Measured in phase 2. +- **Q3.** Whether an interrupted collar run's record should drain the native current on the way out, or may lag by a few + steps. Recommended: drain it. + +### 13. References + +1. SEP 40, [Transverse absorbing boundaries](archive/sep-0040-transverse-absorbing-boundaries.md), §5.3 and §7.7. +2. ADR-049, ADR-057 and ADR-061, [decisions](../decisions.md). + +## Part II — Implementation record + +No phase has been implemented. + +## Part III — History + +- 2026-11-04: Drafted. Tracking issue: to be opened with this draft. From 4996b2648f224601906704de199a16b1d8410121 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 13:29:37 +0000 Subject: [PATCH 015/122] Port the native State tests to GeneralState The adapter always takes the general engine, so these eight tests held only code nothing can reach. They keep their assertions and tolerances. Refs #88. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/tests/native/test_core.cpp | 49 ++++++++++++++++--------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/native/tests/native/test_core.cpp b/native/tests/native/test_core.cpp index 2d51cce7..3b142699 100644 --- a/native/tests/native/test_core.cpp +++ b/native/tests/native/test_core.cpp @@ -401,8 +401,8 @@ TEST(MultiplyPhase, AppliesQuarterTurnAndPreservesMagnitude) { } } -TEST(State, MasksAndAppliesCombinedWholeStepPropagator) { - auto problem = base_problem(native::FieldFamily::envelope); +TEST(GeneralState, MasksAndAppliesCombinedWholeStepPropagator) { + auto problem = general_problem(native::FieldFamily::envelope); problem.n_phi_terms = 2; problem.phi_terms.clear(); for (std::size_t term = 0; term < 2; ++term) { @@ -416,7 +416,7 @@ TEST(State, MasksAndAppliesCombinedWholeStepPropagator) { auto scalars = trial(problem); scalars.phi_delta = {0.1, 0.2, 0.3, 0.4}; const std::vector initial(problem.n_omega, {1.0, -0.5}); - native::State state(problem); + native::GeneralState state(problem); state.set_initial(initial, {}); const auto partials = state.trial_step(0.25, scalars); state.finish(0.25); @@ -432,12 +432,12 @@ TEST(State, MasksAndAppliesCombinedWholeStepPropagator) { EXPECT_EQ(partials[2], 0.0); } -TEST(State, KerrFamiliesMatchStraightforwardHostTransforms) { +TEST(GeneralState, KerrFamiliesMatchStraightforwardHostTransforms) { for (const auto family : {native::FieldFamily::field_real, native::FieldFamily::field_analytic, native::FieldFamily::envelope}) { SCOPED_TRACE(static_cast(family)); - auto problem = base_problem(family); + auto problem = general_problem(family); problem.nonlinear = true; std::vector initial(problem.n_omega); initial[0] = Complex(1.0, 0.0); @@ -448,7 +448,7 @@ TEST(State, KerrFamiliesMatchStraightforwardHostTransforms) { constexpr double taper = 1.5; constexpr double dz = 0.02; const auto derivative = nonlinear_reference(problem, initial, taper); - native::State state(problem); + native::GeneralState state(problem); state.set_initial(initial, {}); const auto partials = state.trial_step(dz, trial(problem, taper)); state.finish(dz); @@ -2430,12 +2430,12 @@ TEST(PlasmaBandScan, FilteringPreservesShortRowsBlockTailsAndDynamicRange) { } } -TEST(State, RepeatedTrialsOverwriteFullCropAndStageZeroAccumulators) { - auto problem = base_problem(native::FieldFamily::envelope); +TEST(GeneralState, RepeatedTrialsOverwriteFullCropAndStageZeroAccumulators) { + auto problem = general_problem(native::FieldFamily::envelope); problem.nonlinear = true; const std::vector initial = { {1.0, 0.0}, {0.25, -0.125}, {-0.1, 0.05}, {0.05, 0.025}}; - native::State state(problem); + native::GeneralState state(problem); state.set_initial(initial, {}); const auto first = state.trial_step(0.02, trial(problem, 1.5)); const auto second = state.trial_step(0.02, trial(problem, 1.5)); @@ -2456,20 +2456,20 @@ TEST(State, RepeatedTrialsOverwriteFullCropAndStageZeroAccumulators) { EXPECT_EQ(state.field_to_host(), initial); } -TEST(State, RejectsDuplicateCropBins) { - auto problem = base_problem(native::FieldFamily::envelope); +TEST(GeneralState, RejectsDuplicateCropBins) { + auto problem = general_problem(native::FieldFamily::envelope); problem.crop_index[1] = problem.crop_index[0]; - EXPECT_THROW(native::State state(problem), std::invalid_argument); + EXPECT_THROW(native::GeneralState state(problem), std::invalid_argument); } -TEST(State, SplitStepAbsorberDampsOnlyAcceptedState) { - auto problem = base_problem(native::FieldFamily::envelope); +TEST(GeneralState, SplitStepAbsorberDampsOnlyAcceptedState) { + auto problem = general_problem(native::FieldFamily::envelope); problem.nonlinear = false; problem.split_step_absorber = true; problem.absorber_coarse.assign(problem.n_t, 2.0); const std::vector initial(problem.n_omega, {1.0, -0.25}); constexpr double dz = 0.1; - native::State state(problem); + native::GeneralState state(problem); state.set_initial(initial, {}); static_cast(state.trial_step(dz, trial(problem))); const auto before = state.field_to_host(); @@ -2485,15 +2485,15 @@ TEST(State, SplitStepAbsorberDampsOnlyAcceptedState) { EXPECT_FALSE(state.fsal_ready()); } -TEST(State, InStageAbsorberPreservesFirstSameAsLastEligibility) { - auto problem = base_problem(native::FieldFamily::envelope); +TEST(GeneralState, InStageAbsorberPreservesFirstSameAsLastEligibility) { + auto problem = general_problem(native::FieldFamily::envelope); problem.nonlinear = false; problem.in_stage_absorber = true; problem.first_same_as_last = true; problem.absorber_fine.assign(problem.n_to, 2.0); const std::vector initial(problem.n_omega, {1.0, 0.0}); constexpr double dz = 0.1; - native::State state(problem); + native::GeneralState state(problem); state.set_initial(initial, {}); static_cast(state.trial_step(dz, trial(problem))); state.finish(dz); @@ -2729,8 +2729,8 @@ TEST(GeneralState, LowStorageRefusesATableNeverBuilt) { std::logic_error); } -TEST(State, CloseIsIdempotentAndRejectsFurtherUse) { - native::State state(base_problem(native::FieldFamily::envelope)); +TEST(GeneralState, CloseIsIdempotentAndRejectsFurtherUse) { + native::GeneralState state(general_problem(native::FieldFamily::envelope)); state.close(); state.close(); EXPECT_TRUE(state.closed()); @@ -3088,13 +3088,14 @@ TEST(CpuThreadPolicy, MatrixPackingDoesNotInheritTheDenseWorkWidth) { } TEST(CpuThreadPolicy, WorkspacesKeepIndependentChoicesAndReleaseTheirPools) { - auto problem = base_problem(native::FieldFamily::envelope); - native::State small(problem); + auto problem = general_problem(native::FieldFamily::envelope); + native::GeneralState small(problem); problem.n_to = native::fft_full_thread_elements; problem.time_window.assign(problem.n_to, 1.0); + problem.time_window_derivative.assign(problem.n_to, 0.0); problem.carrier.assign(problem.n_to, Complex(1.0, 0.0)); - native::State large(problem); - native::State another_small(base_problem(native::FieldFamily::envelope)); + native::GeneralState large(problem); + native::GeneralState another_small(general_problem(native::FieldFamily::envelope)); const auto small_sizing = small.execution_sizing(); EXPECT_EQ(small_sizing, another_small.execution_sizing()); const auto key = "fft.fft." + std::to_string(problem.n_to); From 44ef418484f94b1bb50f1eefa5d1005696543732 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 13:30:44 +0000 Subject: [PATCH 016/122] Make GeneralState BoundState's only state BoundState built the other engine only for a problem the adapter never sends. Its refusals and fallbacks go with the dispatch. Two members that never checked `require_open` keep their test of the pointer, which is null after `close`. Refs #88. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/bindings.cpp | 153 +++++++++------------------------------- 1 file changed, 34 insertions(+), 119 deletions(-) diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index c2b121e2..6b0bee82 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -1268,10 +1268,7 @@ class BoundState { : columns_(problem.n_columns()), elements_(problem.n_columns() * problem.n_omega), stages_(problem.n_stages), raman_terms_(problem.n_raman_terms), raman_(problem.raman) { - if (problem.needs_general_engine()) - general_state_ = std::make_shared(std::move(problem)); - else - state_ = std::make_unique(std::move(problem)); + general_state_ = std::make_shared(std::move(problem)); const std::scoped_lock lock(runtime_state.mutex); runtime_state.states.push_back(this); } @@ -1289,58 +1286,39 @@ class BoundState { void set_initial(const std::span field, const std::span noise) { require_open(); - if (general_state_) - general_state_->set_initial(field, noise); - else - state_->set_initial(field, noise); + general_state_->set_initial(field, noise); } std::vector trial_step(const double dz, const native::TrialScalars& scalars, const bool prepare_only = false) { require_open(); - if (general_state_) { - if (prepare_only) { - general_state_->prepare_trial(dz, scalars); - return {}; - } - return general_state_->trial_step(dz, scalars); + if (prepare_only) { + general_state_->prepare_trial(dz, scalars); + return {}; } - if (prepare_only) - throw std::logic_error("batch preparation requires the general native state"); - const auto values = state_->trial_step(dz, scalars); - return {values.begin(), values.end()}; + return general_state_->trial_step(dz, scalars); } void set_flow_actions(const std::vector& forward, const std::vector& inverse, const std::vector& identity) { require_open(); - if (!general_state_) - throw std::logic_error("a coupled flow requires the general native state"); general_state_->set_flow_actions(forward, inverse, identity); } std::vector flow_actions_to_host(const bool inverse) { require_open(); - if (!general_state_) - throw std::logic_error("a coupled flow requires the general native state"); return general_state_->flow_actions_to_host(inverse); } std::vector form_flow_actions(const std::size_t section, const double dz, const std::vector& fractions) { require_open(); - if (!general_state_) - throw std::logic_error("a coupled flow requires the general native state"); return general_state_->form_flow_actions(section, dz, fractions); } std::vector weighted_error(const std::vector& scale) { require_open(); - if (!general_state_) - throw std::logic_error("weighted error requires the general native state"); return general_state_->weighted_error(scale); } void prepare_raman(const std::vector& rates, const std::vector& densities) { require_open(); - if (!general_state_) - throw std::logic_error("Raman preparation requires the general native state"); general_state_->prepare_raman(rates, densities); } std::vector statistics(const std::uint64_t flags, @@ -1352,8 +1330,6 @@ class BoundState { const std::vector& channel_preionisation, const std::vector& power_weight) { require_open(); - if (!general_state_) - throw std::logic_error("statistics require the general native state"); return general_state_->statistics(flags, windows, on_axis, density, taper, preionisation, intensity_factor, channel_preionisation, power_weight); @@ -1362,8 +1338,6 @@ class BoundState { const double intensity_factor, const double z) { require_open(); - if (!general_state_) - throw std::logic_error("spatial statistics require the general native state"); return general_state_->spatial_statistics(flags, intensity_factor, z); } std::vector aperture_statistics(const std::uint64_t flags, @@ -1372,54 +1346,39 @@ class BoundState { const std::vector& regions, const double intensity_factor) { require_open(); - if (!general_state_) - throw std::logic_error("far-field statistics require the general native state"); return general_state_->aperture_statistics(flags, apertures, exponent, regions, intensity_factor); } + // Not `require_open`: this stays a no-op on a closed state, whose pointer is null. void set_crossing_workers(const std::size_t workers) noexcept { if (general_state_) general_state_->set_crossing_workers(workers); } void field_to_host(const std::span destination) { require_open(); - if (general_state_) - general_state_->field_to_host(destination); - else - state_->field_to_host(destination); + general_state_->field_to_host(destination); } /** The state's element count, `columns * n_omega`. */ std::size_t elements() const noexcept { return elements_; } std::vector adams_source(const std::vector& input, const native::TrialScalars& scalars) { require_open(); - if (!general_state_) - throw std::logic_error("the Adams source requires the general native state"); return general_state_->adams_source(input, scalars); } void adopt(const std::vector& field, const double dz) { require_open(); - if (!general_state_) - throw std::logic_error("the Adams adoption requires the general native state"); general_state_->adopt(field, dz); } native::GeneralState& low_storage_state() { require_open(); - if (!general_state_) - throw std::logic_error("the low-storage march requires the general native state"); return *general_state_; } native::GeneralState& general_state() { require_open(); - if (!general_state_) - throw std::logic_error("a z table requires the general native state"); return *general_state_; } native::GeneralState& adams_state() { require_open(); - if (!general_state_) - throw std::logic_error( - "the resident Adams history requires the general native state"); return *general_state_; } // The stage tables an exponential Adams trial reads natively (solprop SEP 39). @@ -1525,165 +1484,122 @@ class BoundState { void stage_to_host(const std::size_t stage, const std::span destination) { require_open(); - if (!general_state_) - throw std::logic_error("stage observation requires the general native state"); general_state_->stage_to_host(stage, destination); } std::vector projected_to_host(const bool coarse) { require_open(); - if (!general_state_) - throw std::logic_error( - "projection observation requires the general native state"); return general_state_->projected_to_host(coarse); } + // Not `require_open`: a closed state, whose pointer is null, reads as having none. std::map dispersive_path() const { return general_state_ ? general_state_->dispersive_path() : std::map{}; } void finish(const double dz) { require_open(); - if (general_state_) - general_state_->finish(dz); - else - state_->finish(dz); + general_state_->finish(dz); } void promote_fsal() { require_open(); - if (general_state_) - general_state_->promote_fsal(); - else - state_->promote_fsal(); + general_state_->promote_fsal(); } void clear_fsal() { require_open(); - if (general_state_) - general_state_->clear_fsal(); - else - state_->clear_fsal(); + general_state_->clear_fsal(); } - // The engines leave a quiet collar out where the reference does (solprop SEP 21). + // The engine leaves a quiet collar out where the reference does (solprop SEP 21). void set_absorber_quiet(const bool quiet) { require_open(); - if (general_state_) - general_state_->set_absorber_quiet(quiet); - else - state_->set_absorber_quiet(quiet); + general_state_->set_absorber_quiet(quiet); } void bind_monitor(const std::vector& weights, const std::vector& column_weights) { require_open(); - if (general_state_) - general_state_->bind_monitor(weights, column_weights); - else - state_->bind_monitor(weights, column_weights); + general_state_->bind_monitor(weights, column_weights); } void unbind_monitor() { require_open(); - if (general_state_) - general_state_->unbind_monitor(); - else - state_->unbind_monitor(); + general_state_->unbind_monitor(); } void bind_occupancy(const std::vector& buckets, const std::size_t count) { require_open(); - if (general_state_) - general_state_->bind_occupancy(buckets, count); - else - state_->bind_occupancy(buckets, count); + general_state_->bind_occupancy(buckets, count); } std::vector monitor_reading() { require_open(); - return general_state_ ? general_state_->monitor_reading() - : state_->monitor_reading(); + return general_state_->monitor_reading(); } std::map memory_ledger() { require_open(); - return general_state_ ? general_state_->memory_ledger() : state_->memory_ledger(); + return general_state_->memory_ledger(); } native::CpuSizing execution_sizing() const { require_open(); - return general_state_ ? general_state_->execution_sizing() - : state_->execution_sizing(); + return general_state_->execution_sizing(); } double planning_seconds() const { require_open(); - return general_state_ ? general_state_->planning_seconds() - : state_->planning_seconds(); + return general_state_->planning_seconds(); } double raman_planning_seconds() const { require_open(); - return general_state_ ? general_state_->raman_planning_seconds() : 0.0; + return general_state_->raman_planning_seconds(); } std::string raman_fft_provider() const { require_open(); - return general_state_ ? general_state_->raman_fft_provider() : "none"; + return general_state_->raman_fft_provider(); } std::map raman_fft_tasks() const { require_open(); - return general_state_ ? general_state_->raman_fft_tasks() - : std::map{}; + return general_state_->raman_fft_tasks(); } std::size_t raman_kernel_transforms() const { require_open(); - return general_state_ ? general_state_->raman_kernel_transforms() : 0; + return general_state_->raman_kernel_transforms(); } bool fsal_ready() const { require_open(); - return general_state_ ? general_state_->fsal_ready() : state_->fsal_ready(); + return general_state_->fsal_ready(); } std::size_t rk_source_evaluations() const { require_open(); - return general_state_ ? general_state_->rk_source_evaluations() - : state_->rk_source_evaluations(); + return general_state_->rk_source_evaluations(); } double quadrature_error() const { require_open(); - return general_state_ ? general_state_->quadrature_error() - : Kokkos::Experimental::quiet_NaN_v; + return general_state_->quadrature_error(); } double quadrature_peak() { require_open(); - return general_state_ ? general_state_->quadrature_peak() - : Kokkos::Experimental::quiet_NaN_v; + return general_state_->quadrature_peak(); } std::string synthesis_provider() const { require_open(); - return general_state_ ? general_state_->synthesis_provider() : "custom"; + return general_state_->synthesis_provider(); } std::string projection_provider() const { require_open(); - return general_state_ ? general_state_->projection_provider() : "custom"; + return general_state_->projection_provider(); } std::string gemm_shape_policy() const { require_open(); - return general_state_ ? general_state_->gemm_shape_policy() - : "mode-averaged 1 x 1 transforms use custom scaling"; + return general_state_->gemm_shape_policy(); } std::size_t response_team_size() const { require_open(); - return general_state_ ? general_state_->response_team_size() - : SOLPROP_NATIVE_DEVICE_TEAM_SIZE_RECORD; + return general_state_->response_team_size(); } std::shared_ptr batch_member() { require_open(); - if (!general_state_) - throw std::logic_error("batch execution requires the general native state"); return general_state_; } std::size_t columns() const noexcept { return columns_; } std::size_t stages() const noexcept { return stages_; } std::size_t raman_terms() const noexcept { return raman_terms_; } bool raman() const noexcept { return raman_; } - bool closed() const noexcept { - return (!state_ || state_->closed()) && - (!general_state_ || general_state_->closed()); - } + bool closed() const noexcept { return !general_state_ || general_state_->closed(); } void close() noexcept { - if (state_) { - state_->close(); - state_.reset(); - } if (general_state_) { general_state_->close(); general_state_.reset(); @@ -1700,7 +1616,6 @@ class BoundState { std::size_t stages_{}; std::size_t raman_terms_{}; bool raman_{}; - std::unique_ptr state_; std::shared_ptr general_state_; std::optional stage_tables_; native::stage::Anchor stage_anchor_; From 3ccab67a8e438b61203e8954e64ebe56854018ae Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 13:32:20 +0000 Subject: [PATCH 017/122] Drop the phase_b_engine flag and needs_general_engine Nothing chooses an engine any more, so the adapter's constant and the query it fed have no reader. `_core` is private: the kernel schema does not change. Refs #88. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/include/solprop_native/core.hpp | 6 ------ native/src/bindings.cpp | 1 - native/src/solprop_native/backend.py | 1 - native/tests/native/test_coupled_flow.cpp | 1 - native/tests/native/test_dispersive.cpp | 1 - native/tests/native/test_general_modal.cpp | 1 - 6 files changed, 11 deletions(-) diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index e9a465e1..2a3083b5 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -513,7 +513,6 @@ struct Problem { bool raman_tensor_capable{}; bool stash_plasma{}; bool weighted_controller{}; - bool phase_b_engine{}; double dt_fine{}; double dt{}; std::size_t n_raman_terms{}; @@ -614,11 +613,6 @@ struct Problem { [[nodiscard]] std::size_t effective_node_pol() const noexcept { return n_pol_transform * n_pol_state; } - [[nodiscard]] bool needs_general_engine() const noexcept { - return phase_b_engine || general_modal || coupled_flow || n_columns() != 1 || - n_nodes != 1 || n_pol_transform != 1 || plasma || raman || - weighted_controller || dispersive || !projection_coarse_matrix.empty(); - } [[nodiscard]] std::size_t raman_drives() const noexcept { if (effective_node_pol() == 2) for (std::size_t index = 1; index < raman_tensor_weights.size(); index += 2) diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index 6b0bee82..cdd68576 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -340,7 +340,6 @@ native::Problem problem_from(const nb::dict& values) { result.raman_thg = nb::cast(values["raman_thg"]); result.stash_plasma = nb::cast(values["stash_plasma"]); result.weighted_controller = nb::cast(values["weighted_controller"]); - result.phase_b_engine = nb::cast(values["phase_b_engine"]); result.dt_fine = nb::cast(values["dt_fine"]); result.dt = nb::cast(values["dt"]); result.raman_fraction = nb::cast(values["raman_fraction"]); diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 2bf5050d..177df98f 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -3829,7 +3829,6 @@ def _native_problem( ), "stash_plasma": stash_plasma, "weighted_controller": problem.stepper.controller.weights is not None, - "phase_b_engine": True, "dt_fine": problem.grids.dt_fine, "dt": problem.grids.dt, "raman_fraction": 0.0 if raman is None else float(raman.fraction), diff --git a/native/tests/native/test_coupled_flow.cpp b/native/tests/native/test_coupled_flow.cpp index fc07e4c1..5643f10e 100644 --- a/native/tests/native/test_coupled_flow.cpp +++ b/native/tests/native/test_coupled_flow.cpp @@ -76,7 +76,6 @@ native::Problem flow_problem(const bool residual) { problem.error_weights = {0.5, 0.5, -1.0}; problem.synthesis_matrix.assign(kModes, 0.0); problem.projection_matrix.assign(kModes, 0.0); - problem.phase_b_engine = true; problem.coupled_flow = true; problem.flow_active = kActive; problem.flow_sections = kSections; diff --git a/native/tests/native/test_dispersive.cpp b/native/tests/native/test_dispersive.cpp index 4443232c..9b1b2591 100644 --- a/native/tests/native/test_dispersive.cpp +++ b/native/tests/native/test_dispersive.cpp @@ -300,7 +300,6 @@ native::Problem dispersive_problem() { problem.projection_matrix = {1.0}; problem.synthesis = 1.0; problem.projection = 1.0; - problem.phase_b_engine = true; const std::size_t bins = problem.n_to / 2 + 1; problem.dispersive = true; problem.dispersive_symmetric = true; diff --git a/native/tests/native/test_general_modal.cpp b/native/tests/native/test_general_modal.cpp index 92384485..e7b0a5e2 100644 --- a/native/tests/native/test_general_modal.cpp +++ b/native/tests/native/test_general_modal.cpp @@ -86,7 +86,6 @@ native::Problem modal_problem(const bool real_form, const std::size_t chunk, problem.error_weights = {0.25}; problem.synthesis_matrix = {0.0, 0.0}; problem.projection_matrix = {0.0, 0.0}; - problem.phase_b_engine = true; problem.general_modal = true; problem.general_real_form = real_form; problem.general_components = kComponents; From 9e80888e0e8575c29c65815268b6edb2a9e15bb1 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 13:36:36 +0000 Subject: [PATCH 018/122] Split the transverse edge's reductions from its judgement SEP 44 phase 1. A backend now hands the edge an EdgeReading, numbers and never the field, through StepDriver._edge_reading. HostEdgeReduction is the reference, on a host copy, and native takes it until phase 2, so nothing moves yet. READ_RESOLUTION goes, so a read now synthesises every plane. The launch's safety-ramp judgement reads clean columns formed at the load, which needs no state on the host. A resume now restores a collar's current, baselines and peaks (#512). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/backends/driver.py | 124 +++++- src/solprop/transverse_monitor.py | 718 ++++++++++++++++++------------ tests/test_transverse_monitor.py | 213 +++++---- tests/test_transverse_reads.py | 8 +- 4 files changed, 673 insertions(+), 390 deletions(-) diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index ae583096..9a434b27 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -76,6 +76,7 @@ terms, ) from solprop.translate.tdse import TDSEPurpose +from solprop.transverse_monitor import EdgeReading, TransverseEdge if TYPE_CHECKING: from solprop.backends.low_storage import LowStorageMarch @@ -358,6 +359,7 @@ def __init__( self._noise: Complex[np.ndarray, "n_batch n_modes n_pol n_omega"] | None = None self._z = 0.0 self._proposal = problem.stepper.dz_init + self._clean_columns: Float[np.ndarray, " n_columns"] | None = None self._schedule_index = 0 self._closed = False # The controller reads one norm a fixed lag back, and the one before it @@ -1243,12 +1245,17 @@ def set_initial( position = self._checked_position(float(z)) drawn = self._noise_field(noise) model = self._problem.initial.noise_model + self._clean_columns = None if ( model == "input" and drawn is not None and not resume and position == self._problem.initial.z ): + if self._problem.transverse is not None: + self._clean_columns = _clean_launch_columns( + values[0], drawn[0], self._problem.grids.active_mask + ) values = values + drawn self._state = self._apply_mask(values) self._noise = drawn if model == "modified" else None @@ -1994,13 +2001,20 @@ def attach_monitor(self, monitor: BoundaryMonitor) -> None: self._refuse_scheduled_crossing(monitor) edge = getattr(monitor, "transverse", None) if edge is not None: - state = self._observed_field(self._require_state()) floor = self._input_floor schedule = self._problem.stepper.schedule + state = self._require_state() edge.launch( - np.asarray(state[0]), + self._edge_reading( + state, + edge, + # A resumed launch reads no share, but continues the current. + full=not edge.resumed, + current=edge.every_step and edge.resumed, + ), self._z, noise=None if floor is None else np.asarray(floor[0]), + clean_columns=self._edge_clean_columns(edge), steps=None if schedule is None else np.asarray(schedule.steps), reads=self.reads_transverse_edge, declared=_declared_discard(self._problem), @@ -2027,11 +2041,81 @@ def _read_transverse(self, monitor: BoundaryMonitor, *, plane: bool) -> None: accumulate = edge.every_step and not plane if not (read or accumulate): return - state = np.asarray(self._observed_field(self._require_state())[0]) + reading = self._edge_reading( + self._require_state(), edge, faces=read, current=accumulate + ) if accumulate: - edge.step(state, self._z) + assert reading.current is not None + edge.step(reading.current, self._z) if read: - edge.read(state, self._z) + edge.read(reading, self._z) + + def _edge_reading( + self, + state: _StateT, + edge: TransverseEdge, + *, + faces: bool = True, + current: bool = False, + full: bool = False, + ) -> EdgeReading: + """Return what the transverse edge asks of a state (SEP 44). + + The default reduces a host copy of the field, the reference + (:class:`~solprop.transverse_monitor.HostEdgeReduction`); a backend whose state + lives elsewhere reduces it there and returns the numbers, without a copy of it. + + Parameters + ---------- + state : object + The backend's protected state representation. + edge : solprop.transverse_monitor.TransverseEdge + The run's edge. + faces : bool, optional + Whether the nodes, the columns and any collar's strip are wanted. + current : bool, optional + Whether a collar's inward current is wanted. + full : bool, optional + Whether every node is wanted, not only the rows a read uses. + + Returns + ------- + solprop.transverse_monitor.EdgeReading + The reading. + """ + return edge.reduction.reading( + np.asarray(self._observed_field(state)[0]), + faces=faces, + current=edge.current_scale if current else None, + full=full, + ) + + def _edge_clean_columns( + self, edge: TransverseEdge + ) -> Float[np.ndarray, " n_columns"] | None: + """Return each column's launched energy without the input noise, or ``None``. + + What the safety ramp is judged on. It is formed where the load has both parts, + which needs no state on the host. Only a resume that found no record of the + monitor has none, and then the state has to cross once. + + Parameters + ---------- + edge : solprop.transverse_monitor.TransverseEdge + The run's edge. + + Returns + ------- + numpy.ndarray or None + The unweighted energies, or ``None`` with no noise or on a resumed launch. + """ + floor = self._input_floor + if floor is None or edge.resumed: + return None + if self._clean_columns is not None: + return self._clean_columns + state = np.asarray(self._observed_field(self._require_state())[0]) + return edge.reduction.column_power(state - np.asarray(floor[0])) def _next_step(self) -> float: """Return the step the march takes next, m. @@ -2802,6 +2886,36 @@ def refuse_general_leaves( raise BackendError(msg) +def _clean_launch_columns( + field: Complex[np.ndarray, "n_modes n_pol n_omega"], + noise: Complex[np.ndarray, "n_modes n_pol n_omega"], + active: np.ndarray, +) -> Float[np.ndarray, " n_modes"]: + """Return each column's energy in a masked launch, less the input noise it carries. + + That is ``mask(field + noise) - noise``: the launch on the active band, and the + noise alone off it, as a supplied realisation needn't be masked. Formed from the + parts, so that no state has to come back from where it lives to subtract the noise. + + Parameters + ---------- + field : numpy.ndarray + The launch before the noise is added. + noise : numpy.ndarray + The realisation added to it. + active : numpy.ndarray + The active band's mask, shape ``(n_omega,)``. + + Returns + ------- + numpy.ndarray + The unweighted energies, shape ``(n_modes,)``. + """ + inside = np.abs(field[..., active]) ** 2 + outside = np.abs(noise[..., ~active]) ** 2 + return np.sum(inside, axis=(1, 2)) + np.sum(outside, axis=(1, 2)) + + def _declared_discard(problem: KernelProblem) -> bool: """Return whether a run declared content above ``discard_above`` (SEP 40 5.3). diff --git a/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py index 9048c601..9e3a1996 100644 --- a/src/solprop/transverse_monitor.py +++ b/src/solprop/transverse_monitor.py @@ -18,9 +18,12 @@ is credited per read interval and judged above the realised input noise's own share. Every run reports a ``transverse_status``; only ``clear`` is a clean verdict. -The field read is the backend's committed spectral state; the reduction is the host's, -which ``numpy_ref`` carries in phase 1 of SEP 40. A backend that does not read the edge -leaves the run ``unwatched``, which it says before the first step. +A backend reduces its committed spectral state where the state lives and hands the edge +an :class:`EdgeReading`, numbers and never the field. :class:`HostEdgeReduction` is the +reference, on a host copy of the state, and everything else here is the same on every +backend: the faces, the weights, the spacing, the ledgers and the judgement (SEP 44). A +backend that does not read the edge leaves the run ``unwatched``, which it says before +the first step. """ from __future__ import annotations @@ -33,7 +36,7 @@ import numpy as np import scipy.fft -from jaxtyping import Complex, Float, Int +from jaxtyping import Bool, Complex, Float, Int from solprop._process import thread_budget from solprop.exceptions import BoundaryError, BoundaryWarning @@ -45,7 +48,8 @@ "GUARD", "NOISE_FACTOR", "READS", - "READ_RESOLUTION", + "EdgeReading", + "HostEdgeReduction", "TransverseEdge", "TransverseStatus", ] @@ -64,19 +68,39 @@ _SMALL_TRANSFORM = 2**18 """Host transforms below this many elements run on one thread.""" -READ_RESOLUTION = 1e-3 -"""A read may leave out the frequencies whose energy together is at most this fraction -of the warning (``budget**2`` of the launch energy). By Parseval a frequency cannot put -more on a face than it holds, so a face's share reads low by at most that, a thousandth -of what warns. A run's light occupies a fraction of a grid's frequencies at this level -(from a fifth to a third on filaments and Kerr beams), and each read would otherwise -synthesise them all (issue #453).""" - TransverseStatus = Literal[ "clear", "warned", "stopped", "insensitive", "coarse", "unwatched", "off" ] +@dataclass(frozen=True, eq=False) +class EdgeReading: + """What a backend reads of its state for the edge: numbers, never the field. + + Attributes + ---------- + nodes : numpy.ndarray or None + Each physical node's (or row's) ``sum |E|^2`` over polarisation and frequency, + unweighted, shape ``(n_nodes,)``. A read need form only the rows the edge named, + and leaves the others zero. ``None`` when only the current was asked for. + columns : numpy.ndarray or None + Each spectral column's ``sum |E|^2``, unweighted, shape ``(n_columns,)``; + ``None`` when only the current was asked for. + strip : numpy.ndarray or None + A requested collar's return strip, ``(inward, windowed)`` energy per line along + a face's normal, shape ``(n_lines, 2)``, unweighted by the launch; ``None`` + without a collar, or when only the current was asked for. + current : float or None + The rectified inward current across the collar's onset, unweighted by the + launch; ``None`` when it was not asked for. + """ + + nodes: Float[np.ndarray, " n_nodes"] | None = None + columns: Float[np.ndarray, " n_columns"] | None = None + strip: Float[np.ndarray, "n_lines 2"] | None = None + current: float | None = None + + @dataclass(frozen=True) class _Face: """One face of the transverse edge. @@ -209,6 +233,301 @@ def _guard_width(space: RadialGrid | FreeGrid | PlanarGrid) -> float: return GUARD * min(widths) +def _radial_inverse(space: RadialGrid) -> Float[np.ndarray, "n_r n_r"]: + """Return the radial grid's synthesis from its spectral columns to its nodes. + + Parameters + ---------- + space : solprop.grid.RadialGrid + The grid. + + Returns + ------- + numpy.ndarray + The dense matrix. + """ + hankel = space.hankel + return ( + hankel.inverse_scale * (hankel.j1[:, None] / hankel.j1[None, :]) * hankel.matrix + ) + + +@dataclass +class HostEdgeReduction: + """The reference reductions of the edge, on a host copy of a state (SEP 44). + + A backend whose state lives elsewhere forms the same numbers there instead + (``StepDriver._edge_reading``), and is held to these. + + Attributes + ---------- + space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid + The run's grid. + rows : numpy.ndarray + Boolean mask of the nodes (or rows) a read uses: the edge's and a collar's. + strip_bounds : tuple of float or None + A requested collar's return strip in the outward coordinate, m. + """ + + space: RadialGrid | FreeGrid | PlanarGrid + rows: Bool[np.ndarray, " n_nodes"] + strip_bounds: tuple[float, float] | None + _inverse: np.ndarray | None = field(default=None, init=False) + # The host's transverse transforms take the process's thread budget (ADR-052). + _workers: int = field(default_factory=lambda: thread_budget().threads, init=False) + + def reading( + self, + state: Complex[np.ndarray, "n_modes n_pol n_omega"], + *, + faces: bool = True, + current: tuple[float, float] | None = None, + full: bool = False, + ) -> EdgeReading: + """Return what the edge asked of a state. + + Parameters + ---------- + state : numpy.ndarray + One batch member's spectral columns. + faces : bool, optional + Whether to read the nodes, the columns and any strip. + current : tuple of float or None, optional + ``(size, k_min)``: to read the current at the onset of that requested size, + m, on that wavenumber, rad/m. + full : bool, optional + Whether every node is wanted, as a launch wants, not only the read's rows. + + Returns + ------- + solprop.transverse_monitor.EdgeReading + The reading. + """ + rate = None if current is None else self.current(state, *current) + if not faces: + return EdgeReading(current=rate) + return EdgeReading( + self.node_power(state, full=full), + self.column_power(state), + None if self.strip_bounds is None else self.strip(state), + rate, + ) + + def node_power( + self, + state: Complex[np.ndarray, "n_modes n_pol n_omega"], + *, + full: bool = True, + ) -> Float[np.ndarray, " n_nodes"]: + """Return each physical node's (or row's) energy, over pol and frequency. + + Parameters + ---------- + state : numpy.ndarray + One batch member's spectral columns. + full : bool, optional + Whether every node is wanted. Otherwise radially the rows a read doesn't use + are not synthesised and are zero: a read happens many times a run, and the + full synthesis is an ``n_r``-square product per frequency. + + Returns + ------- + numpy.ndarray + ``|E|^2`` summed, unweighted, in the state's units squared. + """ + space = self.space + flat = state.reshape(state.shape[0], -1) + if isinstance(space, RadialGrid): + if self._inverse is None: + self._inverse = _radial_inverse(space) + if full: + return _row_energy(self._inverse @ flat) + nodes = np.zeros(space.n_r) + nodes[self.rows] = _row_energy(self._inverse[self.rows] @ flat) + return nodes + if isinstance(space, PlanarGrid): + cube = flat.reshape(space.n_y, space.n_p, -1) + physical = scipy.fft.ifft(cube, axis=0, workers=self._fft_workers(cube)) + physical = physical.reshape(space.n_y, -1) + else: + cube = flat.reshape(space.n_y, space.n_x, -1) + workers = self._fft_workers(cube) + physical = scipy.fft.ifftn(cube, axes=(0, 1), workers=workers) + physical = physical.reshape(space.n_y * space.n_x, -1) + if full: + return _row_energy(physical) + nodes = np.zeros(physical.shape[0]) + nodes[self.rows] = _row_energy(physical[self.rows]) + return nodes + + def _fft_workers(self, cube: np.ndarray) -> int: + """Return the threads for one host transform of `cube`. + + A small transform is faster on one thread than on a pool woken beside the + backend's own threads. + + Parameters + ---------- + cube : numpy.ndarray + The array transformed. + + Returns + ------- + int + The thread budget's threads, or 1 below ``2**18`` elements. + """ + return self._workers if cube.size >= _SMALL_TRANSFORM else 1 + + @staticmethod + def column_power( + state: Complex[np.ndarray, "n_modes n_pol n_omega"], + ) -> Float[np.ndarray, " n_columns"]: + """Return each spectral column's unweighted energy. + + Parameters + ---------- + state : numpy.ndarray + One batch member's spectral columns. + + Returns + ------- + numpy.ndarray + ``sum |E|^2`` over polarisation and frequency. + """ + return _row_energy(state.reshape(state.shape[0], -1)) + + def lines( + self, state: Complex[np.ndarray, "n_modes n_pol n_omega"] + ) -> list[tuple[np.ndarray, np.ndarray, float]]: + """Return the field along each face's outward normal, line by line. + + Parameters + ---------- + state : numpy.ndarray + One member's spectral columns. + + Returns + ------- + list of tuple + ``(s, values, measure)``: the outward coordinate of the line's nodes, m; + the field on them, ``(n_nodes, n_rows)`` with one row per frequency, + polarisation and transverse line (``sqrt(r) E`` radially); and the + measure each row's sum is multiplied by. + """ + space = self.space + flat = state.reshape(state.shape[0], -1) + if isinstance(space, RadialGrid): + r = np.asarray(space.r) + return [ + (r, np.sqrt(r)[:, None] * (_radial_inverse(space) @ flat), 2 * np.pi) + ] + if isinstance(space, PlanarGrid): + cube = np.fft.ifft(flat.reshape(space.n_y, space.n_p, -1), axis=0) + rows = cube.reshape(space.n_y, -1) + y = np.asarray(space.y) + return [(y, rows, 1.0), (-y, rows, 1.0)] + cube = np.fft.ifftn(flat.reshape(space.n_y, space.n_x, -1), axes=(0, 1)) + x = np.asarray(space.x) + dy = 1.0 if isinstance(space, LineGrid) else float(space.y[1] - space.y[0]) + along_x = np.moveaxis(cube, 1, 0).reshape(space.n_x, -1) + lines = [(x, along_x, dy), (-x, along_x, dy)] + if not isinstance(space, LineGrid): + y = np.asarray(space.y) + dx = float(space.x[1] - space.x[0]) + along_y = cube.reshape(space.n_y, -1) + lines += [(y, along_y, dx), (-y, along_y, dx)] + return lines + + def strip( + self, state: Complex[np.ndarray, "n_modes n_pol n_omega"] + ) -> Float[np.ndarray, "n_lines 2"]: + """Return each line's resolved inward energy and windowed energy in the strip. + + A Kaiser(8)-windowed transform along each face's normal, padded twice; the + inward energy is that at ``|q| >= 16 / s`` travelling inward, where the + window's leakage is about ``1e-6`` (section 5.3). + + Parameters + ---------- + state : numpy.ndarray + One member's spectral columns. + + Returns + ------- + numpy.ndarray + ``(inward, windowed)`` per line, unweighted by the launch; zero on a line + with too few nodes in the strip. + """ + assert self.strip_bounds is not None + a, b = self.strip_bounds + resolved = 16.0 / (b - a) + found = [] + for s, values, measure in self.lines(state): + select = (s >= a) & (s <= b) + if np.count_nonzero(select) < 4: + found.append((0.0, 0.0)) + continue + order = np.argsort(s[select]) + coordinate = s[select][order] + spacing = float(np.mean(np.diff(coordinate))) + u = 2 * (coordinate - a) / (b - a) - 1 + window = np.i0(8.0 * np.sqrt(np.clip(1 - u * u, 0.0, 1.0))) / np.i0(8.0) + lines = window[:, None] * values[select][order] + count = 2 * coordinate.size + spectrum = np.abs(np.fft.fft(lines, count, axis=0)) ** 2 * spacing / count + q = 2 * np.pi * np.fft.fftfreq(count, d=spacing) + # solprop propagates as exp(-i k_z z) (the operator carries -i(k_z - k)), so + # a component exp(i q s) travels towards smaller s: inward is q > 0. + found.append( + ( + float(np.sum(spectrum[q >= resolved])) * measure, + float(np.sum(spectrum)) * measure, + ) + ) + return np.array(found, dtype=np.float64).reshape(-1, 2) + + def current( + self, + state: Complex[np.ndarray, "n_modes n_pol n_omega"], + size: float, + k_min: float, + ) -> float: + """Return the inward energy current across the collar's onset, rectified. + + ``J = Im(E* dE/dn) / k`` on the requested size's boundary, summed over its + frequencies and lines, per metre of propagation, counting inward flow only + (section 5.3). + + Parameters + ---------- + state : numpy.ndarray + One member's spectral columns. + size : float + The requested half-size on the narrowest axis, m. + k_min : float + The smallest wavenumber over the requested band, rad/m. + + Returns + ------- + float + The current, unweighted by the launch. + """ + total = 0.0 + for s, values, measure in self.lines(state): + index = int(np.argmin(np.abs(s - size))) + # A node without a neighbour on each side has no slope to read. + order = np.argsort(s) + position = int(np.nonzero(order == index)[0][0]) + if position == 0 or position == s.size - 1: + continue + before, after = order[position - 1], order[position + 1] + slope = (values[after] - values[before]) / float(s[after] - s[before]) + # Under exp(-i k_z z), Im(E* dE/ds) / k is the flux towards smaller s. + flux = np.imag(np.conj(values[index]) * slope) / k_min + total += float(np.sum(np.maximum(flux, 0.0))) * measure + return total + + @dataclass class TransverseEdge: """The transverse edge of one free-space run: geometry, totals and record. @@ -274,10 +593,9 @@ class TransverseEdge: _current_last: tuple[float, float] | None = field(default=None, init=False) _current_baseline: float = field(default=0.0, init=False) _returned_peak: float = field(default=0.0, init=False) - _inverse: np.ndarray | None = field(default=None, init=False) _read_rows: np.ndarray | None = field(default=None, init=False) _kappa_order: np.ndarray = field(init=False) - _workers: int = field(default=1, init=False) + reduction: HostEdgeReduction = field(init=False) def __post_init__(self) -> None: """Build the faces, the node weights and the column wavenumbers.""" @@ -298,116 +616,12 @@ def __post_init__(self) -> None: self._peak = [0.0] * len(self._faces) self._peak_raw = [0.0] * len(self._faces) self._baseline = [0.0] * len(self._faces) - # The host's transverse transforms take the process's thread budget (ADR-052). - self._workers = thread_budget().threads - - # ------------------------------------------------------------------ reductions - def _node_power( - self, - state: Complex[np.ndarray, "n_modes n_pol n_omega"], - *, - read: bool = False, - ) -> Float[np.ndarray, " n_nodes"]: - """Return each physical node's (or row's) energy, summed over pol and frequency. - - Parameters - ---------- - state : numpy.ndarray - One batch member's spectral columns. - read : bool, optional - Whether only the rows a read uses are wanted: the edge's faces, and a - requested collar's (:meth:`_rows_read`). Radially the others are not - synthesised and are zero; a read happens many times a run, and the full - synthesis is an ``n_r``-square product per frequency. - - Returns - ------- - numpy.ndarray - ``|E|^2`` summed, unweighted, in the state's units squared. - """ - space = self.space - flat = state.reshape(state.shape[0], -1) - if read: - flat = self._occupied_planes(flat) - if isinstance(space, RadialGrid): - if self._inverse is None: - hankel = space.hankel - self._inverse = ( - hankel.inverse_scale - * (hankel.j1[:, None] / hankel.j1[None, :]) - * hankel.matrix - ) - if not read: - return _row_energy(self._inverse @ flat) - rows = self._rows_read() - nodes = np.zeros(space.n_r) - nodes[rows] = _row_energy(self._inverse[rows] @ flat) - return nodes - if isinstance(space, PlanarGrid): - cube = flat.reshape(space.n_y, space.n_p, -1) - physical = scipy.fft.ifft(cube, axis=0, workers=self._fft_workers(cube)) - physical = physical.reshape(space.n_y, -1) - else: - cube = flat.reshape(space.n_y, space.n_x, -1) - workers = self._fft_workers(cube) - physical = scipy.fft.ifftn(cube, axes=(0, 1), workers=workers) - physical = physical.reshape(space.n_y * space.n_x, -1) - if not read: - return _row_energy(physical) - rows = self._rows_read() - nodes = np.zeros(physical.shape[0]) - nodes[rows] = _row_energy(physical[rows]) - return nodes - - def _occupied_planes( - self, flat: Complex[np.ndarray, "n_columns n_planes"] - ) -> Complex[np.ndarray, "n_columns n_kept"]: - """Return the frequency planes a read must synthesise (:data:`READ_RESOLUTION`). - - A requested collar's rate is credited as a removal rather than judged against - the warning, so it reads every plane. So does a state that fills more than half - of them, where the copy would cost more than the planes it leaves out. - - Parameters - ---------- - flat : numpy.ndarray - One member's spectral columns, a plane being one polarisation's frequency. - - Returns - ------- - numpy.ndarray - `flat` itself, or a copy holding only the planes kept. - """ - if self.design is not None and self.design.has_collar: - return flat - real, imaginary = np.real(flat), np.imag(flat) - energy = np.einsum("i,ij,ij->j", self._columns, real, real) + np.einsum( - "i,ij,ij->j", self._columns, imaginary, imaginary + collar = self.design is not None and self.design.has_collar + self.reduction = HostEdgeReduction( + space, + self._rows_read(), + self._strip_bounds() if collar else None, ) - order = np.argsort(energy) - limit = READ_RESOLUTION * self.budget**2 * self._launch - left_out = int(np.searchsorted(np.cumsum(energy[order]), limit, side="right")) - if 2 * left_out < energy.size: - return flat - return flat[:, np.sort(order[left_out:])] - - def _fft_workers(self, cube: np.ndarray) -> int: - """Return the threads for one host transform of `cube`. - - A small transform is faster on one thread than on a pool woken beside the - backend's own threads. - - Parameters - ---------- - cube : numpy.ndarray - The array transformed. - - Returns - ------- - int - The thread budget's threads, or 1 below ``2**18`` elements. - """ - return self._workers if cube.size >= _SMALL_TRANSFORM else 1 def _rows_read(self) -> np.ndarray: """Return the nodes (or rows) a read uses: the faces' and a collar's. @@ -443,29 +657,53 @@ def _node_rate(self) -> Float[np.ndarray, " n_nodes"] | None: return rate def _column_power( - self, state: Complex[np.ndarray, "n_modes n_pol n_omega"] + self, columns: Float[np.ndarray, " n_columns"] ) -> Float[np.ndarray, " n_columns"]: """Return each spectral column's energy, in the node reduction's units. Parameters ---------- - state : numpy.ndarray - One batch member's spectral columns. + columns : numpy.ndarray + Each column's unweighted energy, as a reading gives it. Returns ------- numpy.ndarray Weighted ``|E|^2``, scaled so that its sum is the node reduction's total. """ - return self._columns * _row_energy(state.reshape(state.shape[0], -1)) + return self._columns * columns # ------------------------------------------------------------------ the record - def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in order + @property + def current_scale(self) -> tuple[float, float]: + """Return what a reading of the inward current needs of the edge. + + Returns + ------- + tuple of float + The requested half-size on the narrowest axis, m, and the band's smallest + wavenumber, rad/m. + """ + return self._requested_size(), self.k_min + + @property + def resumed(self) -> bool: + """Return whether this edge continues an earlier execution's record. + + Returns + ------- + bool + True after :meth:`resume` found a record, so that the launch is not judged. + """ + return self._resumed + + def launch( # noqa: PLR0912, PLR0913, PLR0915 - the launch and judgements, in order self, - state: Complex[np.ndarray, "n_modes n_pol n_omega"], + reading: EdgeReading, z: float, *, noise: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, + clean_columns: Float[np.ndarray, " n_columns"] | None = None, steps: Float[np.ndarray, " n_steps"] | None, reads: bool, declared: bool, @@ -474,12 +712,15 @@ def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in orde Parameters ---------- - state : numpy.ndarray - The launched state, one member, spectral columns. + reading : solprop.transverse_monitor.EdgeReading + The launched state's reading: every node, unless :attr:`resumed`. z : float Its position, m. noise : numpy.ndarray or None - The input noise it carries, if any. + The input noise it carries, if any, which is always a host array. + clean_columns : numpy.ndarray or None, optional + Each column's unweighted energy without that noise, for the safety ramp's + judgement. ``None`` when there is no noise, or the launch was resumed. steps : numpy.ndarray or None A fixed schedule's steps, which the read spacing cannot limit. reads : bool @@ -493,18 +734,24 @@ def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in orde When physical light launched into the safety ramp's band exceeds ten budgets (section 5.3). """ - nodes = self._node_power(state) + assert reading.nodes is not None and reading.columns is not None + nodes = reading.nodes + columns = reading.columns weighted = self._weights * nodes if self._resumed: # The launch was judged by the first execution; its baselines and totals # are restored, and the reads continue from the resumed state. self._last_z = z if noise is not None: - self._noise_columns = self._column_power(noise) - self._last_columns = self._column_power(state) + self._noise_columns = self._column_power( + self.reduction.column_power(noise) + ) + self._last_columns = self._column_power(columns) self._spacing_now = self._spacing(self._last_columns) self._next = z + self._spacing_now if reads else math.inf self._last_rate = (z, self._collar_rate(nodes)) + if reading.current is not None and self._launch > 0.0: + self._current_last = (z, reading.current / self._launch) if not reads: self._status = "unwatched" return @@ -516,7 +763,10 @@ def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in orde warn, stop = self.budget**2, (self.stop_factor * self.budget) ** 2 # About the launch, not the backend: judged whether or not the edge is read. self._judge_ramp_band( - state if noise is None else state - noise, stop, warn, declared=declared + self._column_power(columns if clean_columns is None else clean_columns), + stop, + warn, + declared=declared, ) if not reads: self._status = "unwatched" @@ -542,7 +792,7 @@ def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in orde stacklevel=4, ) if noise is not None: - noise_nodes = self._weights * self._node_power(noise) + noise_nodes = self._weights * self.reduction.node_power(noise) eta = float(np.sum(noise_nodes)) / self._launch share = float(np.sum(self._weights[self._faces[0].edge])) / float( np.sum(self._weights) @@ -551,7 +801,7 @@ def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in orde int(self.band.size), 1 ) self._allowance = NOISE_FACTOR * eta * share / math.sqrt(modes) - self._noise_columns = self._column_power(noise) + self._noise_columns = self._column_power(self.reduction.column_power(noise)) if self.design is not None: ramp = self.design.ramp_rate > 0.0 self._noise_share_in_ramp = ( @@ -570,7 +820,7 @@ def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in orde BoundaryWarning, stacklevel=4, ) - self._last_columns = self._column_power(state) + self._last_columns = self._column_power(columns) spacing = self._spacing(self._last_columns) self._spacing_now = spacing self._next = z + spacing @@ -587,11 +837,11 @@ def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in orde ) self._last_rate = (z, self._collar_rate(nodes)) if self.every_step: - self._launch_return(state, noise, weighted) + self._launch_return(reading, noise, weighted) def _judge_ramp_band( self, - launch: Complex[np.ndarray, "n_modes n_pol n_omega"], + columns: Float[np.ndarray, " n_columns"], stop: float, warn: float, *, @@ -601,8 +851,8 @@ def _judge_ramp_band( Parameters ---------- - launch : numpy.ndarray - The launch as attached, less the input noise it carries. + columns : numpy.ndarray + Each column's weighted energy in the launch, without the input noise. stop, warn : float The stop and warning levels as energy shares. declared : bool @@ -615,7 +865,6 @@ def _judge_ramp_band( """ if self.design is None: return - columns = self._column_power(launch) total = float(np.sum(columns)) share = ( float(np.sum(columns[self.design.ramp_rate > 0.0])) / total @@ -751,15 +1000,13 @@ def wants_read(self, z: float) -> bool: """ return self._launch > 0.0 and self._status != "unwatched" and z > self._last_z - def read( - self, state: Complex[np.ndarray, "n_modes n_pol n_omega"], z: float - ) -> None: - """Read the edge at a committed position, credit the removals and judge. + def read(self, reading: EdgeReading, z: float) -> None: + """Take a reading at a committed position, credit the removals and judge. Parameters ---------- - state : numpy.ndarray - One member's spectral columns. + reading : solprop.transverse_monitor.EdgeReading + The state's reading. z : float Its position, m. @@ -770,7 +1017,8 @@ def read( """ if not self.wants_read(z): return - nodes = self._node_power(state, read=True) + assert reading.nodes is not None and reading.columns is not None + nodes = reading.nodes weighted = self._weights * nodes gap = z - self._last_z self._reads += 1 @@ -790,7 +1038,7 @@ def read( self._spatial_removed += 0.5 * (z - previous_z) * (previous + rate) self._last_rate = (z, rate) self._last_z = z - self._last_columns = self._column_power(state) + self._last_columns = self._column_power(reading.columns) self._spacing_now = self._spacing(self._last_columns) self._next = z + self._spacing_now if self.design is not None and self.design.has_collar: @@ -798,7 +1046,7 @@ def read( for index, face in enumerate(self._faces): share = float(np.sum(weighted[face.edge])) / self._launch self._peak_raw[index] = max(self._peak_raw[index], share) - self._judge_return(state, z) + self._judge_return(reading, z) self._judge_ramp_removal() return warn, stop = self.budget**2, (self.stop_factor * self.budget) ** 2 @@ -862,140 +1110,37 @@ def _requested_size(self) -> float: return space.radius_x return min(space.radius_x, space.radius_y) - def _lines( - self, state: Complex[np.ndarray, "n_modes n_pol n_omega"] - ) -> list[tuple[np.ndarray, np.ndarray, float]]: - """Return the field along each face's outward normal, line by line. - - Parameters - ---------- - state : numpy.ndarray - One member's spectral columns. - - Returns - ------- - list of tuple - ``(s, values, measure)``: the outward coordinate of the line's nodes, m; - the field on them, ``(n_nodes, n_rows)`` with one row per frequency, - polarisation and transverse line (``sqrt(r) E`` radially); and the - measure each row's sum is multiplied by. - """ - space = self.space - flat = state.reshape(state.shape[0], -1) - if isinstance(space, RadialGrid): - hankel = space.hankel - inverse = ( - hankel.inverse_scale - * (hankel.j1[:, None] / hankel.j1[None, :]) - * hankel.matrix - ) - r = np.asarray(space.r) - return [(r, np.sqrt(r)[:, None] * (inverse @ flat), 2 * np.pi)] - if isinstance(space, PlanarGrid): - cube = np.fft.ifft(flat.reshape(space.n_y, space.n_p, -1), axis=0) - rows = cube.reshape(space.n_y, -1) - y = np.asarray(space.y) - return [(y, rows, 1.0), (-y, rows, 1.0)] - cube = np.fft.ifftn(flat.reshape(space.n_y, space.n_x, -1), axes=(0, 1)) - x = np.asarray(space.x) - dy = 1.0 if isinstance(space, LineGrid) else float(space.y[1] - space.y[0]) - along_x = np.moveaxis(cube, 1, 0).reshape(space.n_x, -1) - lines = [(x, along_x, dy), (-x, along_x, dy)] - if not isinstance(space, LineGrid): - y = np.asarray(space.y) - dx = float(space.x[1] - space.x[0]) - along_y = cube.reshape(space.n_y, -1) - lines += [(y, along_y, dx), (-y, along_y, dx)] - return lines - - def _strip_inward( - self, state: Complex[np.ndarray, "n_modes n_pol n_omega"] + def _strip_shares( + self, strip: Float[np.ndarray, "n_lines 2"] ) -> tuple[float, float]: """Return the strip's resolved inward energy and its windowed energy. - A Kaiser(8)-windowed transform along each face's normal, padded twice; the - inward energy is that at ``|q| >= 16 / s`` travelling inward, where the - window's leakage is about ``1e-6`` (section 5.3). The larger face's, as - shares of the launch. + The larger line's, as shares of the launch (section 5.3). Parameters ---------- - state : numpy.ndarray - One member's spectral columns. + strip : numpy.ndarray + ``(inward, windowed)`` per line, as a reading gives it. Returns ------- tuple of float The inward share and the windowed share. """ - a, b = self._strip_bounds() - resolved = 16.0 / (b - a) best, windowed = 0.0, 0.0 - for s, values, measure in self._lines(state): - select = (s >= a) & (s <= b) - if np.count_nonzero(select) < 4: - continue - order = np.argsort(s[select]) - coordinate = s[select][order] - spacing = float(np.mean(np.diff(coordinate))) - u = 2 * (coordinate - a) / (b - a) - 1 - window = np.i0(8.0 * np.sqrt(np.clip(1 - u * u, 0.0, 1.0))) / np.i0(8.0) - lines = window[:, None] * values[select][order] - count = 2 * coordinate.size - spectrum = np.abs(np.fft.fft(lines, count, axis=0)) ** 2 * spacing / count - q = 2 * np.pi * np.fft.fftfreq(count, d=spacing) - # solprop propagates as exp(-i k_z z) (the operator carries -i(k_z - k)), so - # a component exp(i q s) travels towards smaller s: inward is q > 0. - inward = float(np.sum(spectrum[q >= resolved])) * measure / self._launch - total = float(np.sum(spectrum)) * measure / self._launch - if inward > best: - best, windowed = inward, total + for inward, total in strip: + share = float(inward) / self._launch + if share > best: + best, windowed = share, float(total) / self._launch return best, windowed - def _onset_current( - self, state: Complex[np.ndarray, "n_modes n_pol n_omega"] - ) -> float: - """Return the inward energy current across the collar's onset, rectified. - - ``J = Im(E* dE/dn) / k`` on the requested size's boundary, summed over its - frequencies and lines, per metre of propagation, as a share of the launch, - counting inward flow only (section 5.3). - - Parameters - ---------- - state : numpy.ndarray - One member's spectral columns. - - Returns - ------- - float - The current, 1/m. - """ - size = self._requested_size() - total = 0.0 - for s, values, measure in self._lines(state): - index = int(np.argmin(np.abs(s - size))) - # A node without a neighbour on each side has no slope to read. - order = np.argsort(s) - position = int(np.nonzero(order == index)[0][0]) - if position == 0 or position == s.size - 1: - continue - before, after = order[position - 1], order[position + 1] - slope = (values[after] - values[before]) / float(s[after] - s[before]) - # Under exp(-i k_z z), Im(E* dE/ds) / k is the flux towards smaller s. - flux = np.imag(np.conj(values[index]) * slope) / self.k_min - total += float(np.sum(np.maximum(flux, 0.0))) * measure - return total / self._launch - - def step( - self, state: Complex[np.ndarray, "n_modes n_pol n_omega"], z: float - ) -> None: + def step(self, current: float, z: float) -> None: """Accumulate a collar's inward current at a committed step (section 5.3). Parameters ---------- - state : numpy.ndarray - One member's spectral columns. + current : float + The rectified current across the onset, unweighted by the launch. z : float Its position, m. """ @@ -1006,11 +1151,11 @@ def step( or self._status == "unwatched" ): return - current = self._onset_current(state) + share = current / self._launch if self._current_last is not None: previous_z, previous = self._current_last - self._current += 0.5 * (z - previous_z) * (previous + current) - self._current_last = (z, current) + self._current += 0.5 * (z - previous_z) * (previous + share) + self._current_last = (z, share) @property def next_read(self) -> float: @@ -1039,7 +1184,7 @@ def every_step(self) -> bool: def _launch_return( self, - state: Complex[np.ndarray, "n_modes n_pol n_omega"], + reading: EdgeReading, noise: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, weighted: Float[np.ndarray, " n_nodes"], ) -> None: @@ -1047,17 +1192,18 @@ def _launch_return( Parameters ---------- - state : numpy.ndarray - The launched state. + reading : solprop.transverse_monitor.EdgeReading + The launched state's reading. noise : numpy.ndarray or None Its input noise. weighted : numpy.ndarray Each node's weighted energy. """ - strip, _ = self._strip_inward(state) + assert reading.strip is not None + strip, _ = self._strip_shares(reading.strip) self._strip_baseline = strip if noise is not None: - self._strip_floor = 3.0 * self._strip_inward(noise)[0] + self._strip_floor = 3.0 * self._strip_shares(self.reduction.strip(noise))[0] outside = np.ones(weighted.size, dtype=bool) for face in self._faces: outside &= ~face.clip @@ -1068,15 +1214,13 @@ def _launch_return( self._current_last = None self._current = 0.0 - def _judge_return( - self, state: Complex[np.ndarray, "n_modes n_pol n_omega"], z: float - ) -> None: + def _judge_return(self, reading: EdgeReading, z: float) -> None: """Judge what a collar returns: the strip and the current (section 5.3). Parameters ---------- - state : numpy.ndarray - One member's spectral columns. + reading : solprop.transverse_monitor.EdgeReading + The state's reading. z : float Its position, m. @@ -1085,7 +1229,8 @@ def _judge_return( BoundaryError When the returned share exceeds ``stop_factor`` budgets. """ - strip, windowed = self._strip_inward(state) + assert reading.strip is not None + strip, windowed = self._strip_shares(reading.strip) self._strip_peak = max(self._strip_peak, strip) judged_strip = max( strip - self._strip_baseline - self._strip_floor - 1e-6 * windowed, 0.0 @@ -1283,6 +1428,12 @@ def numbers(name: str) -> list[float] | None: self._noise_share_in_ramp = number("noise_share_in_ramp") self._declared_share = number("declared_share_in_ramp") self._spatial_removed = number("spatial_removed") + self._current = number("current") + self._current_baseline = number("current_baseline") + self._strip_baseline = number("strip_baseline") + self._strip_floor = number("strip_floor") + self._strip_peak = number("strip_max") + self._returned_peak = number("returned_max") self._reads = int(number("reads")) self._largest_gap = number("largest_gap") self._warned_at = number("warned_at", math.nan) @@ -1326,6 +1477,7 @@ def summary(self) -> dict[str, object]: "returned_max": self._returned_peak, "strip_max": self._strip_peak, "strip_baseline": self._strip_baseline, + "strip_floor": self._strip_floor, "current": self._current, "current_baseline": self._current_baseline, "largest_gap": self._largest_gap, diff --git a/tests/test_transverse_monitor.py b/tests/test_transverse_monitor.py index ca07f719..cfc0c9a4 100644 --- a/tests/test_transverse_monitor.py +++ b/tests/test_transverse_monitor.py @@ -19,6 +19,7 @@ import numpy as np import pytest +from solprop.backends.driver import _clean_launch_columns from solprop.backends.registry import available from solprop.boundaries import SpatialAbsorber, SpatialAbsorberDesign from solprop.driver import Simulation @@ -30,10 +31,37 @@ from solprop.output.hdf5 import resume from solprop.output.schema import PlaneStore from solprop.translate.transverse import _nodes_window -from solprop.transverse_monitor import READ_RESOLUTION, TransverseEdge, _row_energy +from solprop.transverse_monitor import TransverseEdge, _row_energy from test_transverse_resolution import LENGTH, suite +def _launch(edge: TransverseEdge, state: np.ndarray, z: float, **options: Any) -> None: + """Launch `edge` on a state's host reading, as a backend's load would.""" + noise = options.get("noise") + clean = None if noise is None else edge.reduction.column_power(state - noise) + reading = edge.reduction.reading( + state, + full=not edge.resumed, + current=edge.current_scale if edge.every_step and edge.resumed else None, + ) + edge.launch(reading, z, clean_columns=clean, **options) + + +def _read(edge: TransverseEdge, state: np.ndarray, z: float) -> None: + """Read `edge` on a state's host reading.""" + edge.read(edge.reduction.reading(state), z) + + +def _current(edge: TransverseEdge, state: np.ndarray) -> float: + """Return the state's inward current as a share of the launch.""" + return edge.reduction.current(state, *edge.current_scale) / edge._launch # pyright: ignore[reportPrivateUsage] + + +def _step(edge: TransverseEdge, state: np.ndarray, z: float) -> None: + """Accumulate `edge`'s current on a state's host reading.""" + edge.step(edge.reduction.current(state, *edge.current_scale), z) + + def edge_record(output: object) -> dict[str, Any]: """Return a finished run's transverse edge record.""" assert isinstance(output, PlaneStore) @@ -191,6 +219,67 @@ def build() -> Simulation: assert max(resumed["edge_max"]) == pytest.approx(max(expected["edge_max"]), rel=0.1) +def test_a_resume_restores_a_collars_totals_and_continues_its_current() -> None: + # A resumed collar run used to judge against zero baselines, with its current and + # peaks restarted: nothing the resumed branch of the launch doesn't take again was + # restored. The current's trapezoid continues from the resumed state's own sample. + edge, space = _line_edge() + start = _packet(space, 0.0, 0.0) + # Returning, but faint enough to stay below the stop. + packet = 3e-3 * _packet(space, 230e-6, 1e6) + options: dict[str, Any] = { + "noise": None, + "steps": None, + "reads": True, + "declared": False, + } + with warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + _launch(edge, start, 0.0, **options) + _step(edge, packet, 1e-5) + _step(edge, packet, 2e-5) + _read(edge, packet, 2e-5) + record = edge.summary() + assert cast("float", record["current"]) > 0.0 + assert cast("float", record["strip_max"]) > 0.0 + resumed, _ = _line_edge() + resumed.resume(record) + for name in ( + "current", + "current_baseline", + "strip_baseline", + "strip_floor", + "strip_max", + "returned_max", + ): + # Copied from the record: a structural contract, not arithmetic. + assert resumed.summary()[name] == record[name], name + with warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + _launch(resumed, packet, 2e-5, **options) + _step(resumed, packet, 3e-5) + _step(edge, packet, 3e-5) + # Two runs of the same arithmetic on the same numbers, in one process. + assert resumed.summary()["current"] == pytest.approx( + edge.summary()["current"], rel=1e-12 + ) + + +def test_the_clean_launch_columns_are_the_masked_launch_less_its_noise() -> None: + # They stand in for `mask(field + noise) - noise`, which on a device would mean a + # copy of the state. The two differ only by the rounding of adding and subtracting + # the noise, relative to the column's energy. + rng = np.random.default_rng(29) + shape = (5, 2, 16) + field = rng.standard_normal(shape) + 1j * rng.standard_normal(shape) + noise = 1e-3 * (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)) + active = np.ones(16, dtype=bool) + active[:3] = False + expected = np.sum(np.abs(active * (field + noise) - noise) ** 2, axis=(1, 2)) + found = _clean_launch_columns(field, noise, active) + np.testing.assert_allclose(found, expected, rtol=1e-13) + + def test_the_start_of_run_summary_says_what_the_edge_can_report() -> None: from solprop.driver.summary import run_summary # noqa: PLC0415 from solprop.output import MemoryOutput # noqa: PLC0415 @@ -246,17 +335,18 @@ def test_the_strip_reads_inward_flow_and_not_outward() -> None: # inward reads most of itself. edge, space = _line_edge() launch = _packet(space, 0.0, 0.0) - edge.launch(launch, 0.0, noise=None, steps=None, reads=True, declared=False) + _launch(edge, launch, 0.0, noise=None, steps=None, reads=True, declared=False) # solprop propagates as exp(-i k_z z): a packet exp(i kappa x) travels to -x. - outward, _ = edge._strip_inward(_packet(space, 230e-6, -1e6)) # pyright: ignore[reportPrivateUsage] - inward, _ = edge._strip_inward(_packet(space, 230e-6, 1e6)) # pyright: ignore[reportPrivateUsage] + outward, _ = edge._strip_shares(edge.reduction.strip(_packet(space, 230e-6, -1e6))) # pyright: ignore[reportPrivateUsage] + inward, _ = edge._strip_shares(edge.reduction.strip(_packet(space, 230e-6, 1e6))) # pyright: ignore[reportPrivateUsage] assert outward < 1e-6 assert inward > 0.1 def test_the_onset_current_counts_inward_flow_only() -> None: edge, space = _line_edge() - edge.launch( + _launch( + edge, _packet(space, 0.0, 0.0), 0.0, noise=None, @@ -264,8 +354,8 @@ def test_the_onset_current_counts_inward_flow_only() -> None: reads=True, declared=False, ) - leaving = edge._onset_current(_packet(space, 200e-6, -1e6)) # pyright: ignore[reportPrivateUsage] - returning = edge._onset_current(_packet(space, 200e-6, 1e6)) # pyright: ignore[reportPrivateUsage] + leaving = _current(edge, _packet(space, 200e-6, -1e6)) # pyright: ignore[reportPrivateUsage] + returning = _current(edge, _packet(space, 200e-6, 1e6)) # pyright: ignore[reportPrivateUsage] assert leaving < 1e-20 assert returning > 1e3 * leaving @@ -284,15 +374,15 @@ def test_the_ramp_s_removal_stops_a_run_at_ten_budgets() -> None: space, design, 1e-3, 10.0, 0.01, np.arange(1), 2 * math.pi / 800e-9 ) clean = _packet(space, 0.0, 0.0) - edge.launch(clean, 0.0, noise=None, steps=None, reads=True, declared=False) + _launch(edge, clean, 0.0, noise=None, steps=None, reads=True, declared=False) filament = clean.copy() top = int(np.argmax(np.abs(np.asarray(space.k_x)))) filament[top] = 0.05 * float(np.max(np.abs(clean))) # content generated at the edge with warnings.catch_warnings(): warnings.simplefilter("ignore", BoundaryWarning) - edge.read(filament, 1e-4) + _read(edge, filament, 1e-4) with pytest.raises(BoundaryError, match="safety ramp has removed"): - edge.read(filament, 1e-3) + _read(edge, filament, 1e-3) assert edge.status == "stopped" @@ -337,7 +427,7 @@ def _launched(edge: TransverseEdge, state: np.ndarray, **options: Any) -> None: "reads": True, "declared": False, } - edge.launch(state, 0.0, **(arguments | options)) + _launch(edge, state, 0.0, **(arguments | options)) def _gaussian_line(space: LineGrid, centre: float, waist: float) -> np.ndarray: @@ -351,7 +441,7 @@ def test_a_launch_with_no_energy_is_never_read() -> None: _launched(edge, np.zeros((64, 1, 1), dtype=complex)) # Nothing to judge shares against: no read is due, and a read does nothing. assert edge.ceiling(0.0) == math.inf - edge.read(np.zeros((64, 1, 1), dtype=complex), 1e-3) + _read(edge, np.zeros((64, 1, 1), dtype=complex), 1e-3) assert edge.summary()["reads"] == 0 @@ -391,13 +481,13 @@ def test_input_noise_above_the_warning_leaves_the_edge_insensitive() -> None: launch = cast("float", record["launch_energy"]) scale = math.sqrt(above * launch / _energy(edge, packet)) with pytest.warns(BoundaryWarning, match="light reached the transverse edge"): - edge.read(beam + noise + scale * packet, 1e-3) + _read(edge, beam + noise + scale * packet, 1e-3) assert edge.status == "insensitive" def _energy(edge: TransverseEdge, state: np.ndarray) -> float: """Return a state's energy in the edge's own measure.""" - return float(np.sum(edge._column_power(state))) # pyright: ignore[reportPrivateUsage] + return float(np.sum(edge._column_power(edge.reduction.column_power(state)))) # pyright: ignore[reportPrivateUsage] def _scaled(edge: TransverseEdge, state: np.ndarray, share: float) -> np.ndarray: @@ -426,13 +516,13 @@ def test_the_ramp_s_warning_keeps_an_earlier_verdict() -> None: ) state = beam + filament + _scaled(edge, _gaussian_line(space, 196e-6, 2e-6), 3e-5) with pytest.warns(BoundaryWarning, match="light reached the transverse edge"): - edge.read(state, 1e-4) + _read(edge, state, 1e-4) assert edge.status == "warned" with pytest.warns(BoundaryWarning, match="safety ramp has removed"): - edge.read(state, 2e-4) + _read(edge, state, 2e-4) assert edge.status == "warned" # Without a collar there is no current across an onset to accumulate. - edge.step(state, 2e-4) + _step(edge, state, 2e-4) assert edge.summary()["current"] == 0.0 @@ -474,7 +564,7 @@ def test_a_stop_on_a_slab_names_a_y_half_width() -> None: edge = _edge(space, None) _launched(edge, _slab(space, 0.0, 20e-6, 0.0)) with pytest.raises(BoundaryError, match="a y half-width of at least") as caught: - edge.read(_scaled(edge, _slab(space, 195e-6, 4e-6, 0.0), 1e-2), 1e-3) + _read(edge, _scaled(edge, _slab(space, 195e-6, 4e-6, 0.0), 1e-2), 1e-3) assert "n_y of at least" in str(caught.value) @@ -518,10 +608,10 @@ def packet(centre: float, waist: float, kappa: float) -> np.ndarray: _launched(edge, beam) inward = packet(230e-6, 10e-6, 3e5) with pytest.warns(BoundaryWarning, match="requested collar returns"): - edge.read(beam + _scaled(edge, inward, 1e-5), 1e-4) + _read(edge, beam + _scaled(edge, inward, 1e-5), 1e-4) assert edge.status == "warned" with pytest.raises(BoundaryError, match="absorbing too little"): - edge.read(beam + _scaled(edge, inward, 1e-2), 2e-4) + _read(edge, beam + _scaled(edge, inward, 1e-2), 2e-4) assert edge.status == "stopped" assert cast("float", edge.summary()["spatial_removed"]) > 0.0 @@ -534,8 +624,8 @@ def test_a_collar_narrower_than_its_strip_s_resolution_reads_no_return() -> None edge = _edge(space, _collar(space, 2e-6)) _launched(edge, _gaussian_line(space, 0.0, 20e-6)) returning = _gaussian_line(space, 195e-6, 10e-6) * 10 - assert edge._strip_inward(returning) == (0.0, 0.0) # pyright: ignore[reportPrivateUsage] - assert edge._onset_current(returning) == 0.0 # pyright: ignore[reportPrivateUsage] + assert edge._strip_shares(edge.reduction.strip(returning)) == (0.0, 0.0) # pyright: ignore[reportPrivateUsage] + assert _current(edge, returning) == 0.0 # pyright: ignore[reportPrivateUsage] def test_under_input_noise_a_collar_s_strip_is_judged_above_the_noise_s() -> None: @@ -548,7 +638,9 @@ def test_under_input_noise_a_collar_s_strip_is_judged_above_the_noise_s() -> Non warnings.simplefilter("ignore", BoundaryWarning) _launched(edge, beam + noise, noise=noise) floor = edge._strip_floor # pyright: ignore[reportPrivateUsage] - assert floor == pytest.approx(3 * edge._strip_inward(noise)[0]) # pyright: ignore[reportPrivateUsage] + assert floor == pytest.approx( + 3 * edge._strip_shares(edge.reduction.strip(noise))[0] + ) # pyright: ignore[reportPrivateUsage] assert floor > 0.0 @@ -565,78 +657,3 @@ def test_row_energy_sums_each_rows_squared_magnitude(dtype: type) -> None: sliced = rows[:, ::2] expected = np.sum(np.abs(sliced.astype(np.complex128)) ** 2, axis=1) np.testing.assert_allclose(_row_energy(sliced), expected, rtol=1e-6) - - -def _faint_planes(space: LineGrid, faint: float) -> np.ndarray: - """Return a beam in three of 32 frequency planes, `faint` of it in the others.""" - beam = _gaussian_line(space, 0.0, 20e-6) - weights = np.full(32, faint, dtype=float) - weights[10:13] = 1.0 - return beam * weights - - -def test_a_read_synthesises_only_the_planes_that_can_reach_its_resolution() -> None: - space = LineGrid(200e-6, 128) - edge = _edge(space, None) - state = _faint_planes(space, 1e-12) - _launched(edge, state) - flat = state.reshape(state.shape[0], -1) - kept = edge._occupied_planes(flat) # pyright: ignore[reportPrivateUsage] - assert kept.shape == (128, 3) - np.testing.assert_array_equal(kept, flat[:, 10:13]) - - -def test_a_read_that_leaves_planes_out_reads_low_by_at_most_its_bound() -> None: - # READ_RESOLUTION's promise, against a read of every plane: the faint planes hold - # the whole of a face's light, the case that loses the most. - space = LineGrid(200e-6, 128) - beam = _faint_planes(space, 0.0) - spread = _gaussian_line(space, 195e-6, 5e-6) * np.ones(32) - state = beam + 1e-8 * spread * (np.abs(np.arange(32) - 11) > 1) - full, leaving_out = _edge(space, None), _edge(space, None) - _launched(full, beam) - _launched(leaving_out, beam) - flat = state.reshape(state.shape[0], -1) - assert leaving_out._occupied_planes(flat).shape[1] == 3 # pyright: ignore[reportPrivateUsage] - full._occupied_planes = lambda rows: rows # type: ignore[method-assign] # pyright: ignore[reportPrivateUsage] - full.read(state, 1e-4) - leaving_out.read(state, 1e-4) - every = np.asarray(full.summary()["edge_max_raw"], dtype=float) - some = np.asarray(leaving_out.summary()["edge_max_raw"], dtype=float) - # An under-read to rounding (the kept planes' transforms are the same), by no more - # than the bound; the faint planes do reach the x+ face, so there is one to lose. - assert np.all(some <= every * (1 + 1e-12)) - assert np.max(every - some) <= READ_RESOLUTION * full.budget**2 - assert np.max(every - some) > 0.0 - - -def test_a_read_of_a_state_filling_its_planes_takes_no_copy() -> None: - space = LineGrid(200e-6, 128) - edge = _edge(space, None) - state = _faint_planes(space, 1.0) - _launched(edge, state) - flat = state.reshape(state.shape[0], -1) - assert edge._occupied_planes(flat) is flat # pyright: ignore[reportPrivateUsage] - - -def test_a_read_of_light_fainter_than_its_resolution_keeps_no_plane() -> None: - # Most of the launch has been absorbed: every plane is left out, and the read - # reports an empty edge rather than failing on an empty transform. - space = LineGrid(200e-6, 128) - edge = _edge(space, None) - beam = _faint_planes(space, 0.0) - _launched(edge, beam) - state = 1e-6 * beam - flat = state.reshape(state.shape[0], -1) - assert edge._occupied_planes(flat).shape == (128, 0) # pyright: ignore[reportPrivateUsage] - edge.read(state, 1e-4) - assert np.all(np.asarray(edge.summary()["edge_max_raw"], dtype=float) == 0.0) - - -def test_a_requested_collar_reads_every_plane() -> None: - # Its rate is credited as a removal, not judged against the warning. - space = LineGrid(200e-6, 128, collar_fraction=0.5) - edge = _edge(space, _collar(space, 1e-4)) - state = _faint_planes(space, 1e-12) - flat = state.reshape(state.shape[0], -1) - assert edge._occupied_planes(flat) is flat # pyright: ignore[reportPrivateUsage] diff --git a/tests/test_transverse_reads.py b/tests/test_transverse_reads.py index 68194957..fd9bf860 100644 --- a/tests/test_transverse_reads.py +++ b/tests/test_transverse_reads.py @@ -44,7 +44,7 @@ from solprop.pulses import GaussianPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, Multistep, PIController, RungeKutta -from solprop.transverse_monitor import TransverseEdge +from solprop.transverse_monitor import HostEdgeReduction BACKENDS = ("numpy_ref", "native") RELAXED = BoundaryCheck(budget=1.0, transverse_budget=1.0) @@ -408,13 +408,13 @@ def test_a_plane_the_step_already_read_reduces_nothing_more( length = packet.length planes = [length / 4, length / 2, length] calls = {"nodes": 0} - node_power = TransverseEdge._node_power + node_power = HostEdgeReduction.node_power - def counting(self: TransverseEdge, *args: Any, **keywords: Any) -> np.ndarray: + def counting(self: HostEdgeReduction, *args: Any, **keywords: Any) -> np.ndarray: calls["nodes"] += 1 return node_power(self, *args, **keywords) - monkeypatch.setattr(TransverseEdge, "_node_power", counting) + monkeypatch.setattr(HostEdgeReduction, "node_power", counting) schedule = FixedSchedule.uniform(0.0, length, length / 8, save_planes=planes) with warnings.catch_warnings(): warnings.simplefilter("ignore", BoundaryWarning) From c210b0838c863aa8a45386523371d30c8bf9fee9 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 13:42:14 +0000 Subject: [PATCH 019/122] Delete the native State and Workspace engine Nothing can reach it: the general engine runs every problem, and makes every check this engine's validator made. `fft`, `multiply_phase` and `compact_problem` stay in core.cpp, and `_core.State` keeps its name. OccupancyScatter and its ScatterView include had no other user. GeneralState's monitor comments deferred to the deleted class's, so they take over its text. Refs #88. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/include/solprop_native/core.hpp | 83 +- native/src/core.cpp | 1322 ------------------------ native/src/general.cpp | 6 +- native/tests/test_numerics.py | 3 +- 4 files changed, 21 insertions(+), 1393 deletions(-) diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index 2a3083b5..43fd006e 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -6,7 +6,6 @@ #include #include -#include #include "solprop_native/launch.hpp" @@ -110,11 +109,6 @@ struct OccupancySums { }; using ComplexView = Kokkos::View; -/** The histogram the boundary monitor's occupancy reading adds into, one slot per - walk-off bucket (solprop #440): duplicated per thread on the host, atomic on a - device. */ -using OccupancyScatter = - Kokkos::Experimental::ScatterView; using ConstComplexView = Kokkos::View; using RealView = Kokkos::View; using ConstRealView = Kokkos::View; @@ -724,66 +718,9 @@ enum class TableKind { phi, prefactor, rate, flux }; general modal route, a coupled flow nor a separable geometry. */ [[nodiscard]] bool compact_problem(const Problem& problem) noexcept; -/** Execution-space views, FFT plans, and scratch owned by one State. */ -class Workspace; class GeneralWorkspace; class GeneralBatchWorkspace; -/** Persistent single-column execution-space state, plans, and scratch storage. */ -class State { -public: - explicit State(Problem problem); - ~State(); - State(const State&) = delete; - State& operator=(const State&) = delete; - State(State&&) = delete; - State& operator=(State&&) = delete; - - /** Load the field and noise from host memory, one copy into the memory space. */ - void set_initial(std::span field, std::span noise); - [[nodiscard]] std::array trial_step(double dz, - const TrialScalars& scalars); - void finish(double dz); - void promote_fsal(); - void clear_fsal(); - void set_absorber_quiet(bool quiet); - /** - * Bind solprop's boundary monitor (SEP 21): its four time weights, row-major - * `(4, n_t)`, and its columns' quadrature by mode (empty for unit weights). From - * here on each split-step finish adds what it takes to an accumulator, which - * `monitor_reading` drains, and keeps the energies of the power it found. - */ - void bind_monitor(const std::vector& weights, - const std::vector& column_weights); - void unbind_monitor() noexcept; - /** - * The bound monitor's reading of the accepted state: `sum_t P`, the four weighted - * sums `sum_t w_k P`, what the split collar took since the last reading, and - * `sum_t P` and the two guards' and the collar's sums of the power the last step's - * split found (zeros before the first such step). - */ - [[nodiscard]] std::vector monitor_reading(); - /** - * Bind the split collar's walk-off bucket of each frequency bin, `count` buckets - * (solprop #440): from here on `monitor_reading` also returns the state's energy - * per bucket, the fastest first. - */ - void bind_occupancy(const std::vector& buckets, std::size_t count); - [[nodiscard]] std::vector field_to_host() const; - /** The field into `destination`, of the field's size: one copy from the space. */ - void field_to_host(std::span destination) const; - [[nodiscard]] std::map memory_ledger() const; - [[nodiscard]] CpuSizing execution_sizing() const; - [[nodiscard]] double planning_seconds() const noexcept; - [[nodiscard]] bool fsal_ready() const noexcept; - [[nodiscard]] std::size_t rk_source_evaluations() const noexcept; - [[nodiscard]] bool closed() const noexcept; - void close() noexcept; - -private: - std::unique_ptr workspace_; -}; - /** Multicolumn general state with batched FFTs and chunked node transforms. */ class GeneralState { public: @@ -918,13 +855,27 @@ class GeneralState { void promote_fsal(); void clear_fsal(); void set_absorber_quiet(bool quiet); - /** As `State::bind_monitor`; the columns' weights are by mode. */ + /** + * Bind solprop's boundary monitor (SEP 21): its four time weights, row-major + * `(4, n_t)`, and its columns' quadrature by mode (empty for unit weights). From + * here on each split-step finish adds what it takes to an accumulator, which + * `monitor_reading` drains, and keeps the energies of the power it found. + */ void bind_monitor(const std::vector& weights, const std::vector& column_weights); void unbind_monitor() noexcept; - /** As `State::monitor_reading`, over every column of the state. */ + /** + * The bound monitor's reading of the accepted state, over every column: `sum_t P`, + * the four weighted sums `sum_t w_k P`, what the split collar took since the last + * reading, and `sum_t P` and the two guards' and the collar's sums of the power the + * last step's split found (zeros before the first such step). + */ [[nodiscard]] std::vector monitor_reading(); - /** As `State::bind_occupancy`, the buckets by mode, `(n_modes, n_omega)`. */ + /** + * Bind the split collar's walk-off bucket of each frequency bin, by mode + * `(n_modes, n_omega)`, `count` buckets (solprop #440): from here on + * `monitor_reading` also returns the state's energy per bucket, the fastest first. + */ void bind_occupancy(const std::vector& buckets, std::size_t count); [[nodiscard]] double quadrature_error() const; [[nodiscard]] double quadrature_peak(); diff --git a/native/src/core.cpp b/native/src/core.cpp index 29f73d1a..d0538402 100644 --- a/native/src/core.cpp +++ b/native/src/core.cpp @@ -3,20 +3,15 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "solprop_native/core.hpp" -#include "solprop_native/fp.hpp" #include #include #include #include -#include #include #include -#include -#include #include -#include #include #if defined(KOKKOS_ENABLE_OPENMP) @@ -372,1323 +367,6 @@ std::vector reference_stream_copies(const std::size_t elements, return seconds; } -namespace { - -using RealPlan = KokkosFFT::Plan; -using InverseRealPlan = KokkosFFT::Plan; -using ComplexPlan = KokkosFFT::Plan; -using ByteView = Kokkos::View; -using IndexView = Kokkos::View; -using ComplexView2 = Kokkos::View; - -KOKKOS_INLINE_FUNCTION Complex exponential(const Complex value) { - const double magnitude = Kokkos::exp(value.real()); - return Complex(magnitude * Kokkos::cos(value.imag()), - magnitude * Kokkos::sin(value.imag())); -} - -// A host span as an unmanaged view, for one copy between it and a memory-space view -// of the same extent: no staging mirror, no element loop. A span whose data is not -// aligned as `Complex` (a NumPy buffer need not be) is staged in an aligned vector. -void upload_span(const ExecutionSpace& execution_space, const ComplexView& destination, - const std::span source) { - if (destination.extent(0) != source.size()) - throw std::invalid_argument("fixed problem array has the wrong extent"); - if (reinterpret_cast(source.data()) % alignof(Complex) != 0) { - const std::vector aligned(source.begin(), source.end()); - upload_span(execution_space, destination, aligned); - execution_space.fence("solprop_native unaligned upload"); - return; - } - const Kokkos::View> - host(source.data(), source.size()); - Kokkos::deep_copy(execution_space, destination, host); -} - -template -void copy_vector(const ExecutionSpace& execution_space, const View& destination, - const std::vector& source) { - if (destination.extent(0) != source.size()) { - throw std::invalid_argument("fixed problem array has the wrong extent"); - } - auto mirror = Kokkos::create_mirror_view(destination); - for (std::size_t index = 0; index < source.size(); ++index) { - mirror(index) = source[index]; - } - Kokkos::deep_copy(execution_space, destination, mirror); -} - -void require_extent(const std::size_t actual, const std::size_t expected, - const char* name) { - if (actual != expected) { - throw std::invalid_argument(std::string(name) + " has the wrong extent"); - } -} - -void require_problem(const Problem& problem) { - if (problem.n_omega == 0 || problem.n_t == 0 || problem.n_to == 0) { - throw std::invalid_argument("FFT extents must be positive"); - } - if (problem.n_stages == 0) { - throw std::invalid_argument("the tableau must contain a stage"); - } - require_extent(problem.crop_index.size(), problem.n_omega, "crop_index"); - require_extent(problem.active_mask.size(), problem.n_omega, "active_mask"); - require_extent(problem.norm_mask.size(), problem.n_omega, "norm_mask"); - require_extent(problem.omega_window.size(), problem.n_omega, "omega_window"); - require_extent(problem.time_window.size(), problem.n_to, "time_window"); - require_extent(problem.phi_terms.size(), problem.n_phi_terms * problem.n_omega, - "phi_terms"); - require_extent(problem.prefactor_terms.size(), - problem.n_prefactor_terms * problem.n_omega, "prefactor_terms"); - require_extent(problem.tableau_matrix.size(), problem.n_stages * problem.n_stages, - "tableau_matrix"); - require_extent(problem.tableau_nodes.size(), problem.n_stages, "tableau_nodes"); - require_extent(problem.solution_weights.size(), problem.n_stages, "solution_weights"); - require_extent(problem.error_weights.size(), problem.n_stages, "error_weights"); - require_extent(problem.carrier.size(), problem.n_to, "carrier"); - if (problem.in_stage_absorber) { - require_extent(problem.absorber_fine.size(), problem.n_to, "absorber_fine"); - } - if (problem.split_step_absorber) { - require_extent(problem.absorber_coarse.size(), problem.n_t, "absorber_coarse"); - } - const std::size_t fine_spectrum = - problem.family == FieldFamily::field_real ? problem.n_to / 2 + 1 : problem.n_to; - std::vector seen(fine_spectrum, false); - for (const auto index : problem.crop_index) { - if (index >= fine_spectrum) { - throw std::invalid_argument("crop_index lies outside the fine spectrum"); - } - if (seen[index]) { - throw std::invalid_argument("crop_index contains a duplicate fine-spectrum bin"); - } - seen[index] = true; - } -} - -} // namespace - -class Workspace { -public: - explicit Workspace(Problem problem) : problem_(std::move(problem)) { - require_problem(problem_); - compact_ = compact_problem(problem_); - if (compact_) { - kernel_pool_.compact(); - fft_pool_.compact(); - } - execution_space_ = kernel_pool_.get(CpuOperation::stage, problem_.n_omega); - response_execution_space_ = - kernel_pool_.get(CpuOperation::response, problem_.n_to, problem_.n_to); - fft_execution_space_ = fft_pool_.get(CpuOperation::fft, problem_.n_to); - coarse_fft_execution_space_ = fft_pool_.get(CpuOperation::fft, problem_.n_t); - allocate(); - upload(); - const auto started = std::chrono::steady_clock::now(); - plan(); - planning_seconds_ = - std::chrono::duration(std::chrono::steady_clock::now() - started) - .count(); - execution_space_.fence("solprop_native construction"); - } - - void set_initial(const std::span field, - const std::span noise) { - require_open(); - require_extent(field.size(), problem_.n_omega, "initial field"); - if (!noise.empty()) - require_extent(noise.size(), problem_.n_omega, "noise"); - upload_span(execution_space_, current_, field); - if (noise.empty()) { - if (problem_.modified_noise) - Kokkos::deep_copy(execution_space_, noise_, Complex{}); - has_noise_ = false; - } else { - if (!problem_.modified_noise) - throw std::invalid_argument( - "a native noise field is valid only for modified noise"); - upload_span(execution_space_, noise_, noise); - has_noise_ = true; - } - mask(current_); - execution_space_.fence("solprop_native set_initial"); - initialized_ = true; - rk_source_evaluations_ = 0; - clear_fsal(); - } - - std::array trial_step(const double dz, const TrialScalars& scalars) { - require_open(); - if (!initialized_) - throw std::logic_error("no initial field has been loaded"); - require_trial_scalars(scalars); - upload_trial(scalars); - std::size_t first = 0; - if (has_recycled_) { - reuse_fsal(); - first = 1; - } - for (std::size_t stage = first; stage < problem_.n_stages; ++stage) { - evaluate_stage(stage, dz); - } - accept_and_score(dz); - pending_fsal_ = problem_.first_same_as_last && !problem_.split_step_absorber; - execution_space_.fence("solprop_native trial boundary"); - const auto host = - Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, partials_); - return {host(0), host(1), host(2)}; - } - - void finish(const double dz) { - require_open(); - if (!initialized_) - throw std::logic_error("no initial field has been loaded"); - if (problem_.split_step_absorber) { - split_step(dz); - } else { - mask(advanced_); - } - std::swap(current_, advanced_); - execution_space_.fence("solprop_native accepted-step boundary"); - } - - void promote_fsal() { - require_open(); - if (!pending_fsal_) - return; - const auto stages = stages_; - const auto whole = whole_propagator_; - const auto recycled = recycled_; - const std::size_t last = problem_.n_stages - 1; - launch::parallel_for( - "solprop_native.stage.promote_fsal", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_omega), - KOKKOS_LAMBDA(const std::size_t index) { - recycled(index) = whole(index) * stages(last, index); - }); - has_recycled_ = true; - pending_fsal_ = false; - } - - void clear_fsal() { - has_recycled_ = false; - pending_fsal_ = false; - } - - // The boundary monitor found the temporal collar quiet (solprop SEP 21): its - // in-stage term, below the monitor's threshold, is left out of the stages. - void set_absorber_quiet(const bool quiet) noexcept { absorber_quiet_ = quiet; } - - void bind_monitor(const std::vector& weights, - const std::vector& column_weights); - void unbind_monitor() noexcept { monitor_bound_ = false; } - void bind_occupancy(const std::vector& buckets, std::size_t count); - std::vector monitor_reading(); - - std::vector field_to_host() const { - std::vector result(problem_.n_omega); - field_to_host(result); - return result; - } - - // One copy from the memory space into the caller's buffer (solprop SEP 39 section - // 6.7: no staging mirror and no element loop). - void field_to_host(const std::span destination) const { - require_open(); - if (!initialized_) - throw std::logic_error("no initial field has been loaded"); - require_extent(destination.size(), problem_.n_omega, "field destination"); - execution_space_.fence("solprop_native field_to_host"); - const Kokkos::View> - host(destination.data(), destination.size()); - // On the stage's instance, so that a compact problem keeps its team. - Kokkos::deep_copy(execution_space_, host, current_); - execution_space_.fence("solprop_native field_to_host copy"); - } - - std::map memory_ledger() const; - CpuSizing execution_sizing() const { - auto result = kernel_pool_.sizing(); - result.merge(fft_pool_.sizing()); - return result; - } - /** The team a compact problem's FFTW loops take (`CpuTeamScope`); zero otherwise. */ - [[nodiscard]] int cpu_team() const noexcept { - return compact_ ? std::max(kernel_pool_.widest(), fft_pool_.widest()) : 0; - } - double planning_seconds() const noexcept { return planning_seconds_; } - bool fsal_ready() const noexcept { return has_recycled_; } - std::size_t rk_source_evaluations() const noexcept { return rk_source_evaluations_; } - bool closed() const noexcept { return closed_; } - void close() noexcept; - - // NVCC extended device lambdas require their enclosing member to be public. - // Workspace is translation-unit-local, so this does not widen the native API. - std::size_t fine_spectrum_size() const { - return problem_.family == FieldFamily::field_real ? problem_.n_to / 2 + 1 - : problem_.n_to; - } - std::size_t projected_spectrum_size() const { - return problem_.family == FieldFamily::envelope ? problem_.n_to - : problem_.n_to / 2 + 1; - } - void require_open() const { - if (closed_) - throw std::logic_error("native state is closed"); - } - void require_trial_scalars(const TrialScalars& scalars) const; - void allocate(); - void upload(); - void upload_trial(const TrialScalars& scalars); - void plan(); - void reuse_fsal(); - void evaluate_stage(std::size_t stage, double dz); - void inverse_transform(); - void response(std::size_t stage); - void forward_response(); - void form_absorber(); - void forward_absorber(); - [[nodiscard]] bool absorbing() const noexcept { - return problem_.in_stage_absorber && !absorber_quiet_; - } - bool absorber_quiet_ = false; - void finish_stage(std::size_t stage); - void accept_and_score(double dz); - void mask(const ComplexView& values); - void split_step(double dz); - void plan_coarse_inverse(); - void add_split_removal(double dz); - [[nodiscard]] bool monitoring_split() const noexcept { - return monitor_bound_ && problem_.split_step_absorber; - } - // solprop's boundary monitor (SEP 21, ADR-057): its four time weights `(4, n_t)` - // and the column's quadrature weight, and what the split-step collar took since the - // last reading beside one split's sum. Allocated at the first bind, with the coarse - // pair a split collar would have planned, and kept. - bool monitor_bound_{}; - double monitor_column_weight_ = 1.0; - - Problem problem_; - bool compact_{}; - CpuExecutionPool kernel_pool_{CpuPool::kernels}; - CpuExecutionPool fft_pool_{CpuPool::fft}; - mutable ExecutionSpace execution_space_; - ExecutionSpace response_execution_space_, fft_execution_space_, - coarse_fft_execution_space_; - bool closed_{}; - bool initialized_{}; - bool has_noise_{}; - bool has_recycled_{}; - std::size_t rk_source_evaluations_{}; - bool pending_fsal_{}; - double planning_seconds_{}; - ComplexView current_, advanced_, solution_, error_, recycled_, noise_; - ComplexView whole_propagator_, fine_spectrum_, projected_spectrum_; - ComplexView absorber_spectrum_, carrier_, fine_time_complex_; - ComplexView projected_complex_, absorber_complex_, coarse_spectrum_; - ComplexView coarse_time_complex_; - ComplexView2 stages_, phi_terms_, prefactor_terms_; - RealView partials_, omega_window_, time_window_, absorber_fine_; - RealView absorber_coarse_, fine_time_real_, projected_real_, absorber_real_; - RealView coarse_time_real_, tableau_, stage_phi_delta_, stage_phi_at_; - RealView monitor_weights_; - Kokkos::View monitor_removed_; - // One split's sums, and the last accepted step's, which a reading returns. - Kokkos::View monitor_split_sum_, monitor_found_; - // The split collar's walk-off bucket of each bin and the state's energy per bucket, - // which a reading returns for the crossing limit (solprop #440); empty unbound. - ByteView occupancy_buckets_; - RealView occupancy_; - OccupancyScatter occupancy_scatter_; - RealView stage_prefactor_, stage_density_, stage_taper_; - ByteView active_mask_, norm_mask_; - IndexView crop_index_; - std::unique_ptr inverse_real_plan_; - std::unique_ptr inverse_complex_plan_; - std::unique_ptr forward_real_plan_; - std::unique_ptr forward_complex_plan_; - std::unique_ptr absorber_real_plan_; - std::unique_ptr absorber_complex_plan_; - std::unique_ptr coarse_inverse_real_plan_; - std::unique_ptr coarse_inverse_complex_plan_; - std::unique_ptr coarse_forward_real_plan_; - std::unique_ptr coarse_forward_complex_plan_; -}; - -void Workspace::require_trial_scalars(const TrialScalars& scalars) const { - require_extent(scalars.phi_delta.size(), - (problem_.n_stages + 1) * problem_.n_phi_terms, "phi_delta"); - require_extent(scalars.phi_at.size(), problem_.n_stages * problem_.n_phi_terms, - "phi_at"); - require_extent(scalars.prefactor.size(), - problem_.n_stages * problem_.n_prefactor_terms, "prefactor"); - require_extent(scalars.density.size(), problem_.n_stages, "density"); - require_extent(scalars.taper.size(), problem_.n_stages, "taper"); -} - -void Workspace::allocate() { - const auto n = problem_.n_omega; - current_ = ComplexView("solprop_native.state.current", n); - advanced_ = ComplexView("solprop_native.state.advanced", n); - stages_ = ComplexView2("solprop_native.step.stages", problem_.n_stages, n); - solution_ = ComplexView("solprop_native.step.solution", n); - error_ = ComplexView("solprop_native.step.error", n); - recycled_ = ComplexView("solprop_native.step.recycled", n); - if (problem_.modified_noise) - noise_ = ComplexView("solprop_native.noise", n); - whole_propagator_ = ComplexView("solprop_native.step.whole_propagator", n); - partials_ = RealView("solprop_native.step.error_partials", 3); - crop_index_ = IndexView("solprop_native.transform.crop_index", n); - active_mask_ = ByteView("solprop_native.mask.active", n); - norm_mask_ = ByteView("solprop_native.mask.norm", n); - omega_window_ = RealView("solprop_native.window.omega", n); - time_window_ = RealView("solprop_native.window.time", problem_.n_to); - carrier_ = ComplexView("solprop_native.kerr.carrier", problem_.n_to); - phi_terms_ = ComplexView2("solprop_native.linear.phi_terms", problem_.n_phi_terms, n); - prefactor_terms_ = ComplexView2("solprop_native.response.prefactor_terms", - problem_.n_prefactor_terms, n); - tableau_ = - RealView("solprop_native.step.tableau", problem_.n_stages * problem_.n_stages); - stage_phi_delta_ = RealView("solprop_native.step.phi_delta", - (problem_.n_stages + 1) * problem_.n_phi_terms); - stage_phi_at_ = - RealView("solprop_native.step.phi_at", problem_.n_stages * problem_.n_phi_terms); - stage_prefactor_ = RealView("solprop_native.step.prefactor", - problem_.n_stages * problem_.n_prefactor_terms); - stage_density_ = RealView("solprop_native.step.density", problem_.n_stages); - stage_taper_ = RealView("solprop_native.step.taper", problem_.n_stages); - fine_spectrum_ = - ComplexView("solprop_native.fft.fine_spectrum", fine_spectrum_size()); - projected_spectrum_ = - ComplexView("solprop_native.fft.projected_spectrum", projected_spectrum_size()); - absorber_spectrum_ = - ComplexView("solprop_native.fft.absorber_spectrum", projected_spectrum_size()); - if (problem_.family == FieldFamily::field_real) { - fine_time_real_ = RealView("solprop_native.fft.modal_time_real", problem_.n_to); - } else { - fine_time_complex_ = - ComplexView("solprop_native.fft.modal_time_complex", problem_.n_to); - } - if (problem_.family == FieldFamily::envelope) { - projected_complex_ = - ComplexView("solprop_native.response.projected_complex", problem_.n_to); - absorber_complex_ = - ComplexView("solprop_native.absorber.fine_complex", problem_.n_to); - } else { - projected_real_ = RealView("solprop_native.response.projected_real", problem_.n_to); - absorber_real_ = RealView("solprop_native.absorber.fine_real", problem_.n_to); - } - absorber_fine_ = - RealView("solprop_native.absorber.rate_fine", problem_.absorber_fine.size()); - absorber_coarse_ = - RealView("solprop_native.absorber.rate_coarse", problem_.absorber_coarse.size()); - if (!problem_.split_step_absorber) - return; - coarse_spectrum_ = ComplexView("solprop_native.absorber.coarse_spectrum", n); - if (problem_.family == FieldFamily::envelope) { - coarse_time_complex_ = - ComplexView("solprop_native.absorber.coarse_time_complex", problem_.n_t); - } else { - coarse_time_real_ = - RealView("solprop_native.absorber.coarse_time_real", problem_.n_t); - } -} - -void Workspace::upload() { - copy_vector(execution_space_, crop_index_, problem_.crop_index); - copy_vector(execution_space_, active_mask_, problem_.active_mask); - copy_vector(execution_space_, norm_mask_, problem_.norm_mask); - copy_vector(execution_space_, omega_window_, problem_.omega_window); - copy_vector(execution_space_, time_window_, problem_.time_window); - copy_vector(execution_space_, carrier_, problem_.carrier); - copy_vector(execution_space_, tableau_, problem_.tableau_matrix); - copy_vector(execution_space_, absorber_fine_, problem_.absorber_fine); - copy_vector(execution_space_, absorber_coarse_, problem_.absorber_coarse); - auto phi = Kokkos::create_mirror_view(phi_terms_); - for (std::size_t term = 0; term < problem_.n_phi_terms; ++term) { - for (std::size_t index = 0; index < problem_.n_omega; ++index) { - phi(term, index) = problem_.phi_terms[term * problem_.n_omega + index]; - } - } - Kokkos::deep_copy(execution_space_, phi_terms_, phi); - auto prefactor = Kokkos::create_mirror_view(prefactor_terms_); - for (std::size_t term = 0; term < problem_.n_prefactor_terms; ++term) { - for (std::size_t index = 0; index < problem_.n_omega; ++index) { - prefactor(term, index) = - problem_.prefactor_terms[term * problem_.n_omega + index]; - } - } - Kokkos::deep_copy(execution_space_, prefactor_terms_, prefactor); -} - -void Workspace::upload_trial(const TrialScalars& scalars) { - copy_vector(execution_space_, stage_phi_delta_, scalars.phi_delta); - copy_vector(execution_space_, stage_phi_at_, scalars.phi_at); - copy_vector(execution_space_, stage_prefactor_, scalars.prefactor); - copy_vector(execution_space_, stage_density_, scalars.density); - copy_vector(execution_space_, stage_taper_, scalars.taper); -} - -void Workspace::plan() { - constexpr int axis = -1; - if (problem_.family == FieldFamily::field_real) { - inverse_real_plan_ = std::make_unique( - fft_execution_space_, fine_spectrum_, fine_time_real_, - KokkosFFT::Direction::backward, axis); - } else { - inverse_complex_plan_ = std::make_unique( - fft_execution_space_, fine_spectrum_, fine_time_complex_, - KokkosFFT::Direction::backward, axis); - } - if (problem_.family == FieldFamily::envelope) { - forward_complex_plan_ = std::make_unique( - fft_execution_space_, projected_complex_, projected_spectrum_, - KokkosFFT::Direction::forward, axis); - absorber_complex_plan_ = std::make_unique( - fft_execution_space_, absorber_complex_, absorber_spectrum_, - KokkosFFT::Direction::forward, axis); - } else { - forward_real_plan_ = std::make_unique( - fft_execution_space_, projected_real_, projected_spectrum_, - KokkosFFT::Direction::forward, axis); - absorber_real_plan_ = std::make_unique( - fft_execution_space_, absorber_real_, absorber_spectrum_, - KokkosFFT::Direction::forward, axis); - } - if (!problem_.split_step_absorber) - return; - plan_coarse_inverse(); - if (problem_.family == FieldFamily::envelope) { - coarse_forward_complex_plan_ = std::make_unique( - coarse_fft_execution_space_, coarse_time_complex_, coarse_spectrum_, - KokkosFFT::Direction::forward, axis); - } else { - coarse_forward_real_plan_ = std::make_unique( - coarse_fft_execution_space_, coarse_time_real_, coarse_spectrum_, - KokkosFFT::Direction::forward, axis); - } -} - -// The coarse inverse transform on its buffers, which the split-step collar and the -// boundary monitor read; allocated here when the constructor did not. -void Workspace::plan_coarse_inverse() { - constexpr int axis = -1; - if (coarse_spectrum_.extent(0) == 0) - coarse_spectrum_ = - ComplexView("solprop_native.absorber.coarse_spectrum", problem_.n_omega); - if (problem_.family == FieldFamily::envelope) { - if (coarse_time_complex_.extent(0) == 0) - coarse_time_complex_ = - ComplexView("solprop_native.absorber.coarse_time_complex", problem_.n_t); - coarse_inverse_complex_plan_ = std::make_unique( - coarse_fft_execution_space_, coarse_spectrum_, coarse_time_complex_, - KokkosFFT::Direction::backward, axis); - } else { - if (coarse_time_real_.extent(0) == 0) - coarse_time_real_ = - RealView("solprop_native.absorber.coarse_time_real", problem_.n_t); - coarse_inverse_real_plan_ = std::make_unique( - coarse_fft_execution_space_, coarse_spectrum_, coarse_time_real_, - KokkosFFT::Direction::backward, axis); - } -} - -// What one split takes of the power it finds, `sum P (1 - exp(-alpha h))`, added to -// the run's accumulator in the memory space (solprop SEP 21, ADR-057): the monitor's -// reading drains it at a check, so every accepted step is counted, checked or not. The -// same pass keeps `sum P'` and the guards' and the collar's sums of `P'`, the power -// found halfway through the split's factor, `P exp(-alpha h / 2)`, which the monitor -// judges the occupancies on: the symmetric splitting's state at the step's end. -void Workspace::add_split_removal(const double dz) { - const auto rate = absorber_coarse_; - const auto weights = monitor_weights_; - const double weight = monitor_column_weight_; - const auto n_t = problem_.n_t; - const Kokkos::RangePolicy samples(execution_space_, 0, n_t); - if (problem_.family == FieldFamily::envelope) { - const auto time = coarse_time_complex_; - launch::parallel_reduce( - "solprop_native.monitor.split_removal_complex", samples, - KOKKOS_LAMBDA(const std::size_t index, SplitSums& update) { - const Complex value = time(index); - const double power = - weight * (value.real() * value.real() + value.imag() * value.imag()); - update.values[0] += power * -Kokkos::expm1(-rate(index) * dz); - const double found = Kokkos::exp(-0.5 * rate(index) * dz) * power; - update.values[1] += found; - update.values[2] += weights(index) * found; - update.values[3] += weights(n_t + index) * found; - update.values[4] += weights(2 * n_t + index) * found; - }, - Kokkos::Sum(monitor_split_sum_)); - } else { - const auto time = coarse_time_real_; - launch::parallel_reduce( - "solprop_native.monitor.split_removal_real", samples, - KOKKOS_LAMBDA(const std::size_t index, SplitSums& update) { - const double power = weight * time(index) * time(index); - update.values[0] += power * -Kokkos::expm1(-rate(index) * dz); - const double found = Kokkos::exp(-0.5 * rate(index) * dz) * power; - update.values[1] += found; - update.values[2] += weights(index) * found; - update.values[3] += weights(n_t + index) * found; - update.values[4] += weights(2 * n_t + index) * found; - }, - Kokkos::Sum(monitor_split_sum_)); - } - const auto removed = monitor_removed_; - const auto split = monitor_split_sum_; - const auto found = monitor_found_; - launch::parallel_for( - "solprop_native.monitor.split_removal_add", - Kokkos::RangePolicy(execution_space_, 0, 1), - KOKKOS_LAMBDA(const std::size_t) { - removed() += split().values[0]; - found() = split(); - }); -} - -// Bind solprop's boundary monitor (SEP 21): its four time weights, row-major `(4, -// n_t)`, and the column's quadrature (empty for a unit weight). The first bind -// allocates its few views, and the coarse pair when no split collar planned one; -// the split's accumulator starts at zero. -void Workspace::bind_monitor(const std::vector& weights, - const std::vector& column_weights) { - require_open(); - require_extent(weights.size(), 4 * problem_.n_t, "monitor weights"); - if (!column_weights.empty()) - require_extent(column_weights.size(), 1, "monitor column weights"); - if (monitor_weights_.extent(0) == 0) { - monitor_weights_ = RealView("solprop_native.monitor.weights", 4 * problem_.n_t); - monitor_removed_ = - Kokkos::View("solprop_native.monitor.removed"); - monitor_split_sum_ = - Kokkos::View("solprop_native.monitor.split_sum"); - monitor_found_ = - Kokkos::View("solprop_native.monitor.split_found"); - if (!coarse_inverse_complex_plan_ && !coarse_inverse_real_plan_) - plan_coarse_inverse(); - } - copy_vector(execution_space_, monitor_weights_, weights); - monitor_column_weight_ = column_weights.empty() ? 1.0 : column_weights[0]; - Kokkos::deep_copy(execution_space_, monitor_removed_, 0.0); - Kokkos::deep_copy(execution_space_, monitor_found_, SplitSums{}); - execution_space_.fence("solprop_native monitor bind"); - monitor_bound_ = true; -} - -// The bound monitor's reading of the accepted state: `sum_t P`, the four weighted -// sums and the split's accumulator, which this drains (solprop SEP 21, ADR-057). -std::vector Workspace::monitor_reading() { - require_open(); - if (!initialized_) - throw std::logic_error("no initial field has been loaded"); - if (!monitor_bound_) - throw std::logic_error("no boundary monitor is bound"); - const auto current = current_; - const auto coarse = coarse_spectrum_; - const auto n_t = problem_.n_t; - const double inverse_n = 1.0 / static_cast(n_t); - launch::parallel_for( - "solprop_native.monitor.spectrum", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_omega), - KOKKOS_LAMBDA(const std::size_t index) { - coarse(index) = inverse_n * current(index); - }); - const auto weights = monitor_weights_; - const double weight = monitor_column_weight_; - MonitorSums sums; - const Kokkos::RangePolicy samples(execution_space_, 0, n_t); - if (problem_.family == FieldFamily::envelope) { - KokkosFFT::execute(*coarse_inverse_complex_plan_, coarse_spectrum_, - coarse_time_complex_, KokkosFFT::Normalization::none); - const auto time = coarse_time_complex_; - launch::parallel_reduce( - "solprop_native.monitor.sums", samples, - KOKKOS_LAMBDA(const std::size_t index, MonitorSums& update) { - const Complex value = time(index); - const double power = - weight * (value.real() * value.real() + value.imag() * value.imag()); - update.values[0] += power; - for (std::size_t region = 0; region < 4; ++region) - update.values[region + 1] += weights(region * n_t + index) * power; - }, - Kokkos::Sum(sums)); - } else { - KokkosFFT::execute(*coarse_inverse_real_plan_, coarse_spectrum_, coarse_time_real_, - KokkosFFT::Normalization::none); - const auto time = coarse_time_real_; - launch::parallel_reduce( - "solprop_native.monitor.sums", samples, - KOKKOS_LAMBDA(const std::size_t index, MonitorSums& update) { - const double power = weight * time(index) * time(index); - update.values[0] += power; - for (std::size_t region = 0; region < 4; ++region) - update.values[region + 1] += weights(region * n_t + index) * power; - }, - Kokkos::Sum(sums)); - } - double removed = 0.0; - SplitSums found; - if (problem_.split_step_absorber) { - execution_space_.fence("solprop_native monitor reading"); - removed = - Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, monitor_removed_)(); - found = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, monitor_found_)(); - Kokkos::deep_copy(execution_space_, monitor_removed_, 0.0); - } - std::vector reading{sums.values[0], sums.values[1], sums.values[2], - sums.values[3], sums.values[4], removed, - found.values[1], found.values[2], found.values[3], - found.values[4]}; - if (occupancy_.extent(0) != 0) { - // The state's energy per walk-off bucket, beside the five. - const auto buckets = occupancy_buckets_; - auto scatter = occupancy_scatter_; - // `contribute` adds the per-thread copies of a host build into the view: each - // reading starts the view, as `reset` starts the copies, from zero (a device - // build's atomic scatter adds into the view itself). - Kokkos::deep_copy(execution_space_, occupancy_, 0.0); - scatter.reset(execution_space_); - launch::parallel_for( - "solprop_native.monitor.occupancy", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_omega), - KOKKOS_LAMBDA(const std::size_t index) { - const Complex value = current(index); - auto access = scatter.access(); - access(buckets(index)) += - weight * (value.real() * value.real() + value.imag() * value.imag()); - }); - Kokkos::Experimental::contribute(execution_space_, occupancy_, scatter); - const auto host = - Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, occupancy_); - reading.insert(reading.end(), host.data(), host.data() + host.extent(0)); - } - execution_space_.fence("solprop_native monitor reading"); - return reading; -} - -// Bind the split collar's walk-off bucket of each bin (solprop #440): from here on a -// monitor reading also returns the state's energy per bucket. The views are allocated -// at the first bind and kept. -void Workspace::bind_occupancy(const std::vector& buckets, - const std::size_t count) { - require_open(); - require_extent(buckets.size(), problem_.n_omega, "occupancy buckets"); - if (count == 0 || count > 256) - throw std::invalid_argument("the occupancy needs between 1 and 256 buckets"); - for (const auto bucket : buckets) - if (bucket >= count) - throw std::invalid_argument("an occupancy bucket lies past the bucket count"); - if (occupancy_.extent(0) != count) { - occupancy_buckets_ = - ByteView("solprop_native.monitor.occupancy_buckets", problem_.n_omega); - occupancy_ = RealView("solprop_native.monitor.occupancy", count); - occupancy_scatter_ = OccupancyScatter(occupancy_); - } - auto host = Kokkos::create_mirror_view(occupancy_buckets_); - for (std::size_t index = 0; index < buckets.size(); ++index) - host(index) = buckets[index]; - Kokkos::deep_copy(execution_space_, occupancy_buckets_, host); - execution_space_.fence("solprop_native occupancy bind"); -} - -void Workspace::reuse_fsal() { - const auto stages = stages_; - const auto recycled = recycled_; - const auto solution = solution_; - const auto error = error_; - const double solution_weight = problem_.solution_weights[0]; - const double error_weight = problem_.error_weights[0]; - launch::parallel_for( - "solprop_native.stage.reuse_fsal", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_omega), - KOKKOS_LAMBDA(const std::size_t index) { - const Complex value = recycled(index); - stages(0, index) = value; - solution(index) = solution_weight * value; - error(index) = error_weight * value; - }); -} - -void Workspace::evaluate_stage(const std::size_t stage, const double dz) { - ++rk_source_evaluations_; - if (problem_.n_omega != fine_spectrum_size()) { - Kokkos::deep_copy(execution_space_, fine_spectrum_, Complex{}); - } - const auto current = current_; - const auto stages = stages_; - const auto tableau = tableau_; - const auto phi_terms = phi_terms_; - const auto phi_delta = stage_phi_delta_; - const auto phi_at = stage_phi_at_; - const auto noise = noise_; - const auto crop = crop_index_; - const auto fine = fine_spectrum_; - const auto whole = whole_propagator_; - const std::size_t n = problem_.n_omega; - const std::size_t n_phi = problem_.n_phi_terms; - const std::size_t n_stages = problem_.n_stages; - const bool add_noise = problem_.modified_noise && has_noise_; - const bool analytic = problem_.family == FieldFamily::field_analytic; - const double inverse_n_to = 1.0 / static_cast(problem_.n_to); - launch::parallel_for( - "solprop_native.stage.combine_pad", - Kokkos::RangePolicy(execution_space_, 0, n), - KOKKOS_LAMBDA(const std::size_t index) { - // The phase increments: strict (solprop SEP 25 section 4.2). - const auto phases = [&](Complex& phase, - Complex& phase_at) SOLPROP_NATIVE_STRICT_FUNCTION { - SOLPROP_NATIVE_STRICT_BLOCK - for (std::size_t term = 0; term < n_phi; ++term) { - const Complex fixed = phi_terms(term, index); - phase = strict_accumulate(phase, phi_delta(stage * n_phi + term), fixed); - if (add_noise) - phase_at = - strict_accumulate(phase_at, phi_at(stage * n_phi + term), fixed); - } - }; - Complex phase{}; - Complex phase_at{}; - phases(phase, phase_at); - const Complex propagator = exponential(phase); - Complex accumulated{}; - for (std::size_t previous = 0; previous < stage; ++previous) { - accumulated += tableau(stage * n_stages + previous) * stages(previous, index); - } - Complex value = propagator * (current(index) + dz * accumulated); - if (add_noise) { - value += noise(index) * - Complex(Kokkos::cos(phase_at.imag()), Kokkos::sin(phase_at.imag())); - } - const double analytic_scale = analytic && index > 0 ? 2.0 : 1.0; - fine(crop(index)) = analytic_scale * inverse_n_to * value; - if (stage + 1 == n_stages) { - const auto whole_phase = [&]() SOLPROP_NATIVE_STRICT_FUNCTION { - SOLPROP_NATIVE_STRICT_BLOCK - Complex sum{}; - for (std::size_t term = 0; term < n_phi; ++term) - sum = strict_accumulate(sum, phi_delta(n_stages * n_phi + term), - phi_terms(term, index)); - return sum; - }; - whole(index) = exponential(whole_phase()); - } - }); - if (!problem_.nonlinear && !absorbing()) { - const auto solution = solution_; - const auto error = error_; - const double solution_weight = problem_.solution_weights[stage]; - const double error_weight = problem_.error_weights[stage]; - launch::parallel_for( - "solprop_native.stage.linear_zero", - Kokkos::RangePolicy(execution_space_, 0, n), - KOKKOS_LAMBDA(const std::size_t index) { - stages(stage, index) = Complex{}; - if (stage == 0) { - solution(index) = Complex{}; - error(index) = Complex{}; - } else { - solution(index) += solution_weight * Complex{}; - error(index) += error_weight * Complex{}; - } - }); - return; - } - inverse_transform(); - response(stage); - forward_response(); - if (absorbing()) { - form_absorber(); - forward_absorber(); - } - finish_stage(stage); -} - -void Workspace::inverse_transform() { - if (problem_.family == FieldFamily::field_real) { - KokkosFFT::execute(*inverse_real_plan_, fine_spectrum_, fine_time_real_, - KokkosFFT::Normalization::none); - } else { - KokkosFFT::execute(*inverse_complex_plan_, fine_spectrum_, fine_time_complex_, - KokkosFFT::Normalization::none); - } -} - -void Workspace::response(const std::size_t stage) { - const auto real_time = fine_time_real_; - const auto complex_time = fine_time_complex_; - const auto projected_real = projected_real_; - const auto projected_complex = projected_complex_; - const auto time_window = time_window_; - const auto carrier = carrier_; - const auto density = stage_density_; - const auto taper = stage_taper_; - const auto family = problem_.family; - const bool nonlinear = problem_.nonlinear; - const bool cubic_real = problem_.cubic_real; - const double synthesis = problem_.synthesis; - const double projection = problem_.projection; - const double coefficient = problem_.kerr_coefficient; - const double self_factor = problem_.self_factor; - const double conjugate_factor = problem_.conjugate_factor; - const double harmonic_factor = problem_.harmonic_factor; - launch::parallel_for( - "solprop_native.response.kerr_1x1", - Kokkos::RangePolicy(response_execution_space_, 0, problem_.n_to), - KOKKOS_LAMBDA(const std::size_t index) { - const double taper_value = taper(stage); - const double project_scale = projection / taper_value; - if (family == FieldFamily::field_real) { - const double node = synthesis * taper_value * real_time(index); - const double polarisation = - nonlinear ? coefficient * density(stage) * node * node * node : 0.0; - projected_real(index) = project_scale * polarisation * time_window(index); - return; - } - const Complex node = synthesis * taper_value * complex_time(index); - Complex polarisation{}; - if (nonlinear) { - const double factor = coefficient * density(stage); - if (cubic_real) { - polarisation = factor * node * node * node; - } else { - const double modulus = - node.real() * node.real() + node.imag() * node.imag(); - const Complex square = node * node; - polarisation = factor * (self_factor * modulus * node + - conjugate_factor * square * Kokkos::conj(node) + - harmonic_factor * carrier(index) * square * node); - } - } - polarisation *= project_scale * time_window(index); - if (family == FieldFamily::envelope) { - projected_complex(index) = polarisation; - } else { - projected_real(index) = polarisation.real(); - } - }); -} - -void Workspace::forward_response() { - if (problem_.family == FieldFamily::envelope) { - KokkosFFT::execute(*forward_complex_plan_, projected_complex_, projected_spectrum_, - KokkosFFT::Normalization::none); - } else { - KokkosFFT::execute(*forward_real_plan_, projected_real_, projected_spectrum_, - KokkosFFT::Normalization::none); - } -} - -void Workspace::form_absorber() { - const auto rate = absorber_fine_; - const auto real_time = fine_time_real_; - const auto complex_time = fine_time_complex_; - const auto absorber_real = absorber_real_; - const auto absorber_complex = absorber_complex_; - const auto family = problem_.family; - launch::parallel_for( - "solprop_native.absorber.nonlinear_term", - Kokkos::RangePolicy(response_execution_space_, 0, problem_.n_to), - KOKKOS_LAMBDA(const std::size_t index) { - if (family == FieldFamily::envelope) { - absorber_complex(index) = -0.5 * rate(index) * complex_time(index); - } else if (family == FieldFamily::field_real) { - absorber_real(index) = -0.5 * rate(index) * real_time(index); - } else { - absorber_real(index) = -0.5 * rate(index) * complex_time(index).real(); - } - }); -} - -void Workspace::forward_absorber() { - if (problem_.family == FieldFamily::envelope) { - KokkosFFT::execute(*absorber_complex_plan_, absorber_complex_, absorber_spectrum_, - KokkosFFT::Normalization::none); - } else { - KokkosFFT::execute(*absorber_real_plan_, absorber_real_, absorber_spectrum_, - KokkosFFT::Normalization::none); - } -} - -void Workspace::finish_stage(const std::size_t stage) { - const auto projected = projected_spectrum_; - const auto absorber = absorber_spectrum_; - const auto crop = crop_index_; - const auto active = active_mask_; - const auto window = omega_window_; - const auto phi_terms = phi_terms_; - const auto phi_delta = stage_phi_delta_; - const auto prefactor_terms = prefactor_terms_; - const auto prefactor_weights = stage_prefactor_; - const auto stages = stages_; - const auto solution = solution_; - const auto error = error_; - const std::size_t n_phi = problem_.n_phi_terms; - const std::size_t n_prefactor = problem_.n_prefactor_terms; - const bool in_stage_absorber = absorbing(); - const double solution_weight = problem_.solution_weights[stage]; - const double error_weight = problem_.error_weights[stage]; - launch::parallel_for( - "solprop_native.stage.finish_accumulate", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_omega), - KOKKOS_LAMBDA(const std::size_t index) { - // The phase increment: strict (solprop SEP 25 section 4.2). - const auto stage_phase = [&]() SOLPROP_NATIVE_STRICT_FUNCTION { - SOLPROP_NATIVE_STRICT_BLOCK - Complex sum{}; - for (std::size_t term = 0; term < n_phi; ++term) - sum = strict_accumulate(sum, phi_delta(stage * n_phi + term), - phi_terms(term, index)); - return sum; - }; - const Complex phase = stage_phase(); - Complex prefactor{}; - for (std::size_t term = 0; term < n_prefactor; ++term) { - prefactor += prefactor_weights(stage * n_prefactor + term) * - prefactor_terms(term, index); - } - Complex value = prefactor * window(index) * projected(crop(index)); - if (in_stage_absorber && active(index) != 0U) { - value += absorber(crop(index)); - } - value *= exponential(-phase); - stages(stage, index) = value; - if (stage == 0) { - solution(index) = solution_weight * value; - error(index) = error_weight * value; - } else { - solution(index) += solution_weight * value; - error(index) += error_weight * value; - } - }); -} - -namespace { -/** - * The Kerr engine's accepted update and its three squared norms in one pass over the - * bins: `advanced`, then the old, new and error norms' partial sums, reduced in - * whatever order measures fastest (solprop SEP 25). - */ -struct AcceptScore { - using value_type = double[]; - using size_type = std::size_t; - size_type value_count = 3; - ComplexView current, advanced, solution, error, whole; - ByteView in_norm; - double dz; - - KOKKOS_INLINE_FUNCTION void operator()(const std::size_t index, - value_type sums) const { - const Complex candidate = whole(index) * (current(index) + dz * solution(index)); - advanced(index) = candidate; - if (in_norm(index) == 0U) - return; - const Complex residual = whole(index) * (dz * error(index)); - sums[0] += current(index).real() * current(index).real() + - current(index).imag() * current(index).imag(); - sums[1] += - candidate.real() * candidate.real() + candidate.imag() * candidate.imag(); - sums[2] += residual.real() * residual.real() + residual.imag() * residual.imag(); - } - KOKKOS_INLINE_FUNCTION void join(value_type destination, - const value_type source) const { - for (size_type row = 0; row < value_count; ++row) - destination[row] += source[row]; - } - KOKKOS_INLINE_FUNCTION void init(value_type values) const { - for (size_type row = 0; row < value_count; ++row) - values[row] = 0.0; - } -}; -} // namespace - -void Workspace::accept_and_score(const double dz) { - const AcceptScore score{3, current_, advanced_, solution_, - error_, whole_propagator_, norm_mask_, dz}; - launch::parallel_reduce( - "solprop_native.step.accept_error_partials", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_omega), score, - partials_); -} - -void Workspace::mask(const ComplexView& values) { - const auto active = active_mask_; - launch::parallel_for( - "solprop_native.mask.accepted", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_omega), - KOKKOS_LAMBDA(const std::size_t index) { - if (active(index) == 0U) - values(index) = Complex{}; - }); -} - -void Workspace::split_step(const double dz) { - const auto advanced = advanced_; - const auto coarse = coarse_spectrum_; - const double inverse_n = 1.0 / static_cast(problem_.n_t); - launch::parallel_for( - "solprop_native.absorber.split_pad", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_omega), - KOKKOS_LAMBDA(const std::size_t index) { - coarse(index) = inverse_n * advanced(index); - }); - if (problem_.family == FieldFamily::envelope) { - KokkosFFT::execute(*coarse_inverse_complex_plan_, coarse_spectrum_, - coarse_time_complex_, KokkosFFT::Normalization::none); - if (monitoring_split()) - add_split_removal(dz); - const auto time = coarse_time_complex_; - const auto rate = absorber_coarse_; - launch::parallel_for( - "solprop_native.absorber.split_apply_complex", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_t), - KOKKOS_LAMBDA(const std::size_t index) { - time(index) *= Kokkos::exp(-0.5 * rate(index) * dz); - }); - KokkosFFT::execute(*coarse_forward_complex_plan_, coarse_time_complex_, - coarse_spectrum_, KokkosFFT::Normalization::none); - } else { - KokkosFFT::execute(*coarse_inverse_real_plan_, coarse_spectrum_, coarse_time_real_, - KokkosFFT::Normalization::none); - if (monitoring_split()) - add_split_removal(dz); - const auto time = coarse_time_real_; - const auto rate = absorber_coarse_; - launch::parallel_for( - "solprop_native.absorber.split_apply_real", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_t), - KOKKOS_LAMBDA(const std::size_t index) { - time(index) *= Kokkos::exp(-0.5 * rate(index) * dz); - }); - KokkosFFT::execute(*coarse_forward_real_plan_, coarse_time_real_, coarse_spectrum_, - KokkosFFT::Normalization::none); - } - const auto active = active_mask_; - launch::parallel_for( - "solprop_native.absorber.split_commit", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_omega), - KOKKOS_LAMBDA(const std::size_t index) { - advanced(index) = active(index) != 0U ? coarse(index) : Complex{}; - }); -} - -std::map Workspace::memory_ledger() const { - const std::size_t complex_bytes = sizeof(Complex); - const std::size_t real_bytes = sizeof(double); - const std::size_t fine_time = - problem_.n_to * - (problem_.family == FieldFamily::field_real ? real_bytes : complex_bytes); - const std::size_t projected_time = - problem_.n_to * - (problem_.family == FieldFamily::envelope ? complex_bytes : real_bytes); - return { - {"state", problem_.n_omega * complex_bytes}, - {"advanced", problem_.n_omega * complex_bytes}, - {"stages", problem_.n_stages * problem_.n_omega * complex_bytes}, - {"accumulator_solution", problem_.n_omega * complex_bytes}, - {"accumulator_error", problem_.n_omega * complex_bytes}, - {"recycled_stage", problem_.n_omega * complex_bytes}, - {"noise", problem_.modified_noise ? problem_.n_omega * complex_bytes : 0}, - {"propagators", problem_.n_omega * complex_bytes}, - {"fine_spectrum", fine_spectrum_size() * complex_bytes}, - {"modal_time", fine_time}, - {"node_field", fine_time}, - {"node_polarisation", projected_time}, - {"projected", projected_time}, - {"projected_coarse", 0}, - {"projected_spectrum", projected_spectrum_size() * complex_bytes}, - {"absorber_time", problem_.in_stage_absorber ? projected_time : 0}, - {"absorber_spectrum", - problem_.in_stage_absorber ? projected_spectrum_size() * complex_bytes : 0}, - {"coarse_spectrum", coarse_spectrum_.size() * complex_bytes}, - {"coarse_time", coarse_time_real_.size() * real_bytes + - coarse_time_complex_.size() * complex_bytes}, - {"monitor_weights", - (monitor_weights_.size() + monitor_removed_.size() + occupancy_.size()) * - real_bytes + - occupancy_buckets_.size() + - (monitor_split_sum_.size() + monitor_found_.size()) * sizeof(SplitSums)}, - {"fixed_terms", (problem_.n_phi_terms + problem_.n_prefactor_terms) * - problem_.n_omega * complex_bytes}, - {"masks_and_transform_data", - problem_.crop_index.size() * sizeof(std::size_t) + - (problem_.active_mask.size() + problem_.norm_mask.size()) * - sizeof(unsigned char) + - (problem_.omega_window.size() + problem_.time_window.size() + - problem_.absorber_fine.size() + problem_.absorber_coarse.size()) * - real_bytes + - problem_.carrier.size() * complex_bytes}, - {"tableau", (problem_.tableau_matrix.size() + problem_.tableau_nodes.size() + - problem_.solution_weights.size() + problem_.error_weights.size()) * - real_bytes}, - {"trial_scalars", - ((problem_.n_stages + 1) * problem_.n_phi_terms + - problem_.n_stages * problem_.n_phi_terms + - problem_.n_stages * problem_.n_prefactor_terms + 2 * problem_.n_stages) * - real_bytes}, - {"error_partials", 3 * real_bytes}, - {"fft_workspace", 0}, - }; -} - -void Workspace::close() noexcept { - if (closed_) - return; - execution_space_.fence(); - inverse_real_plan_.reset(); - inverse_complex_plan_.reset(); - forward_real_plan_.reset(); - forward_complex_plan_.reset(); - absorber_real_plan_.reset(); - absorber_complex_plan_.reset(); - coarse_inverse_real_plan_.reset(); - coarse_inverse_complex_plan_.reset(); - coarse_forward_real_plan_.reset(); - coarse_forward_complex_plan_.reset(); - current_ = {}; - advanced_ = {}; - stages_ = {}; - solution_ = {}; - error_ = {}; - recycled_ = {}; - noise_ = {}; - whole_propagator_ = {}; - fine_spectrum_ = {}; - fine_time_real_ = {}; - fine_time_complex_ = {}; - projected_real_ = {}; - projected_complex_ = {}; - projected_spectrum_ = {}; - absorber_real_ = {}; - absorber_complex_ = {}; - absorber_spectrum_ = {}; - coarse_spectrum_ = {}; - coarse_time_real_ = {}; - coarse_time_complex_ = {}; - monitor_bound_ = false; - monitor_weights_ = {}; - monitor_removed_ = {}; - monitor_split_sum_ = {}; - monitor_found_ = {}; - occupancy_scatter_ = {}; - occupancy_buckets_ = {}; - occupancy_ = {}; - partials_ = {}; - omega_window_ = {}; - time_window_ = {}; - absorber_fine_ = {}; - absorber_coarse_ = {}; - tableau_ = {}; - stage_phi_delta_ = {}; - stage_phi_at_ = {}; - stage_prefactor_ = {}; - stage_density_ = {}; - stage_taper_ = {}; - carrier_ = {}; - phi_terms_ = {}; - prefactor_terms_ = {}; - active_mask_ = {}; - norm_mask_ = {}; - crop_index_ = {}; - // Release private OpenMP pools while the process runtime is still alive. - coarse_fft_execution_space_ = ExecutionSpace(); - fft_execution_space_ = ExecutionSpace(); - response_execution_space_ = ExecutionSpace(); - execution_space_ = ExecutionSpace(); - fft_pool_.clear(); - kernel_pool_.clear(); - closed_ = true; - initialized_ = false; - clear_fsal(); -} - -State::State(Problem problem) - : workspace_(std::make_unique(std::move(problem))) {} -State::~State() { close(); } -void State::set_initial(const std::span field, - const std::span noise) { - const CpuTeamScope team(workspace_->cpu_team()); - workspace_->set_initial(field, noise); -} -std::array State::trial_step(const double dz, const TrialScalars& scalars) { - const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->trial_step(dz, scalars); -} -void State::finish(const double dz) { - const CpuTeamScope team(workspace_->cpu_team()); - workspace_->finish(dz); -} -void State::promote_fsal() { - const CpuTeamScope team(workspace_->cpu_team()); - workspace_->promote_fsal(); -} -void State::clear_fsal() { - const CpuTeamScope team(workspace_->cpu_team()); - workspace_->clear_fsal(); -} -void State::set_absorber_quiet(const bool quiet) { - const CpuTeamScope team(workspace_->cpu_team()); - workspace_->set_absorber_quiet(quiet); -} -void State::bind_monitor(const std::vector& weights, - const std::vector& column_weights) { - const CpuTeamScope team(workspace_->cpu_team()); - workspace_->bind_monitor(weights, column_weights); -} -void State::unbind_monitor() noexcept { - if (workspace_) - workspace_->unbind_monitor(); -} -std::vector State::monitor_reading() { - const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->monitor_reading(); -} -void State::bind_occupancy(const std::vector& buckets, - const std::size_t count) { - const CpuTeamScope team(workspace_->cpu_team()); - workspace_->bind_occupancy(buckets, count); -} -std::vector State::field_to_host() const { - const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->field_to_host(); -} -void State::field_to_host(const std::span destination) const { - const CpuTeamScope team(workspace_->cpu_team()); - workspace_->field_to_host(destination); -} -std::map State::memory_ledger() const { - const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->memory_ledger(); -} -CpuSizing State::execution_sizing() const { - const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->execution_sizing(); -} -double State::planning_seconds() const noexcept { - const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->planning_seconds(); -} -bool State::fsal_ready() const noexcept { - const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->fsal_ready(); -} -std::size_t State::rk_source_evaluations() const noexcept { - const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->rk_source_evaluations(); -} -bool State::closed() const noexcept { - const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->closed(); -} -void State::close() noexcept { - if (workspace_) - workspace_->close(); -} - void fft(const ExecutionSpace& execution_space, const ComplexView input, const ComplexView output) { const std::size_t size = input.extent(0); diff --git a/native/src/general.cpp b/native/src/general.cpp index 5ac30ede..9d2fdceb 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -3187,9 +3187,9 @@ bool GeneralWorkspace::use_chunked_blas_for_synthesis() const noexcept { // One node, one mode and one scalar component: the synthesis and the projection are // each one product, which the response column forms itself (solprop SEP 25 section -// 6.7), as the Kerr-only engine's `kerr_1x1` does, in place of the stage's taper -// scaling, two one-element GEMMs and the zero fill of the projection. Each folded -// product is the GEMM's own one rounding, `(s * taper) * x` and `(p / taper) * y`. +// 6.7), in place of the stage's taper scaling, two one-element GEMMs and the zero +// fill of the projection. Each folded product is the GEMM's own one rounding, +// `(s * taper) * x` and `(p / taper) * y`. // The host's columns only: a device keeps its own column kernels. bool GeneralWorkspace::folds_single_node() const noexcept { #if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) diff --git a/native/tests/test_numerics.py b/native/tests/test_numerics.py index 338df3ad..4abfeb7e 100644 --- a/native/tests/test_numerics.py +++ b/native/tests/test_numerics.py @@ -198,8 +198,7 @@ def test_a_kerr_leaf_density_path_scales_the_single_column_response( multi-component Kerr leaf has: the physics is unchanged, so the reference's split run and the backend's are each as accurate as the reference's unsplit run, against a much tighter reference, within the factor of four of the adaptive envelope - (solprop SEP 25 section 4.4). The unreachable single-column engine (`State`) - ignores the path and misses by about one per cent on such a case. + (solprop SEP 25 section 4.4). """ problem, grid = phase_a_case kerr = problem.responses.kerr_coefficients From b74e39a8926b1e8e379cc3bc463398f4b2be1844 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:02:57 +0000 Subject: [PATCH 020/122] Ring test: compare what a load keeps, on the active band A load zeroes outside the active band, so a random field never came back bit for bit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/backends/test_native_gpu.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/backends/test_native_gpu.py b/tests/backends/test_native_gpu.py index c5e8cecf..f4808566 100644 --- a/tests/backends/test_native_gpu.py +++ b/tests/backends/test_native_gpu.py @@ -109,6 +109,8 @@ def test_a_large_state_crosses_both_ways_through_the_ring_bit_for_bit( rng = np.random.default_rng(53) shape = problem.shape field = rng.standard_normal(shape) + 1j * rng.standard_normal(shape) + # A load masks outside the active band, which isn't what this test is about. + field[..., ~np.asarray(problem.grids.active_mask)] = 0.0 assert field.nbytes >= 2**28 assert engine.memory_ledger()["host_crossing_ring"] == 0 engine.set_initial(field, None, z=problem.initial.z) From e1883d6e6cb2210ba987453bf2443ff44bef060e Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:12:53 +0000 Subject: [PATCH 021/122] Read the transverse edge where the state lives SEP 44 phase 2, for a run without a requested collar. The edge hands native its weight tables, four regions on the nodes, and native returns the region sums and each mode's energy. Native holds no face, mask or judgement, and a field-free default run brings no state to the host, which a test now holds it to. A collar still reduces a host copy. An inverse transform into the free scratch, then one sum: the same state read on both sides agrees within the transform's rounding on the radial, Cartesian, line and planar routes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/include/solprop_native/core.hpp | 13 ++ native/src/bindings.cpp | 42 +++++ native/src/general.cpp | 162 ++++++++++++++++- native/src/solprop_native/__init__.py | 1 + native/src/solprop_native/_core.pyi | 11 ++ native/src/solprop_native/backend.py | 60 +++++++ src/solprop/transverse_monitor.py | 166 +++++++++++++----- tests/backends/test_driver.py | 5 + tests/backends/test_native_edge_reading.py | 195 +++++++++++++++++++++ tests/test_transverse_reads.py | 28 ++- 10 files changed, 625 insertions(+), 58 deletions(-) create mode 100644 tests/backends/test_native_edge_reading.py diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index e9a465e1..21bde33a 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -82,6 +82,13 @@ struct MonitorSums { } }; +/** What the transverse edge reads of a state (solprop SEP 44): four numbers per weight + table, and each mode's energy. */ +struct EdgeNumbers { + std::vector sums; + std::vector columns; +}; + /** What a split-step collar's finish reads of the power it finds (solprop SEP 21, ADR-057), one reduction value: the energy it takes, then the energy, the low guard's, the high guard's and the collar's, which the monitor judges the @@ -941,6 +948,12 @@ class GeneralState { [[nodiscard]] std::vector field_to_host() const; /** The field into `destination`, of the field's size: one copy from the space. */ void field_to_host(std::span destination) const; + /** What the transverse edge's reading sums with (solprop SEP 44). */ + void bind_edge(const std::vector& faces, const std::vector& clip, + const std::vector& totals, + const std::vector& synthesis, std::size_t first_row); + /** The edge's region sums and each mode's energy, where the state lives. */ + EdgeNumbers edge_reading(bool whole, bool totals); /** The threads a large crossing takes (`crossing.hpp`), from solprop's budget. */ void set_crossing_workers(std::size_t workers) noexcept; [[nodiscard]] std::vector stage_to_host(std::size_t stage) const; diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index c2b121e2..1c89c714 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -1377,6 +1377,20 @@ class BoundState { return general_state_->aperture_statistics(flags, apertures, exponent, regions, intensity_factor); } + void bind_edge(const std::vector& faces, const std::vector& clip, + const std::vector& totals, + const std::vector& synthesis, const std::size_t first_row) { + require_open(); + if (!general_state_) + throw std::logic_error("the edge's reading requires the general native state"); + general_state_->bind_edge(faces, clip, totals, synthesis, first_row); + } + native::EdgeNumbers edge_reading(const bool whole, const bool totals) { + require_open(); + if (!general_state_) + throw std::logic_error("the edge's reading requires the general native state"); + return general_state_->edge_reading(whole, totals); + } void set_crossing_workers(const std::size_t workers) noexcept { if (general_state_) general_state_->set_crossing_workers(workers); @@ -2945,6 +2959,34 @@ NB_MODULE(_core, module_) { } return real_output(std::move(values), 1); }) + // solprop SEP 44: the transverse edge's reading, where the state lives. + .def( + "bind_edge", + [](BoundState& self, const RealInput faces, const RealInput clip, + const RealInput totals, const RealInput synthesis, + const std::size_t first_row) { + const auto held_faces = real_vector(faces); + const auto held_clip = real_vector(clip); + const auto held_totals = real_vector(totals); + const auto held_synthesis = real_vector(synthesis); + const nb::gil_scoped_release release; + self.bind_edge(held_faces, held_clip, held_totals, held_synthesis, + first_row); + }, + "faces"_a.noconvert(), "clip"_a.noconvert(), "totals"_a.noconvert(), + "synthesis"_a.noconvert(), "first_row"_a) + .def( + "edge_reading", + [](BoundState& self, const bool whole, const bool totals) { + native::EdgeNumbers numbers; + { + const nb::gil_scoped_release release; + numbers = self.edge_reading(whole, totals); + } + return nb::make_tuple(real_output(std::move(numbers.sums), 1), + real_output(std::move(numbers.columns), 1)); + }, + "whole"_a, "totals"_a) .def( "set_crossing_workers", [](BoundState& self, const std::size_t workers) { diff --git a/native/src/general.cpp b/native/src/general.cpp index 5ac30ede..19d3042a 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -2183,6 +2183,10 @@ class GeneralWorkspace { void unbind_monitor() noexcept { monitor_bound_ = false; } void bind_occupancy(const std::vector& buckets, std::size_t count); std::vector monitor_reading(); + void bind_edge(const std::vector& faces, const std::vector& clip, + const std::vector& totals, + const std::vector& synthesis, std::size_t first_row); + EdgeNumbers edge_reading(bool whole, bool totals); std::vector field_to_host() const; void field_to_host(std::span destination) const; void cross_to_host(const ComplexView2& source, std::span destination) const; @@ -2889,6 +2893,10 @@ class GeneralWorkspace { // #440); empty unbound. ByteView occupancy_buckets_; std::size_t occupancy_count_ = 0; + // What the transverse edge's reading sums with, once a monitor is bound (SEP 44). + RealView2 edge_faces_, edge_clip_, edge_totals_, edge_synthesis_; + RealView edge_columns_; + std::size_t edge_first_row_ = 0; // A large crossing's pinned chunks, once one has been made (`crossing.hpp`). mutable crossing::Ring crossing_ring_; [[nodiscard]] bool monitoring_split() const noexcept { @@ -14611,10 +14619,10 @@ namespace { // `power(row, index)`, in total and weighed by each of the four regions' weights. A // host work item takes a run of one row's samples, its sums in sample order, so that // no sample divides its flat index by the row length; a device takes one sample. -template +template MonitorSums monitor_sums(const char* label, const ExecutionSpace& execution, const std::size_t count, const std::size_t n_t, - const RealView2& weights, const Power& power) { + const Weights& weights, const Power& power) { MonitorSums sums; #if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) launch::parallel_reduce( @@ -14654,6 +14662,135 @@ MonitorSums monitor_sums(const char* label, const ExecutionSpace& execution, } } // namespace +namespace { +// The transverse edge's region weights (solprop SEP 44), from the rows given on: a +// radial read synthesises only the outer rows its regions can touch. +struct EdgeWeights { + RealView2 weights; + std::size_t first{}; + KOKKOS_INLINE_FUNCTION double operator()(const std::size_t region, + const std::size_t index) const { + return weights(region, first + index); + } +}; +} // namespace + +// Bind what the transverse edge's reading sums with (solprop SEP 44): three tables of +// four regions' weights on the nodes, and radially the synthesis and the first row a +// read uses. Native holds no face, mask or judgement. The views are allocated at the +// first bind and kept. +void GeneralWorkspace::bind_edge(const std::vector& faces, + const std::vector& clip, + const std::vector& totals, + const std::vector& synthesis, + const std::size_t first_row) { + require_open(); + const auto nodes = faces.size() / 4; + require_extent(faces.size(), 4 * nodes, "edge faces"); + require_extent(clip.size(), 4 * nodes, "edge clip"); + require_extent(totals.size(), 4 * nodes, "edge totals"); + if (problem_.separable_geometry()) { + require_extent(nodes, transverse_units(), "edge nodes"); + require_extent(synthesis.size(), 0, "a separable edge's synthesis"); + } else { + require_extent(nodes, problem_.n_modes, "edge nodes"); + require_extent(synthesis.size(), nodes * nodes, "edge synthesis"); + } + if (first_row >= nodes) + throw std::invalid_argument("the edge's first row lies past its nodes"); + if (edge_faces_.extent(0) == 0) { + edge_faces_ = RealView2("solprop_native.edge.faces", 4, nodes); + edge_clip_ = RealView2("solprop_native.edge.clip", 4, nodes); + edge_totals_ = RealView2("solprop_native.edge.totals", 4, nodes); + edge_columns_ = RealView("solprop_native.edge.columns", problem_.n_modes); + if (!synthesis.empty()) + edge_synthesis_ = RealView2("solprop_native.edge.synthesis", nodes, nodes); + } + copy_vector(execution_space_, edge_faces_, faces); + copy_vector(execution_space_, edge_clip_, clip); + copy_vector(execution_space_, edge_totals_, totals); + if (!synthesis.empty()) + copy_vector(execution_space_, edge_synthesis_, synthesis); + edge_first_row_ = first_row; + execution_space_.fence("solprop_native edge bind"); +} + +// The state's reading for the transverse edge: its region sums and each mode's energy, +// with only those numbers crossing. With `whole` (a launch) every table on every node; +// otherwise the faces', and with `totals` a collar's, on the rows a read uses. The +// transverse scratch is free at a committed position: a step's first pass writes it +// again. +EdgeNumbers GeneralWorkspace::edge_reading(const bool whole, const bool totals) { + require_open(); + if (!initialized_) + throw std::logic_error("no initial field has been loaded"); + if (edge_faces_.extent(0) == 0) + throw std::logic_error("no transverse edge is bound"); + const auto current = current_; + const auto n_pol = problem_.n_pol_state; + const auto n = problem_.n_omega; + EdgeNumbers reading; + const auto take = [&reading](const MonitorSums& sums) { + reading.sums.insert(reading.sums.end(), sums.values.data() + 1, + sums.values.data() + 5); + }; + if (problem_.separable_geometry()) { + separable_load_current(); + const auto scratch = transverse_; + const auto units = transverse_units(); + const auto planes = scratch.size() / units; + const auto power = KOKKOS_LAMBDA(const std::size_t plane, const std::size_t node) { + return squared_abs(scratch.data()[plane * units + node]); + }; + take(monitor_sums("solprop_native.edge.faces", execution_space_, planes, units, + edge_faces_, power)); + if (whole) + take(monitor_sums("solprop_native.edge.clip", execution_space_, planes, units, + edge_clip_, power)); + if (whole || totals) + take(monitor_sums("solprop_native.edge.totals", execution_space_, planes, units, + edge_totals_, power)); + } else { + // Each plane's synthesis on the rows wanted. A launch reads every node: its total + // is the grid's. A dense product per sample, on a read's few outer rows. + const auto synthesis = edge_synthesis_; + const auto nodes = problem_.n_modes; + const auto first = whole ? std::size_t{0} : edge_first_row_; + const auto power = KOKKOS_LAMBDA(const std::size_t plane, const std::size_t row) { + const auto pol = plane / n; + const auto omega = plane % n; + Complex value{}; + for (std::size_t mode = 0; mode < nodes; ++mode) + value += synthesis(first + row, mode) * current(mode * n_pol + pol, omega); + return squared_abs(value); + }; + const auto sums = [&](const char* label, const RealView2& weights) { + return monitor_sums(label, execution_space_, n_pol * n, nodes - first, + EdgeWeights{weights, first}, power); + }; + take(sums("solprop_native.edge.faces", edge_faces_)); + if (whole) + take(sums("solprop_native.edge.clip", edge_clip_)); + if (whole || totals) + take(sums("solprop_native.edge.totals", edge_totals_)); + } + const auto energies = edge_columns_; + launch::parallel_for( + "solprop_native.edge.columns", + Kokkos::RangePolicy(execution_space_, 0, problem_.n_modes), + KOKKOS_LAMBDA(const std::size_t mode) { + double sum = 0.0; + for (std::size_t pol = 0; pol < n_pol; ++pol) + for (std::size_t index = 0; index < n; ++index) + sum += squared_abs(current(mode * n_pol + pol, index)); + energies(mode) = sum; + }); + execution_space_.fence("solprop_native edge columns"); + const auto host = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, energies); + reading.columns.assign(host.data(), host.data() + host.size()); + return reading; +} + std::vector GeneralWorkspace::monitor_reading() { require_open(); if (!initialized_) @@ -15299,6 +15436,10 @@ std::map GeneralWorkspace::base_memory_ledger() const (monitor_split_sum_.size() + monitor_found_.size()) * sizeof(SplitSums)}, {"statistics_flux_magnitude", statistics_magnitude_.size() * real_bytes}, {"host_crossing_ring", crossing_ring_.bytes()}, + {"transverse_edge", + (edge_faces_.size() + edge_clip_.size() + edge_totals_.size() + + edge_synthesis_.size() + edge_columns_.size()) * + real_bytes}, {"statistics_outputs", (statistics_total_power_.size() + statistics_on_axis_.size() + statistics_packed_.size()) * real_bytes}, @@ -15541,6 +15682,11 @@ void GeneralWorkspace::close() noexcept { occupancy_buckets_ = {}; occupancy_count_ = 0; crossing_ring_.release(); + edge_faces_ = {}; + edge_clip_ = {}; + edge_totals_ = {}; + edge_synthesis_ = {}; + edge_columns_ = {}; tableau_ = {}; omega_window_ = {}; omega_ = {}; @@ -15844,6 +15990,18 @@ std::vector GeneralState::monitor_reading() { const CpuTeamScope team(workspace_->cpu_team()); return workspace_->monitor_reading(); } +void GeneralState::bind_edge(const std::vector& faces, + const std::vector& clip, + const std::vector& totals, + const std::vector& synthesis, + const std::size_t first_row) { + const CpuTeamScope team(workspace_->cpu_team()); + workspace_->bind_edge(faces, clip, totals, synthesis, first_row); +} +EdgeNumbers GeneralState::edge_reading(const bool whole, const bool totals) { + const CpuTeamScope team(workspace_->cpu_team()); + return workspace_->edge_reading(whole, totals); +} void GeneralState::bind_occupancy(const std::vector& buckets, const std::size_t count) { const CpuTeamScope team(workspace_->cpu_team()); diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py index 42f06f4a..ad768a3f 100644 --- a/native/src/solprop_native/__init__.py +++ b/native/src/solprop_native/__init__.py @@ -182,6 +182,7 @@ def _band_request() -> int: "_bind_monitor", "_boundary_energies", "_dense_stages", + "_edge_reading", "_drop_held", "_finish", "_held_field", diff --git a/native/src/solprop_native/_core.pyi b/native/src/solprop_native/_core.pyi index 554d919b..d508a030 100644 --- a/native/src/solprop_native/_core.pyi +++ b/native/src/solprop_native/_core.pyi @@ -285,6 +285,17 @@ class State: def bind_occupancy(self, buckets: npt.NDArray[np.uint8], count: int) -> None: ... def monitor_reading(self) -> npt.NDArray[np.float64]: ... def field_to_host(self) -> npt.NDArray[np.complex128]: ... + def bind_edge( + self, + faces: npt.NDArray[np.float64], + clip: npt.NDArray[np.float64], + totals: npt.NDArray[np.float64], + synthesis: npt.NDArray[np.float64], + first_row: int, + ) -> None: ... + def edge_reading( + self, whole: bool, totals: bool + ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: ... def set_crossing_workers(self, workers: int) -> None: ... def stage_to_host(self, stage: int) -> npt.NDArray[np.complex128]: ... def projected_to_host(self, coarse: bool = False) -> npt.NDArray[np.complex128]: ... diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 2bf5050d..d4b0031c 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -141,6 +141,7 @@ require_device_ready, ) from solprop.translate.tdse import TDSEPurpose +from solprop.transverse_monitor import EdgeReading, TransverseEdge from . import ( PROC_BIND, @@ -1129,6 +1130,65 @@ def _bind_monitor(self, monitor: BoundaryMonitor | None) -> None: SPLIT_RATE_BUCKETS, ) self._bound_buckets = crossing.buckets + edge = getattr(monitor, "transverse", None) + if edge is not None and not edge.every_step: + # Weights and no faces: what it reads comes back to the edge's judging. + tables = edge.tables + synthesis = edge.reduction.synthesis() + self._native_state.bind_edge( + np.ascontiguousarray(tables.faces, dtype=np.float64).reshape(-1), + np.ascontiguousarray(tables.clip, dtype=np.float64).reshape(-1), + np.ascontiguousarray(tables.totals, dtype=np.float64).reshape(-1), + np.empty(0, dtype=np.float64) + if synthesis is None + else np.ascontiguousarray(synthesis, dtype=np.float64).reshape(-1), + int(np.argmax(tables.rows)), + ) + + def _edge_reading( + self, + state: _core.State, + edge: TransverseEdge, + *, + faces: bool = True, + current: bool = False, + full: bool = False, + ) -> EdgeReading: + """Read the state for the transverse edge where it lives (solprop SEP 44). + + Parameters + ---------- + state : solprop_native._core.State + This backend's state. + edge : solprop.transverse_monitor.TransverseEdge + The run's edge. + faces : bool, optional + Whether the regions, the columns and any collar's strip are wanted. + current : bool, optional + Whether a collar's inward current is wanted. + full : bool, optional + Whether every node is wanted, as a launch wants. + + Returns + ------- + solprop.transverse_monitor.EdgeReading + The reading, of which only numbers crossed. A requested collar still reduces + a host copy of the state. + """ + if edge.every_step: + return super()._edge_reading( + state, edge, faces=faces, current=current, full=full + ) + if state is not self._native_state: + raise BackendError("the read state does not belong to this backend") + sums, columns = state.edge_reading(full, False) + values = np.asarray(sums).reshape(-1) + return EdgeReading( + faces=values[:4], + clip=values[4:8] if full else None, + totals=values[8:12] if full else None, + columns=np.asarray(columns).reshape(-1), + ) def _boundary_energies(self, state: _core.State) -> BoundaryEnergies: """Reduce the monitor's five numbers where the state lives (solprop SEP 21). diff --git a/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py index 9e3a1996..540a64c5 100644 --- a/src/solprop/transverse_monitor.py +++ b/src/solprop/transverse_monitor.py @@ -73,16 +73,47 @@ ] +@dataclass(frozen=True, eq=False) +class EdgeTables: + """The weights a state's node energies are summed with, region by region. + + Each table holds four regions' weights on the nodes (or rows), so that a backend + needs no faces, masks or geometry of its own: it forms ``table @ nodes``, where + ``nodes`` is each node's unweighted ``sum |E|^2`` over polarisation and frequency, + and returns the numbers. A region no face uses is zero. + + Attributes + ---------- + faces : numpy.ndarray + Each face's edge, in the outer :data:`GUARD` of the outer size. + clip : numpy.ndarray + Each face's clip, in the outer :data:`GUARD` of the requested size: a launch's. + totals : numpy.ndarray + The whole grid; a requested collar's rate over it, 1/m; and what lies outside + the request, for the launch's baseline of the current. + rows : numpy.ndarray + Boolean mask of the nodes (or rows) a read's sums can touch. + """ + + faces: Float[np.ndarray, "4 n_nodes"] + clip: Float[np.ndarray, "4 n_nodes"] + totals: Float[np.ndarray, "4 n_nodes"] + rows: Bool[np.ndarray, " n_nodes"] + + @dataclass(frozen=True, eq=False) class EdgeReading: """What a backend reads of its state for the edge: numbers, never the field. Attributes ---------- - nodes : numpy.ndarray or None - Each physical node's (or row's) ``sum |E|^2`` over polarisation and frequency, - unweighted, shape ``(n_nodes,)``. A read need form only the rows the edge named, - and leaves the others zero. ``None`` when only the current was asked for. + faces : numpy.ndarray or None + ``EdgeTables.faces @ nodes``, shape ``(4,)``; ``None`` when only the current was + asked for. + clip : numpy.ndarray or None + ``EdgeTables.clip @ nodes``, for a launch. + totals : numpy.ndarray or None + ``EdgeTables.totals @ nodes``, for a launch or a collar's read. columns : numpy.ndarray or None Each spectral column's ``sum |E|^2``, unweighted, shape ``(n_columns,)``; ``None`` when only the current was asked for. @@ -95,7 +126,9 @@ class EdgeReading: launch; ``None`` when it was not asked for. """ - nodes: Float[np.ndarray, " n_nodes"] | None = None + faces: Float[np.ndarray, " 4"] | None = None + clip: Float[np.ndarray, " 4"] | None = None + totals: Float[np.ndarray, " 4"] | None = None columns: Float[np.ndarray, " n_columns"] | None = None strip: Float[np.ndarray, "n_lines 2"] | None = None current: float | None = None @@ -263,14 +296,14 @@ class HostEdgeReduction: ---------- space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid The run's grid. - rows : numpy.ndarray - Boolean mask of the nodes (or rows) a read uses: the edge's and a collar's. + tables : solprop.transverse_monitor.EdgeTables + What a state's node energies are summed with. strip_bounds : tuple of float or None A requested collar's return strip in the outward coordinate, m. """ space: RadialGrid | FreeGrid | PlanarGrid - rows: Bool[np.ndarray, " n_nodes"] + tables: EdgeTables strip_bounds: tuple[float, float] | None _inverse: np.ndarray | None = field(default=None, init=False) # The host's transverse transforms take the process's thread budget (ADR-052). @@ -306,13 +339,31 @@ def reading( rate = None if current is None else self.current(state, *current) if not faces: return EdgeReading(current=rate) + nodes = self.node_power(state, full=full) + collar = self.strip_bounds is not None return EdgeReading( - self.node_power(state, full=full), - self.column_power(state), - None if self.strip_bounds is None else self.strip(state), - rate, + faces=self.tables.faces @ nodes, + clip=self.tables.clip @ nodes if full else None, + totals=self.tables.totals @ nodes if full or collar else None, + columns=self.column_power(state), + strip=self.strip(state) if collar else None, + current=rate, ) + def synthesis(self) -> Float[np.ndarray, "n_r n_r"] | None: + """Return a radial grid's synthesis from columns to nodes, or ``None``. + + Returns + ------- + numpy.ndarray or None + The dense matrix; ``None`` on a grid whose transform is an FFT. + """ + if not isinstance(self.space, RadialGrid): + return None + if self._inverse is None: + self._inverse = _radial_inverse(self.space) + return self._inverse + def node_power( self, state: Complex[np.ndarray, "n_modes n_pol n_omega"], @@ -343,7 +394,8 @@ def node_power( if full: return _row_energy(self._inverse @ flat) nodes = np.zeros(space.n_r) - nodes[self.rows] = _row_energy(self._inverse[self.rows] @ flat) + rows = self.tables.rows + nodes[rows] = _row_energy(self._inverse[rows] @ flat) return nodes if isinstance(space, PlanarGrid): cube = flat.reshape(space.n_y, space.n_p, -1) @@ -357,7 +409,8 @@ def node_power( if full: return _row_energy(physical) nodes = np.zeros(physical.shape[0]) - nodes[self.rows] = _row_energy(physical[self.rows]) + rows = self.tables.rows + nodes[rows] = _row_energy(physical[rows]) return nodes def _fft_workers(self, cube: np.ndarray) -> int: @@ -595,6 +648,7 @@ class TransverseEdge: _returned_peak: float = field(default=0.0, init=False) _read_rows: np.ndarray | None = field(default=None, init=False) _kappa_order: np.ndarray = field(init=False) + tables: EdgeTables = field(init=False) reduction: HostEdgeReduction = field(init=False) def __post_init__(self) -> None: @@ -617,12 +671,40 @@ def __post_init__(self) -> None: self._peak_raw = [0.0] * len(self._faces) self._baseline = [0.0] * len(self._faces) collar = self.design is not None and self.design.has_collar + self.tables = self._edge_tables() self.reduction = HostEdgeReduction( space, - self._rows_read(), + self.tables, self._strip_bounds() if collar else None, ) + def _edge_tables(self) -> EdgeTables: + """Return the weights a state's node energies are summed with. + + Returns + ------- + solprop.transverse_monitor.EdgeTables + The tables, over the grid's nodes (or rows). + """ + weights = self._weights + faces, clip = np.zeros((2, 4, weights.size)) + for index, face in enumerate(self._faces): + faces[index] = weights * face.edge + clip[index] = weights * face.clip + totals = np.zeros((4, weights.size)) + totals[0] = weights + rate = self._node_rate() + if rate is not None: + totals[1] = rate * weights + if self.design is not None and self.design.has_collar: + outside = np.ones(weights.size, dtype=bool) + for face in self._faces: + outside &= ~face.clip + if isinstance(self.space, RadialGrid): + outside = np.asarray(self.space.r) > self._requested_size() + totals[2] = weights * outside + return EdgeTables(faces, clip, totals, self._rows_read()) + def _rows_read(self) -> np.ndarray: """Return the nodes (or rows) a read uses: the faces' and a collar's. @@ -734,10 +816,8 @@ def launch( # noqa: PLR0912, PLR0913, PLR0915 - the launch and judgements, in o When physical light launched into the safety ramp's band exceeds ten budgets (section 5.3). """ - assert reading.nodes is not None and reading.columns is not None - nodes = reading.nodes + assert reading.faces is not None and reading.columns is not None columns = reading.columns - weighted = self._weights * nodes if self._resumed: # The launch was judged by the first execution; its baselines and totals # are restored, and the reads continue from the resumed state. @@ -749,13 +829,14 @@ def launch( # noqa: PLR0912, PLR0913, PLR0915 - the launch and judgements, in o self._last_columns = self._column_power(columns) self._spacing_now = self._spacing(self._last_columns) self._next = z + self._spacing_now if reads else math.inf - self._last_rate = (z, self._collar_rate(nodes)) + self._last_rate = (z, self._collar_rate(reading)) if reading.current is not None and self._launch > 0.0: self._current_last = (z, reading.current / self._launch) if not reads: self._status = "unwatched" return - self._launch = float(np.sum(weighted)) + assert reading.clip is not None and reading.totals is not None + self._launch = float(reading.totals[0]) self._last_z = z if not self._launch > 0.0: self._next = math.inf @@ -780,9 +861,8 @@ def launch( # noqa: PLR0912, PLR0913, PLR0915 - the launch and judgements, in o ) return for index, face in enumerate(self._faces): - share = float(np.sum(weighted[face.edge])) / self._launch - self._baseline[index] = share - clipped = float(np.sum(weighted[face.clip])) / self._launch + self._baseline[index] = float(reading.faces[index]) / self._launch + clipped = float(reading.clip[index]) / self._launch if clipped > warn: warnings.warn( f"the launch holds {clipped:.2e} of its energy in the outer " @@ -835,9 +915,9 @@ def launch( # noqa: PLR0912, PLR0913, PLR0915 - the launch and judgements, in o BoundaryWarning, stacklevel=4, ) - self._last_rate = (z, self._collar_rate(nodes)) + self._last_rate = (z, self._collar_rate(reading)) if self.every_step: - self._launch_return(reading, noise, weighted) + self._launch_return(reading, noise) def _judge_ramp_band( self, @@ -893,23 +973,23 @@ def _judge_ramp_band( stacklevel=5, ) - def _collar_rate(self, nodes: Float[np.ndarray, " n_nodes"]) -> float: + def _collar_rate(self, reading: EdgeReading) -> float: """Return a requested collar's removal rate, as a share of the launch per m. Parameters ---------- - nodes : numpy.ndarray - Each node's unweighted energy. + reading : solprop.transverse_monitor.EdgeReading + The state's reading. Returns ------- float ``sum alpha w |E|^2 / U_launch``; zero without a collar. """ - rate = self._node_rate() - if rate is None: + if self.design is None or self.design.collar_rate is None: return 0.0 - return float(np.sum(rate * self._weights * nodes)) / self._launch + assert reading.totals is not None + return float(reading.totals[1]) / self._launch def _spacing(self, columns: Float[np.ndarray, " n_columns"]) -> float: """Return the read spacing for the current spectrum, ``min(L/16, 4 g / v_q)``. @@ -1017,9 +1097,7 @@ def read(self, reading: EdgeReading, z: float) -> None: """ if not self.wants_read(z): return - assert reading.nodes is not None and reading.columns is not None - nodes = reading.nodes - weighted = self._weights * nodes + assert reading.faces is not None and reading.columns is not None gap = z - self._last_z self._reads += 1 self._largest_gap = max(self._largest_gap, gap) @@ -1031,7 +1109,7 @@ def read(self, reading: EdgeReading, z: float) -> None: self._ramp_noise += float(np.sum(self._noise_columns * taken)) / ( self._launch ) - rate = self._collar_rate(nodes) + rate = self._collar_rate(reading) # Every launch that leaves the edge read sets the rate's first sample. assert self._last_rate is not None previous_z, previous = self._last_rate @@ -1043,8 +1121,8 @@ def read(self, reading: EdgeReading, z: float) -> None: self._next = z + self._spacing_now if self.design is not None and self.design.has_collar: # With a collar, outward flow is intended: recorded at INFO, never stopped. - for index, face in enumerate(self._faces): - share = float(np.sum(weighted[face.edge])) / self._launch + for index in range(len(self._faces)): + share = float(reading.faces[index]) / self._launch self._peak_raw[index] = max(self._peak_raw[index], share) self._judge_return(reading, z) self._judge_ramp_removal() @@ -1052,7 +1130,7 @@ def read(self, reading: EdgeReading, z: float) -> None: warn, stop = self.budget**2, (self.stop_factor * self.budget) ** 2 judgements = [] for index, face in enumerate(self._faces): - share = float(np.sum(weighted[face.edge])) / self._launch + share = float(reading.faces[index]) / self._launch self._peak_raw[index] = max(self._peak_raw[index], share) judged = share - self._baseline[index] - self._allowance self._peak[index] = max(self._peak[index], judged) @@ -1186,7 +1264,6 @@ def _launch_return( self, reading: EdgeReading, noise: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, - weighted: Float[np.ndarray, " n_nodes"], ) -> None: """Record a collar's launch baselines and floors (section 5.3). @@ -1196,21 +1273,14 @@ def _launch_return( The launched state's reading. noise : numpy.ndarray or None Its input noise. - weighted : numpy.ndarray - Each node's weighted energy. """ assert reading.strip is not None strip, _ = self._strip_shares(reading.strip) self._strip_baseline = strip if noise is not None: self._strip_floor = 3.0 * self._strip_shares(self.reduction.strip(noise))[0] - outside = np.ones(weighted.size, dtype=bool) - for face in self._faces: - outside &= ~face.clip - size = self._requested_size() - if isinstance(self.space, RadialGrid): - outside = np.asarray(self.space.r) > size - self._current_baseline = float(np.sum(weighted[outside])) / self._launch + assert reading.totals is not None + self._current_baseline = float(reading.totals[2]) / self._launch self._current_last = None self._current = 0.0 diff --git a/tests/backends/test_driver.py b/tests/backends/test_driver.py index b08f4447..ba2b1ab5 100644 --- a/tests/backends/test_driver.py +++ b/tests/backends/test_driver.py @@ -870,6 +870,11 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): "_finish": "(self, state: _StateT, dz: float) -> _StateT", "_bind_monitor": "(self, monitor: solprop.monitor.BoundaryMonitor | None) -> None", "_boundary_energies": "(self, state: _StateT) -> solprop.monitor.BoundaryEnergies", + "_edge_reading": ( + "(self, state: _StateT, edge: solprop.transverse_monitor.TransverseEdge, *, " + "faces: bool = True, current: bool = False, full: bool = False" + ") -> solprop.transverse_monitor.EdgeReading" + ), "_observed_field": ( "(self, state: _StateT)" " -> jaxtyping.Complex[ndarray, 'n_batch n_modes n_pol n_omega']" diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py new file mode 100644 index 00000000..4d7dd9fb --- /dev/null +++ b/tests/backends/test_native_edge_reading.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Native's reading of the transverse edge, against the host's on one state (SEP 44). + +The state is the same array on both sides, so nothing here is propagation: a reading is +an inverse transform and a sum, and the two may differ by that transform's rounding. +""" + +import dataclasses +from typing import Any, cast + +import numpy as np +import pytest + +from bounds import fft_bound +from solprop.backends.registry import available, select +from solprop.driver import Simulation +from solprop.grid import FreeGrid +from solprop.monitor import BoundaryCheck +from solprop.transverse_monitor import EdgeReading + +pytestmark = pytest.mark.skipif( + "native" not in available(), reason="native is not installed" +) + +ROUTES = ("radial", "cartesian", "line", "planar") + + +def _case(route: str) -> Simulation: + """Return a tiny route, watched, under a check that stops nothing.""" + from capability_routes import ROUTES as CASES # noqa: PLC0415 + + simulation = CASES[route](None) + simulation.settings = dataclasses.replace( + simulation.settings, + boundary_check=BoundaryCheck( + budget=1.0, stop_factor=1.0, transverse_budget=1.0 + ), + ) + return simulation + + +def _noise_state(shape: tuple[int, ...], active: np.ndarray, seed: int) -> np.ndarray: + """Return white noise on the active band, which fills every node and face.""" + rng = np.random.default_rng(seed) + state = rng.standard_normal(shape) + 1j * rng.standard_normal(shape) + state[..., ~active] = 0.0 + return state + + +def _attached(simulation: Simulation, state: np.ndarray) -> Any: + """Return a native backend loaded with `state` and watched by the edge.""" + problem = simulation.translate() + backend = cast( + "Any", + select( + "native", + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ), + ) + backend.set_initial(state, None) + monitor = simulation._boundary_monitor() + assert monitor is not None + assert monitor.transverse is not None + backend._bind_monitor(monitor) + return backend, monitor.transverse + + +def _read_both( + route: str, *, full: bool +) -> tuple[EdgeReading, EdgeReading, np.ndarray]: + """Return native's and the host's reading of one state, and the state.""" + simulation = _case(route) + problem = simulation.translate() + state = _noise_state(problem.shape, np.asarray(problem.grids.active_mask), 41) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading(backend._require_state(), edge, full=full) + host = edge.reduction.reading(state[0], full=full) + finally: + backend.close() + return native, host, state + + +@pytest.mark.parametrize("full", [True, False], ids=["launch", "read"]) +@pytest.mark.parametrize("route", ROUTES) +def test_native_reads_what_the_host_reads(route: str, full: bool) -> None: + native, host, state = _read_both(route, full=full) + samples = state.shape[1] * state.shape[3] + budget = 4 * fft_bound(samples) + assert native.faces is not None and host.faces is not None + assert native.columns is not None and host.columns is not None + np.testing.assert_allclose(native.columns, host.columns, rtol=budget) + # A sum of one region against its own scale: each is non-negative, so the rounding + # is relative to it, and white noise leaves no region empty. + assert np.all(host.faces[host.faces != 0.0] > 0.0) + np.testing.assert_allclose(native.faces, host.faces, rtol=budget) + if full: + assert native.clip is not None and host.clip is not None + assert native.totals is not None and host.totals is not None + np.testing.assert_allclose(native.clip, host.clip, rtol=budget) + np.testing.assert_allclose(native.totals, host.totals, rtol=budget) + else: + assert native.clip is None + assert native.totals is None + + +@pytest.mark.parametrize("route", ROUTES) +def test_native_reads_a_state_it_holds_without_changing_it(route: str) -> None: + simulation = _case(route) + problem = simulation.translate() + state = _noise_state(problem.shape, np.asarray(problem.grids.active_mask), 43) + backend, edge = _attached(simulation, state) + try: + before = backend._native_state.field_to_host() + backend._edge_reading(backend._require_state(), edge, full=True) + backend._edge_reading(backend._require_state(), edge) + after = backend._native_state.field_to_host() + finally: + backend.close() + # The reading works on the scratch: the state's own bits don't move. + assert before.tobytes() == after.tobytes() + + +@pytest.mark.parametrize( + ("node", "lit_faces"), + [((0, 5), {"y-"}), ((5, 0), {"x-"}), ((0, 0), {"x-", "y-"})], + ids=["y", "x", "corner"], +) +def test_one_lit_node_reaches_only_its_own_faces( + node: tuple[int, int], lit_faces: set[str] +) -> None: + # A delta in space is a flat spectrum in the columns. A read with the wrong row + # order, axis or scale fails this at once, where white noise can't tell them apart. + # The low faces: on so coarse a grid the high ones hold no node. + simulation = _case("cartesian") + problem = simulation.translate() + monitor = simulation._boundary_monitor() + assert monitor is not None + assert monitor.transverse is not None + space = monitor.transverse.space + assert isinstance(space, FreeGrid) + active = np.asarray(problem.grids.active_mask) + state = np.zeros(problem.shape, dtype=np.complex128) + y = np.arange(space.n_y)[:, None] + x = np.arange(space.n_x)[None, :] + phase = np.exp(-2j * np.pi * (y * node[0] / space.n_y + x * node[1] / space.n_x)) + state[0, :, 0, int(np.nonzero(active)[0][0])] = phase.reshape(-1) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading(backend._require_state(), edge, full=True) + host = edge.reduction.reading(state[0], full=True) + finally: + backend.close() + assert native.faces is not None + assert host.faces is not None + budget = 4 * fft_bound(space.n_y * space.n_x) + names = [face.name for face in edge._faces] + lit = np.array([name in lit_faces for name in names] + [False] * (4 - len(names))) + np.testing.assert_allclose(native.faces[lit], host.faces[lit], rtol=budget) + # Exactly one node's weight, so the scale is right as well as the agreement. + expected = edge._weights[node[0] * space.n_x + node[1]] + np.testing.assert_allclose(native.faces[lit], expected, rtol=budget) + # The dark faces hold the transform's rounding, on the lit face's scale. + assert np.max(np.abs(native.faces[~lit])) <= budget * expected + + +def test_a_field_free_default_run_crosses_no_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The principle of SEP 44: only numbers cross. Every state-sized read of the host + # goes through `_observed_field` on this route. + from solprop_native.backend import NativeBackend # noqa: PLC0415 + + from solprop.backends.driver import StepDriver # noqa: PLC0415 + + def refused(self: StepDriver[Any], state: Any) -> np.ndarray: + raise AssertionError("a field-free run brought the state to the host") + + monkeypatch.setattr(NativeBackend, "_observed_field", refused) + simulation = _case("cartesian") + simulation.settings = dataclasses.replace( + simulation.settings, fields="none", held_plane=False + ) + problem = simulation.translate() + with simulation.runner() as runner: + output = runner.run(np.asarray(problem.initial.field0)[0], progress="none") + outcome = cast("Any", output).outcome + edge = outcome.execution["boundary_monitor"]["transverse"] + assert edge["reads"] > 0 diff --git a/tests/test_transverse_reads.py b/tests/test_transverse_reads.py index fd9bf860..51768f6d 100644 --- a/tests/test_transverse_reads.py +++ b/tests/test_transverse_reads.py @@ -44,7 +44,7 @@ from solprop.pulses import GaussianPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, Multistep, PIController, RungeKutta -from solprop.transverse_monitor import HostEdgeReduction +from solprop.transverse_monitor import EdgeReading BACKENDS = ("numpy_ref", "native") RELAXED = BoundaryCheck(budget=1.0, transverse_budget=1.0) @@ -395,6 +395,17 @@ def test_the_scaled_miss_is_read_far_above_the_stop() -> None: assert max(record["edge_max"]) > 1e3 * STOP +def _driver_class(backend: str) -> type: + """Return the class of `backend`'s step driver, which holds its edge reading.""" + if backend == "native": + from solprop_native.backend import NativeBackend # noqa: PLC0415 + + return NativeBackend + from solprop.backends.driver import StepDriver # noqa: PLC0415 + + return StepDriver + + @pytest.mark.parametrize("backend", BACKENDS) def test_a_plane_the_step_already_read_reduces_nothing_more( backend: str, monkeypatch: pytest.MonkeyPatch @@ -407,14 +418,15 @@ def test_a_plane_the_step_already_read_reduces_nothing_more( packet = REREVIEW length = packet.length planes = [length / 4, length / 2, length] - calls = {"nodes": 0} - node_power = HostEdgeReduction.node_power + calls = {"readings": 0} + driver = cast("type[Any]", _driver_class(backend)) + edge_reading = driver._edge_reading - def counting(self: HostEdgeReduction, *args: Any, **keywords: Any) -> np.ndarray: - calls["nodes"] += 1 - return node_power(self, *args, **keywords) + def counting(self: Any, *args: Any, **keywords: Any) -> EdgeReading: + calls["readings"] += 1 + return edge_reading(self, *args, **keywords) - monkeypatch.setattr(HostEdgeReduction, "node_power", counting) + monkeypatch.setattr(driver, "_edge_reading", counting) schedule = FixedSchedule.uniform(0.0, length, length / 8, save_planes=planes) with warnings.catch_warnings(): warnings.simplefilter("ignore", BoundaryWarning) @@ -428,7 +440,7 @@ def counting(self: HostEdgeReduction, *args: Any, **keywords: Any) -> np.ndarray assert isinstance(output, PlaneStore) assert edge_record(output)["reads"] == 8 # The launch, then one for each of the eight steps and none for the three planes. - assert calls["nodes"] == 9 + assert calls["readings"] == 9 @pytest.mark.parametrize("integrator", [RungeKutta(), Multistep()], ids=["rk", "adams"]) From b54dd45bc45c19f713e16eaa0c73ea67ac0d51a0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:21:09 +0000 Subject: [PATCH 022/122] Fit the occupancy reduction to HIP's smallest block A reduction's value has to fit the device's shared memory per block. HIP's smallest block is 64 threads, and 128 doubles asked for 67,584 bytes of its 65,536, so every reading with buckets raised on an AMD Instinct MI325X. CUDA shrinks its block instead, so the NVIDIA H200 didn't show it, and an offline HIP compile can't. On HIP a reading now takes two passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/include/solprop_native/core.hpp | 15 +++++++++-- native/src/general.cpp | 35 +++++++++++++++----------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index 7c8b33c3..8dcc6451 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -100,14 +100,25 @@ struct SplitSums { team memory, with no atomics, so its size bounds the buckets (`SPLIT_RATE_BUCKETS`). */ inline constexpr std::size_t occupancy_bucket_limit = 128; +// The buckets one reduction holds. A reduction's value has to fit the device's shared +// memory per block: on HIP, whose smallest block is 64 threads, `(64 + 2) * +// sizeof(value)` within 64 KiB, so a reading there takes two passes. +#if defined(KOKKOS_ENABLE_HIP) +inline constexpr std::size_t occupancy_reduction_slots = 124; +#else +inline constexpr std::size_t occupancy_reduction_slots = occupancy_bucket_limit; +#endif struct OccupancySums { - Kokkos::Array values{}; + Kokkos::Array values{}; KOKKOS_INLINE_FUNCTION OccupancySums& operator+=(const OccupancySums& other) { - for (std::size_t slot = 0; slot < occupancy_bucket_limit; ++slot) + for (std::size_t slot = 0; slot < occupancy_reduction_slots; ++slot) values[slot] += other.values[slot]; return *this; } }; +#if defined(KOKKOS_ENABLE_HIP) +static_assert((64 + 2) * sizeof(OccupancySums) <= 65536); +#endif using ComplexView = Kokkos::View; /** The histogram the boundary monitor's occupancy reading adds into, one slot per diff --git a/native/src/general.cpp b/native/src/general.cpp index b5c2e02a..865e97ae 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -14730,20 +14730,27 @@ std::vector GeneralWorkspace::monitor_reading() { // most of a reading, several steps' time on an AMD Instinct MI325X. const auto buckets = occupancy_buckets_; const auto n_pol = problem_.n_pol_state; - OccupancySums occupancy; - launch::parallel_reduce( - "solprop_native.monitor.occupancy", - Kokkos::RangePolicy(execution_space_, 0, columns), - KOKKOS_LAMBDA(const std::size_t row, OccupancySums& update) { - const std::size_t offset = (row / n_pol) * n; - const double weight = column_weights(row); - for (std::size_t index = 0; index < n; ++index) - update.values[buckets(offset + index)] += - weight * squared_abs(current(row, index)); - }, - Kokkos::Sum(occupancy)); - reading.insert(reading.end(), occupancy.values.data(), - occupancy.values.data() + occupancy_count_); + for (std::size_t first = 0; first < occupancy_count_; + first += occupancy_reduction_slots) { + OccupancySums occupancy; + launch::parallel_reduce( + "solprop_native.monitor.occupancy", + Kokkos::RangePolicy(execution_space_, 0, columns), + KOKKOS_LAMBDA(const std::size_t row, OccupancySums& update) { + const std::size_t offset = (row / n_pol) * n; + const double weight = column_weights(row); + for (std::size_t index = 0; index < n; ++index) { + const std::size_t bucket = buckets(offset + index); + if (bucket >= first && bucket - first < occupancy_reduction_slots) + update.values[bucket - first] += + weight * squared_abs(current(row, index)); + } + }, + Kokkos::Sum(occupancy)); + reading.insert(reading.end(), occupancy.values.data(), + occupancy.values.data() + + std::min(occupancy_reduction_slots, occupancy_count_ - first)); + } } execution_space_.fence("solprop_native monitor reading"); return reading; From 14314134667bfcf1a2517fcf87380e87bc0a29fa Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:24:28 +0000 Subject: [PATCH 023/122] Point the probe at the reading's names, and note the plane skip's end Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- benchmarks/device_split.py | 6 +++--- docs/dev/benchmarks.md | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/benchmarks/device_split.py b/benchmarks/device_split.py index 5f08446d..4f177a2a 100644 --- a/benchmarks/device_split.py +++ b/benchmarks/device_split.py @@ -59,9 +59,9 @@ NAMED = ( "_read_transverse", - "_node_power", - "_occupied_planes", - "_column_power", + "_edge_reading", + "node_power", + "column_power", "_spacing", "_observed_field", "_boundary_energies", diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 3a5eaa73..82f84d77 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -882,7 +882,8 @@ and ×1.015. Two changes reduce it, neither moving a field by more than rounding: - **A read leaves out what cannot reach its resolution** (`READ_RESOLUTION`, - `transverse_monitor.py`). Each read synthesised every frequency plane, and a run's light + `transverse_monitor.py`; removed by [SEP 44](seps/sep-0044-native-edge-read.md), since a + read on the state's own memory has no copy to save). Each read synthesised every frequency plane, and a run's light occupies from a fifth to a third of them at this level on the filaments and Kerr beams. A read now leaves out the planes whose energy together is at most 1e-3 of the warning. By Parseval a plane cannot put more on a face than it holds, so a face's share reads low by From 9f8007e46b700ad0badc4758721ad1b64ce0304c Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:24:28 +0000 Subject: [PATCH 024/122] SEP 44: weight tables, and the record of phases 1 and 2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/seps/sep-0044-native-edge-read.md | 98 ++++++++++++++++------ 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index 37761834..c094b885 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -130,9 +130,9 @@ Nothing public changes, except that `solprop.transverse_monitor.READ_RESOLUTION` numbers can rise by at most the bound above. `memory_ledger` gains a `transverse_edge` row. ```text -StepDriver._edge_reading(state, *, full) -> EdgeReading # host by default -EdgeReading(nodes, columns, strip, current) -EdgeBinding(rows, matrix, lines) # from the edge, at attach +StepDriver._edge_reading(state, edge, *, faces, current, full) -> EdgeReading # host by default +EdgeReading(faces, clip, totals, columns, strip, current) # numbers only +EdgeTables(faces, clip, totals, rows) # four regions' weights on the nodes, from the edge ``` ### 6. Implementation strategies @@ -141,13 +141,21 @@ EdgeBinding(rows, matrix, lines) # from the edge, at a crossing from 2.9 s to about 0.2 s on an H200. It leaves the host's reductions and a host volume per read, and on a CPU it changes nothing. Worth having for a saved plane, not enough here. -**Option B: native forms each face's sum.** Fewest numbers cross. But native would have to hold each face's -masks, weights and a reducer per route, and a parity failure could lie in any of them. +**Option B: native forms each face's sum with its own masks.** Fewest numbers cross. But native would have to +hold each face's masks and a reducer per route, and a parity failure could lie in any of them. -**Option C, recommended: native forms $u_n$ and $c_m$ and returns them.** About 6 MiB per read at 768 x 768, -with $u_n$ only on the edge's rows. Every mask, weight, face and judgement stays in Python, and is the same -code on every backend. Native adds two reductions and carries no geometry beyond the row list. Corners, which -count on two faces, come out right by construction. +**Option C: native forms $u_n$ and $c_m$ and returns them.** About 6 MiB per read at 768 x 768, with $u_n$ only +on the edge's rows. Every mask, weight, face and judgement stays in Python. But $u_n$ sums over planes at each +node: against the scratch's order that is a stride of a whole plane per element, and in the scratch's order it +is a histogram with a thread's copy or an atomic per element, which is what step 1 of this work removed from the +temporal monitor's occupancy. + +**Option E, recommended: native sums against weight tables Python gives it.** The edge gives native three tables, +each four regions' weights on the nodes, and native runs the temporal monitor's existing kernel (`monitor_sums`, in +the scratch's own order, one reduction of five numbers) against them, returning twelve numbers and $c_m$. Native +holds no face, mask or judgement, only rows of weights; there is no reducer per route, so a parity failure can lie +only in the synthesis. Corners count on two faces because two tables hold them. The price is the tables: 96 bytes a +node, 57 MiB at 768 x 768 against a 5.3 GiB state, and an upload per point until a runner shares them. **Option D: native also judges.** Returning to Python per read costs microseconds against a read's tens of milliseconds, and a stop needs Python anyway. Not worth moving. @@ -162,15 +170,15 @@ the host reducer's. `_judge_ramp_band` needs `state - noise`: `set_initial` comp powers before it adds the draw, as $\sum_\text{active} |f|^2 + \sum_\text{inactive} |n|^2$, because a supplied realisation isn't masked. One corner keeps a single copy: a resume with no monitor record. -**Native, Cartesian, line and planar.** `separable_load_current`, then a node-energy pass over the edge's rows -and a column-energy pass on the state. `separable_load_current`'s scale makes the scratch equal the host's -synthesis, the scratch is dead at a committed position on all three marches, and a device fences before reading. -The kernel takes `plane_size` and `planes`, so it has no route branch. Planar's node count is `n_y`, not -`n_nodes`. +**Native, Cartesian, line and planar.** `separable_load_current`, whose scale makes the scratch equal the host's +synthesis, on a scratch that is dead at a committed position on all three marches; then `monitor_sums` on the +faces' table, and at a launch the other two; then a column-energy pass on the state. The kernel takes the plane +count and the node count, so it has no route branch beyond those. Planar's node count is `n_y`, not `n_nodes`. -**Native, radial.** A GEMM of the edge's rows of `_inverse` into `statistics_time_`, which is idle between steps. -`modal_complex_` can't serve, because the retained path relies on its non-crop bins staying zero. The scale includes -`pad_scale`, which the adapter supplies. +**Native, radial.** The same sums, with the node's value a dense product per plane of the synthesis's row and the +state, from the first row a read's regions can touch. It needs no scratch, so neither `statistics_time_` nor +`modal_complex_`, whose non-crop bins the retained path relies on staying zero. It is slower at one thread than +the host's BLAS (§ Part II); a GEMM remains open to it if that ever matters. **Collar.** Python computes each line's strides, window, inward bin range and onset triple, captured by value, so native does no tie-breaking. The strip uses a persistent padded transform per axis. The current accumulates in @@ -185,14 +193,14 @@ the route this SEP is mainly for. It's left out unless a CPU measurement asks fo **Kernel contract.** New `StepDriver` members, so a schema bump under today's rule. `KernelProblem` doesn't change. -**Cost.** Estimated, not measured: 30 to 50 ms a read at 768 x 768 x 600 on a data-centre GPU, against 4.3 s today +**Cost.** Estimated before the work: 30 to 50 ms a read at 768 x 768 x 600 on a data-centre GPU, against 4.3 s then on an H200 and against a step of about 230 ms. On CPU a read now transforms every plane, where the host read -transformed a fifth to a third of them, but it loses the copy and the host's single-thread scan. Its net effect has -to be measured (Q2). +transformed a fifth to a third of them, but it loses the copy and the host's single-thread scan. Part II has what +was measured (Q2). ### 7. Verification and validation -- **Same-state parity.** New `tests/backends/test_edge_reading.py`: native's reading of a state against +- **Same-state parity.** New `tests/backends/test_native_edge_reading.py`: native's reading of a state against `HostEdgeReduction` on that state's own host copy, which removes trajectory rounding and leaves the budgets of `tests/bounds.py`. Over route, scheme and march, with adversarial probes: energy in one (polarisation, last frequency) bin, an anisotropic box with light in one corner, `n_pol = 2`, `n_to != n_t` radially, a planar @@ -239,7 +247,7 @@ physics decision, left to [#388](https://github.com/LupoLab/solprop/issues/388). ### 11. Phases -1. **Python split.** `EdgeReading`, `EdgeBinding`, `HostEdgeReduction`; `READ_RESOLUTION` goes; clean column powers; +1. **Python split.** `EdgeReading`, `EdgeTables`, `HostEdgeReduction`; `READ_RESOLUTION` goes; clean column powers; [#512](https://github.com/LupoLab/solprop/issues/512). Native inherits the host path. Verify: the edge's tests, adapted to pass readings. 2. **Native read, no collar.** All four routes on CPU, with same-state parity and the no-crossing test. Native falls back @@ -254,7 +262,8 @@ physics decision, left to [#388](https://github.com/LupoLab/solprop/issues/388). - **Q1.** Whether `Simulation.estimate` should count a collar's strip buffers, as it does the held plane. Recommended: yes. - **Q2.** Whether a CPU read, with every plane now transformed, stays within SEP 40's 2 % on a long run. If not, the - read-as-first-pass reuse of §6 comes back, on routes where it's provable. Measured in phase 2. + read-as-first-pass reuse of §6 comes back, on routes where it's provable. Measured in phase 2: see Part II. It holds + except for the radial route at one thread, ×1.036. - **Q3.** Whether an interrupted collar run's record should drain the native current on the way out, or may lag by a few steps. Recommended: drain it. @@ -265,7 +274,48 @@ physics decision, left to [#388](https://github.com/LupoLab/solprop/issues/388). ## Part II — Implementation record -No phase has been implemented. +Phases 1 and 2 are implemented on the branch `feature/sep-0044-native-edge`, ahead of this SEP's review. + +**Phase 1, the Python split.** As designed, with `EdgeTables` in place of `EdgeBinding`. `READ_RESOLUTION` and +`_occupied_planes` are gone. The launch's clean column powers are formed at the load. A resume restores a collar's +current, baselines and peaks ([#512](https://github.com/LupoLab/solprop/issues/512)), and the record gained +`strip_floor`. + +**Phase 2, the native read without a collar.** Deviations from the draft: native sums against weight tables (Option E) +rather than returning $u_n$ (Option C), for the reason in §6; the radial read is a dense product per plane rather than +a GEMM. A collar still reduces a host copy. + +*Verification, on CPU.* `tests/backends/test_native_edge_reading.py`, all passing: the reading of one state against +`HostEdgeReduction` on all four routes, at a launch and at a read, within four times `tests/bounds.py`'s `fft_bound`; +the state's bits unchanged by a reading; one lit node reaching only its own faces, with the right weight, which white +noise can't show; and a field-free default run with `_observed_field` refused, which has to finish having read the edge. +`tests/backends/test_transverse_backends.py`, `test_transverse_reads.py`, `test_transverse_monitor.py`, +`test_transverse_nonlinear.py`, `test_transverse_resolution.py`, `test_transverse_witnesses.py`, `test_split_crossing.py` +and `test_monitor.py` pass. + +*Not yet covered from §7:* a forced read at every commit against an unread run, the adversarial probes beyond the lit +node, `n_pol = 2`, and every device row. + +*Timing against `main`, Intel Xeon Gold 6240, 18 cores of one socket.* `python -m benchmarks ab`, `--threading +passive`, three rounds of one repeat. This also holds this work's steps 1 and 2, which measured neutral on CPU alone. +Exploratory, on a shared machine: + +| case | threads | wall, s | ratio (range) | field difference | +|---|---:|---|---:|---:| +| `kerr_beam_192` | 1 | 18.74 → 17.62 | 0.934 (0.934–0.955) | 1.6e-16 | +| `radial_filament_256` | 1 | 7.66 → 7.93 | 1.036 (1.024–1.059) | 4.2e-15 | +| `line` | 1 | 1.017 → 1.011 | 0.997 (0.986–1.008) | 1.4e-15 | +| `planar` | 1 | 0.571 → 0.571 | 1.004 (1.001–1.007) | 0 | +| `kerr_beam_192` | 16 | 3.08 → 2.08 | 0.675 (0.662–0.683) | 0 | +| `radial_filament_256` | 16 | 1.763 → 1.722 | 0.984 (0.936–0.986) | 0 | +| `line` | 16 | 0.296 → 0.286 | 0.934 (0.900–1.053) | 0 | +| `planar` | 16 | 0.172 → 0.163 | 0.945 (0.937–0.980) | 0 | + +The same work in every row. On a point of 256 x 256 x 48, sixteen fixed steps and two planes at sixteen threads, one +runner, the default check went from 2.4 s to 2.0 s against 1.8 s without it, and from 3.9 to 2.1 field volumes above +the level before the run against 1.5 without it. + +*HIP.* The wheel compiles offline for `AMD_GFX942` under ROCm 7.2.4. ## Part III — History From a4f44a4190defb20c15f966a2706f939bd5279e3 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:24:41 +0000 Subject: [PATCH 025/122] Say where the edge is read, and what the check now costs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/sep-0044-native-edge.fixed.md | 1 + docs/dev/backends.md | 18 ++++++++++-------- docs/guide/free-space/model.md | 5 +---- docs/guide/setup/running.md | 16 ++++++++-------- 4 files changed, 20 insertions(+), 20 deletions(-) create mode 100644 changelog.d/sep-0044-native-edge.fixed.md diff --git a/changelog.d/sep-0044-native-edge.fixed.md b/changelog.d/sep-0044-native-edge.fixed.md new file mode 100644 index 00000000..c2ddf8d4 --- /dev/null +++ b/changelog.d/sep-0044-native-edge.fixed.md @@ -0,0 +1 @@ +The default boundary check no longer brings a native run's state to the host. Without a requested collar, the transverse edge is read where the state lives and only numbers cross, so a field-free run on a device copies no state at all, and on the CPU a free-space Kerr beam's check costs less. A read now covers every frequency plane: `solprop.transverse_monitor.READ_RESOLUTION` is gone, and a share can read higher than before by at most `1e-9` of the launch energy at the default budget. A resumed run with a requested collar now restores its current, baselines and peaks, so it no longer judges against zero. (SEP 44, #385, #396, #512) diff --git a/docs/dev/backends.md b/docs/dev/backends.md index a9c5c480..a22a3c5e 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -450,10 +450,16 @@ refuse a fluence negative beyond its rounding budget. The helper as one; its end-of-plane log is retired, and the boundary monitor's transverse edge (SEP 40) judges the boundary instead. A backend whose own statistics do not apply this measure refuses the schema. -**The transverse edge is read on the host** ([SEP 40](seps/archive/sep-0040-transverse-absorbing-boundaries.md)). +**The transverse edge is read where the state lives** ([SEP 40](seps/archive/sep-0040-transverse-absorbing-boundaries.md), [SEP 44](seps/sep-0044-native-edge-read.md)). A backend whose `StepDriver.reads_transverse_edge` is set hands the monitor's -`TransverseEdge` its observed field at the edge's reads, at saved planes, and with a -requested collar at every committed step. The read spacing is a ceiling on the step, and +`TransverseEdge` an `EdgeReading` from `StepDriver._edge_reading` at the edge's reads, at saved planes, and with a +requested collar at every committed step. The reading is numbers and never the field: region sums, each +column's energy and any collar's strip and current. The default forms them with `HostEdgeReduction` on a host copy, +the reference, which `numpy_ref` and `mlx` use. `native` forms them on its own state against +the edge's `EdgeTables`, four regions' weights on the nodes, so it holds no face, mask or judgement, and a +field-free run crosses no state (`tests/backends/test_native_edge_reading.py`). A requested collar +still reduces a host copy on `native`. Everything else, the weights, the spacing, the ledgers and the judgement, is one +code on every backend. The read spacing is a ceiling on the step, and a step is not made to land on a read: the edge is read at the last committed position from which the next step, a replay's scheduled one or else the controller's proposal, would carry past the next read (`TransverseEdge.due`), so @@ -466,11 +472,7 @@ an adaptive step is capped at the spacing, and the call returns at the committed position from which the next step would pass the read, so the host reads the state there. With a collar the host step is one, so the march returns at every committed step. A saved plane reads the edge unless the step that reached it already did (`TransverseEdge.wants_read`), as a second read at one position records nothing. -A native device build reads the edge the same way, copying its state to the host at each -read; it is compiled, not qualified on a device. A backend that does not set it reports -`unwatched` and refuses a requested collar by name. The edge's reductions run on the -host copy of the field; moving them into the native memory space is left to the cost -measurement of SEP 40 section 7.7. +A backend that does not set it reports `unwatched` and refuses a requested collar by name. ## 3. Compiled execution: the design `native` runs diff --git a/docs/guide/free-space/model.md b/docs/guide/free-space/model.md index fe445f23..670b2446 100644 --- a/docs/guide/free-space/model.md +++ b/docs/guide/free-space/model.md @@ -187,10 +187,7 @@ lower bound: light generated late, by a filament, may need more. Reads are space light cannot cross the edge unseen between two of them, at most `min(L/16, 4g/v)` apart, with `g` the edge's width and `v` the spread of the current spectrum, and the spacing is a ceiling on the step: a low-source run, which would otherwise take long steps, takes a few -more. A fixed schedule cannot be held to it, and reports itself as `coarse`. A read -synthesises only the frequencies that hold light: those it leaves out hold, together, at -most a thousandth of the warning, so a share reads low by at most `1e-9` of the launch at -the default budget. With a requested collar it reads every frequency. +more. A fixed schedule cannot be held to it, and reports itself as `coarse`. Every run reports a `transverse_status` in its monitor record and says at the start what it will be able to report: `clear` (read at the spacing, below the warning), `warned`, diff --git a/docs/guide/setup/running.md b/docs/guide/setup/running.md index b29caef6..34cfb1b2 100644 --- a/docs/guide/setup/running.md +++ b/docs/guide/setup/running.md @@ -547,11 +547,11 @@ percent of a digest, about 0.6 s and 9 s for a 4 GiB input on a Xeon without SHA instructions, and it refuses a bad input before it is loaded. **The boundary check on a free-space scan.** The default -[`boundary_check`](../numerics/boundaries.md) reads a free-space run's transverse edge on the -host at the launch, at every plane and at every read: it copies the state across and -transforms it out of place, even with `fields="none"`. That is about 1.1 field volumes more at -the peak, which neither `memory_ledger` nor the admission counts, and on native CPU at 256 × -256 × 96 with two planes it took a point from 0.96 to 2.1 volumes above the level before the -input, and from 3.05 to 3.99 s. A scan whose window is already settled can pass -`boundary_check=None`. That also turns off the check of the time window's edges, since no -setting keeps one without the other, so settle the window on one input with the check on first. +[`boundary_check`](../numerics/boundaries.md) reads a free-space run's transverse edge at the +launch, at every plane and at every read. `native` does it where the state lives, with only numbers +crossing, even on a device. With a requested collar, and on `numpy_ref` and `mlx`, it reads a host copy of the state +instead, and transforms it out of place: on a device that is a whole state's copy at each read. On native +CPU at 256 × 256 × 48, with sixteen steps and two planes at sixteen threads, the check took a point from 1.8 to 2.0 s and +from 1.5 to 2.1 volumes above the level before the run. A scan whose window is already settled can +still pass `boundary_check=None`. That also turns off the check of the time window's edges, since +no setting keeps one without the other, so settle the window on one input with the check on first. From 52c62383754c6ae42fe3fe860b16eebf0c6ead13 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:33:02 +0000 Subject: [PATCH 026/122] Hold the native read to two polarisations and to every march A read at every step takes the transverse scratch: the final field is bit for bit that of the same run with the host's read, on all four routes under each march. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/seps/sep-0044-native-edge-read.md | 10 +- tests/backends/test_native_edge_reading.py | 118 +++++++++++++++++++-- 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index c094b885..3ee6b37e 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -287,14 +287,16 @@ a GEMM. A collar still reduces a host copy. *Verification, on CPU.* `tests/backends/test_native_edge_reading.py`, all passing: the reading of one state against `HostEdgeReduction` on all four routes, at a launch and at a read, within four times `tests/bounds.py`'s `fft_bound`; -the state's bits unchanged by a reading; one lit node reaching only its own faces, with the right weight, which white -noise can't show; and a field-free default run with `_observed_field` refused, which has to finish having read the edge. +the state's bits unchanged by a reading; two polarisations on the radial, Cartesian and line routes; one lit node +reaching only its own faces, with the right weight, which white noise can't show; a read at every step on all four +routes under the Runge-Kutta, Adams and low-storage marches, whose final field is bit for bit that of the same run with +the host's read; and a field-free default run with `_observed_field` refused, which has to finish having read the edge. `tests/backends/test_transverse_backends.py`, `test_transverse_reads.py`, `test_transverse_monitor.py`, `test_transverse_nonlinear.py`, `test_transverse_resolution.py`, `test_transverse_witnesses.py`, `test_split_crossing.py` and `test_monitor.py` pass. -*Not yet covered from §7:* a forced read at every commit against an unread run, the adversarial probes beyond the lit -node, `n_pol = 2`, and every device row. +*Not yet covered from §7:* the rest of the adversarial probes (`n_to != n_t` radially, a planar `p != 0`, a small node +chunk), the no-interference matrix's collars, and the device rows, which this SEP's phase 5 holds. *Timing against `main`, Intel Xeon Gold 6240, 18 cores of one socket.* `python -m benchmarks ab`, `--threading passive`, three rounds of one repeat. This also holds this work's steps 1 and 2, which measured neutral on CPU alone. diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index 4d7dd9fb..8d160817 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -15,23 +15,28 @@ import pytest from bounds import fft_bound +from capability_routes import LENGTH, SPATIAL_GRID +from capability_routes import ROUTES as CASES +from solprop.backends.driver import StepDriver from solprop.backends.registry import available, select from solprop.driver import Simulation -from solprop.grid import FreeGrid +from solprop.grid import FreeGrid, LineGrid, RadialGrid from solprop.monitor import BoundaryCheck +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium +from solprop.stepper import FixedSchedule, LowStorageRungeKutta, Multistep, RungeKutta from solprop.transverse_monitor import EdgeReading pytestmark = pytest.mark.skipif( "native" not in available(), reason="native is not installed" ) +NativeBackend = pytest.importorskip("solprop_native.backend").NativeBackend ROUTES = ("radial", "cartesian", "line", "planar") def _case(route: str) -> Simulation: """Return a tiny route, watched, under a check that stops nothing.""" - from capability_routes import ROUTES as CASES # noqa: PLC0415 - simulation = CASES[route](None) simulation.settings = dataclasses.replace( simulation.settings, @@ -170,14 +175,115 @@ def test_one_lit_node_reaches_only_its_own_faces( assert np.max(np.abs(native.faces[~lit])) <= budget * expected +def _polarised(route: str) -> Simulation: + """Return a tiny free-space route launching two polarisations.""" + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), + 80e-6, + jones=(1, 0.4j), + ) + medium = SpatialMedium("Ar", 1.0) + settings = _case(route).settings + if route == "radial": + return Simulation.from_radial( + SPATIAL_GRID, + RadialGrid(3e-4, 16, collar_fraction=0), + medium, + [], + pulse, + settings=settings, + noise=None, + ) + space = ( + LineGrid(3e-4, 16, collar_fraction=0) + if route == "line" + else FreeGrid(3e-4, 12, 4e-4, 16, collar_fraction=0) + ) + return Simulation.from_free( + SPATIAL_GRID, space, medium, [], pulse, settings=settings, noise=None + ) + + +@pytest.mark.parametrize("route", ["radial", "cartesian", "line"]) +def test_two_polarisations_read_as_the_host_reads(route: str) -> None: + # A column is `mode * n_pol + pol`, so a mode's energy has to gather both. + simulation = _polarised(route) + problem = simulation.translate() + assert problem.shape[2] == 2 + state = _noise_state(problem.shape, np.asarray(problem.grids.active_mask), 47) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading(backend._require_state(), edge, full=True) + host = edge.reduction.reading(state[0], full=True) + finally: + backend.close() + assert native.columns is not None and host.columns is not None + assert native.faces is not None and host.faces is not None + budget = 4 * fft_bound(state.shape[1] * state.shape[3]) + np.testing.assert_allclose(native.columns, host.columns, rtol=budget) + np.testing.assert_allclose(native.faces, host.faces, rtol=budget) + + +@pytest.mark.parametrize( + "march", + [RungeKutta(), Multistep(), LowStorageRungeKutta()], + ids=["runge_kutta", "adams", "low_storage"], +) +@pytest.mark.parametrize("route", ROUTES) +def test_a_read_at_every_step_leaves_the_march_where_it_was( + route: str, march: object, monkeypatch: pytest.MonkeyPatch +) -> None: + # A read takes the transverse scratch at a committed position, which every march + # has to write again before using. Against the same run with the host's read, which + # touches no scratch: same steps, same monitor, one process, so no rounding apart. + def final() -> tuple[np.ndarray, int]: + simulation = _case(route) + simulation.settings = dataclasses.replace( + simulation.settings, + integrator=march, # pyright: ignore[reportArgumentType] + schedule=FixedSchedule.uniform( + 0.0, LENGTH, LENGTH / 4, save_planes=[LENGTH] + ), + ) + problem = simulation.translate() + backend = cast( + "Any", + select( + "native", + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ), + ) + try: + backend.set_initial(problem.initial.field0, None) + monitor = simulation._boundary_monitor() + assert monitor is not None + backend.attach_monitor(monitor) + backend.propagate_to(LENGTH) + record = cast("dict[str, Any]", monitor.summary()) + return backend.observe(field=True).require_field(), int( + record["transverse"]["reads"] + ) + finally: + backend.close() + + native, reads = final() + # Every step is longer than the spacing. + assert reads >= 4 + with monkeypatch.context() as patch: + patch.setattr(NativeBackend, "_edge_reading", StepDriver._edge_reading) + host, host_reads = final() + assert host_reads == reads + assert native.tobytes() == host.tobytes() + + def test_a_field_free_default_run_crosses_no_state( monkeypatch: pytest.MonkeyPatch, ) -> None: # The principle of SEP 44: only numbers cross. Every state-sized read of the host # goes through `_observed_field` on this route. - from solprop_native.backend import NativeBackend # noqa: PLC0415 - - from solprop.backends.driver import StepDriver # noqa: PLC0415 def refused(self: StepDriver[Any], state: Any) -> np.ndarray: raise AssertionError("a field-free run brought the state to the host") From ab880c3df40e2554d74654b0ee815007a7439341 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:39:15 +0000 Subject: [PATCH 027/122] Leave HIP's block room for the occupancy kernel's own memory 124 slots passed Kokkos's check and then faulted on an AMD Instinct MI325X: the check doesn't count the kernel's static memory, and the dispatch went past 64 KiB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/include/solprop_native/core.hpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index 8dcc6451..a3e790f8 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -101,10 +101,13 @@ struct SplitSums { */ inline constexpr std::size_t occupancy_bucket_limit = 128; // The buckets one reduction holds. A reduction's value has to fit the device's shared -// memory per block: on HIP, whose smallest block is 64 threads, `(64 + 2) * -// sizeof(value)` within 64 KiB, so a reading there takes two passes. +// memory per block, which on HIP's smallest block of 64 threads is `(64 + 2) * +// sizeof(value)`, with the kernel's own static memory beside it within 64 KiB. Kokkos +// checks only the first: a value that fills the block passes its check and the dispatch +// then faults on an AMD Instinct MI325X ("Illegal opcode in command stream"). So a +// reading there takes two passes, leaving half the block free. #if defined(KOKKOS_ENABLE_HIP) -inline constexpr std::size_t occupancy_reduction_slots = 124; +inline constexpr std::size_t occupancy_reduction_slots = 64; #else inline constexpr std::size_t occupancy_reduction_slots = occupancy_bucket_limit; #endif @@ -117,7 +120,7 @@ struct OccupancySums { } }; #if defined(KOKKOS_ENABLE_HIP) -static_assert((64 + 2) * sizeof(OccupancySums) <= 65536); +static_assert((64 + 2) * sizeof(OccupancySums) <= 48 * 1024); #endif using ComplexView = Kokkos::View; From a07e922929d3939d1a7555316aa77068b676f8ec Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:49:02 +0000 Subject: [PATCH 028/122] Drop Problem::cubic_real and BoundState's duplicate accessors Nothing has read cubic_real since the State engine went (#88), so the field, the bindings' read, the adapter's key and the test helper's line go. low_storage_state, general_state and adams_state were one accessor under three names: open_state replaces them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/include/solprop_native/core.hpp | 1 - native/src/bindings.cpp | 81 +++++++++++--------------- native/src/solprop_native/backend.py | 1 - native/tests/native/test_core.cpp | 1 - 4 files changed, 35 insertions(+), 49 deletions(-) diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index 43fd006e..916a103d 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -495,7 +495,6 @@ struct Problem { double self_factor{}; double conjugate_factor{}; double harmonic_factor{}; - bool cubic_real{}; bool take_real{}; bool plasma{}; bool envelope_plasma{}; diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index cdd68576..131c843f 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -332,7 +332,6 @@ native::Problem problem_from(const nb::dict& values) { result.self_factor = nb::cast(values["self_factor"]); result.conjugate_factor = nb::cast(values["conjugate_factor"]); result.harmonic_factor = nb::cast(values["harmonic_factor"]); - result.cubic_real = nb::cast(values["cubic_real"]); result.take_real = nb::cast(values["take_real"]); if (values.contains("plasma")) { result.plasma = nb::cast(values["plasma"]); @@ -1368,21 +1367,13 @@ class BoundState { require_open(); general_state_->adopt(field, dz); } - native::GeneralState& low_storage_state() { - require_open(); - return *general_state_; - } - native::GeneralState& general_state() { - require_open(); - return *general_state_; - } - native::GeneralState& adams_state() { + native::GeneralState& open_state() { require_open(); return *general_state_; } // The stage tables an exponential Adams trial reads natively (solprop SEP 39). void set_stage_tables(native::stage::Tables tables) { - adams_state(); + open_state(); if (tables.n_stages != stages_) throw std::invalid_argument("the stage tables name another tableau's stages"); // A z table's rows are its slots: the tables read the state's one cache. @@ -1392,7 +1383,7 @@ class BoundState { throw std::invalid_argument("the stage tables and the z table's slots disagree"); if (phi_table || prefactor_table) { tables.z_slots = &z_slots_; - tables.z_state = &general_state(); + tables.z_state = &open_state(); } stage_tables_ = std::move(tables); stage_anchor_ = {}; @@ -1400,18 +1391,18 @@ class BoundState { // solprop SEP 39 phase 4: the z table's slots, one cache for every consumer. void table_slots(const bool phi, const bool prefactor, const std::size_t slots, const bool rate) { - general_state(); + open_state(); z_slots_.configure(phi, prefactor, slots, rate); } std::vector table_load(const std::vector& positions) { if (!z_slots_.present()) throw std::logic_error("no z table's slots were configured"); - return z_slots_.load(general_state(), positions); + return z_slots_.load(open_state(), positions); } void table_rate_at(const double z, const bool before) { if (!z_slots_.has_rate) throw std::logic_error("the z table reads no rate"); - z_slots_.rate(general_state(), z, before); + z_slots_.rate(open_state(), z, before); } [[nodiscard]] std::uint64_t table_evaluations() const { return z_slots_.evaluations; } [[nodiscard]] bool has_stage_tables() const { return stage_tables_.has_value(); } @@ -1431,7 +1422,7 @@ class BoundState { // solprop SEP 39 phase 2: the native march over the resident engine. void march_configure(march::Policy policy, const bool fused, const bool compensated, const bool interpolate, march::Schedule schedule) { - adams_state(); + open_state(); if (!stage_tables_) throw std::logic_error("the native Adams march needs the stage tables"); if (!schedule.steps.empty() && schedule.edges.size() != schedule.steps.size() + 1) @@ -1447,7 +1438,7 @@ class BoundState { throw std::logic_error("the native Adams march is not configured"); auto state = march_state_from(state_values); const auto bounds = march_bounds_from(bounds_values); - auto& general = adams_state(); + auto& general = open_state(); ResidentEngine engine(general, *stage_tables_, stage_anchor_, march_fused_, march_compensated_, march_interpolate_, march_fault_); march::Result result; @@ -2214,7 +2205,7 @@ NB_MODULE(_core, module_) { const auto scalars = adams_scalars(phi_delta, phi_at, prefactor, density, taper, preionisation, raman_rates, material); - auto& state = self.low_storage_state(); + auto& state = self.open_state(); const nb::gil_scoped_release release; return state.low_storage_step(dz, scalars, rebuild_table); }, @@ -2224,15 +2215,14 @@ NB_MODULE(_core, module_) { "rebuild_table"_a) .def("hold_plane", [](BoundState& self) { - auto& state = self.low_storage_state(); + auto& state = self.open_state(); const nb::gil_scoped_release release; state.hold_plane(); }) - .def("release_plane", - [](BoundState& self) { self.low_storage_state().release_plane(); }) + .def("release_plane", [](BoundState& self) { self.open_state().release_plane(); }) .def("held_to_host", [](BoundState& self) { - auto& state = self.low_storage_state(); + auto& state = self.open_state(); const auto count = self.elements(); auto storage = native::crossing::host_buffer(2 * count); { @@ -2248,7 +2238,7 @@ NB_MODULE(_core, module_) { "adams_allocate", [](BoundState& self, const std::size_t slots, const bool compensated, const bool interpolate) { - self.adams_state().adams_allocate(slots, compensated, interpolate); + self.open_state().adams_allocate(slots, compensated, interpolate); }, "slots"_a, "compensated"_a = false, "interpolate"_a = true) .def( @@ -2256,7 +2246,7 @@ NB_MODULE(_core, module_) { [](BoundState& self, const std::size_t slot, const RealInput rate) { const auto weights = real_vector(rate); const nb::gil_scoped_release release; - self.adams_state().adams_compensation_start(slot, weights); + self.open_state().adams_compensation_start(slot, weights); }, "slot"_a, "rate"_a.noconvert()) .def( @@ -2269,7 +2259,7 @@ NB_MODULE(_core, module_) { adams_scalars(phi_delta, phi_at, prefactor, density, taper, preionisation, raman_rates, material); const nb::gil_scoped_release release; - self.adams_state().adams_start(slot, scalars); + self.open_state().adams_start(slot, scalars); }, "slot"_a, "phi_delta"_a.noconvert(), "phi_at"_a.noconvert(), "prefactor"_a.noconvert(), "density"_a.noconvert(), "taper"_a.noconvert(), @@ -2277,7 +2267,7 @@ NB_MODULE(_core, module_) { .def("adams_magnitude", [](BoundState& self) { const nb::gil_scoped_release release; - return self.adams_state().adams_magnitude(); + return self.open_state().adams_magnitude(); }) .def( "adams_scale", @@ -2285,7 +2275,7 @@ NB_MODULE(_core, module_) { const RealInput weights) { const auto values = real_vector(weights); const nb::gil_scoped_release release; - self.adams_state().adams_scale(rtol, atol, values); + self.open_state().adams_scale(rtol, atol, values); }, "rtol"_a, "atol"_a, "weights"_a.noconvert()) .def( @@ -2305,7 +2295,7 @@ NB_MODULE(_core, module_) { std::vector norms; { const nb::gil_scoped_release release; - norms = self.adams_state().adams_fused_trial(dz, weights, scale, scalars); + norms = self.open_state().adams_fused_trial(dz, weights, scale, scalars); } return real_output(std::move(norms), 1); }, @@ -2323,20 +2313,19 @@ NB_MODULE(_core, module_) { // The interpolant's slopes are the new source and these rows. slopes.width = interpolate ? slopes.width + 1 : 0; const nb::gil_scoped_release release; - self.adams_state().adams_fused_accept(dz, kept, free, slopes); + self.open_state().adams_fused_accept(dz, kept, free, slopes); }, "dz"_a, "keep"_a.noconvert(), "free"_a, "dense"_a.noconvert(), "interpolate"_a) - .def("adams_hold", [](BoundState& self) { self.adams_state().adams_hold(); }) - .def("adams_restore", - [](BoundState& self) { self.adams_state().adams_restore(); }) + .def("adams_hold", [](BoundState& self) { self.open_state().adams_hold(); }) + .def("adams_restore", [](BoundState& self) { self.open_state().adams_restore(); }) // solprop SEP 29 G2: the resident history's persistent rows across a resume. .def("adams_row_names", - [](BoundState& self) { return self.adams_state().adams_row_names(); }) + [](BoundState& self) { return self.open_state().adams_row_names(); }) .def( "adams_rows_to_host", [](BoundState& self, const std::string& name) { - auto& state = self.adams_state(); + auto& state = self.open_state(); const auto extents = state.adams_row_extents(name); // The array's own storage is written once, as the host engine's copy is. auto storage = @@ -2358,7 +2347,7 @@ NB_MODULE(_core, module_) { .def( "adams_rows_from_host", [](BoundState& self, const std::string& name, const ComplexInput rows) { - auto& state = self.adams_state(); + auto& state = self.open_state(); // The extents are checked against the row's view before any copy. const auto extents = state.adams_row_extents(name); if (rows.ndim() != 3 || rows.shape(0) != extents[0] || @@ -2383,7 +2372,7 @@ NB_MODULE(_core, module_) { if (edges.ndim() != 1) throw std::invalid_argument( "z table breakpoints must be one-dimensional"); - auto& state = self.general_state(); + auto& state = self.open_state(); const nb::gil_scoped_release release; state.table_upload(table_kind(kind), real_vector(edges), lower, upper, coefficients.data(), coefficients.shape(0), @@ -2399,7 +2388,7 @@ NB_MODULE(_core, module_) { "z table slots and positions must be one-dimensional"); const auto held_slots = index_vector(slots); const auto held_positions = real_vector(positions); - auto& state = self.general_state(); + auto& state = self.open_state(); const nb::gil_scoped_release release; state.table_evaluate(held_slots, held_positions); }, @@ -2408,7 +2397,7 @@ NB_MODULE(_core, module_) { "table_rate", [](BoundState& self, const std::size_t slot, const double z, const bool before) { - auto& state = self.general_state(); + auto& state = self.open_state(); const nb::gil_scoped_release release; state.table_rate(slot, z, before); }, @@ -2416,7 +2405,7 @@ NB_MODULE(_core, module_) { .def( "table_terms_to_host", [](BoundState& self, const std::string& kind, const std::size_t slot) { - auto& state = self.general_state(); + auto& state = self.open_state(); std::vector values; { const nb::gil_scoped_release release; @@ -2430,7 +2419,7 @@ NB_MODULE(_core, module_) { "phase_sum_to_host", [](BoundState& self, const RealInput weights) { const auto held = real_vector(weights); - auto& state = self.general_state(); + auto& state = self.open_state(); std::vector values; { const nb::gil_scoped_release release; @@ -2459,7 +2448,7 @@ NB_MODULE(_core, module_) { adams_scalars(phi_delta, phi_at, prefactor, density, taper, preionisation, raman_rates, material); const nb::gil_scoped_release release; - self.adams_state().adams_observe(table, hermite, scalars); + self.open_state().adams_observe(table, hermite, scalars); }, "weights"_a.noconvert(), "hermite"_a, "phi_delta"_a.noconvert(), "phi_at"_a.noconvert(), "prefactor"_a.noconvert(), "density"_a.noconvert(), @@ -2517,7 +2506,7 @@ NB_MODULE(_core, module_) { std::vector norms; { const nb::gil_scoped_release release; - norms = self.adams_state().adams_exponential_trial( + norms = self.open_state().adams_exponential_trial( dz, weights, scale, step, sample, rate_weights); } return real_output(std::move(norms), 1); @@ -2591,7 +2580,7 @@ NB_MODULE(_core, module_) { std::vector norms; { const nb::gil_scoped_release release; - norms = self.adams_state().adams_exponential_trial( + norms = self.open_state().adams_exponential_trial( dz, weights, scale, trial.step, trial.sample, rate); } return real_output(std::move(norms), 1); @@ -2613,7 +2602,7 @@ NB_MODULE(_core, module_) { { const nb::gil_scoped_release release; norms = - self.adams_state().adams_fused_trial(dz, weights, scale, trial.step); + self.open_state().adams_fused_trial(dz, weights, scale, trial.step); } return real_output(std::move(norms), 1); }, @@ -2655,7 +2644,7 @@ NB_MODULE(_core, module_) { auto slopes = adams_rows(dense); slopes.width = interpolate ? slopes.width + 1 : 0; const nb::gil_scoped_release release; - self.adams_state().adams_exponential_accept(dz, free, slopes); + self.open_state().adams_exponential_accept(dz, free, slopes); }, "dz"_a, "free"_a, "dense"_a.noconvert(), "interpolate"_a) .def( @@ -2686,7 +2675,7 @@ NB_MODULE(_core, module_) { // The accepted step's carrier frame, its history rows after the endpoint. carrier_frame(weights, carrier, count == 0 ? 0 : count - 1); const nb::gil_scoped_release release; - self.adams_state().adams_exponential_observe(xi, dz, weights); + self.open_state().adams_exponential_observe(xi, dz, weights); }, "xi"_a, "dz"_a, "coefficients"_a.noconvert(), "dense_weight"_a.noconvert(), "dense_theta"_a.noconvert(), "carrier"_a.noconvert()) diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 177df98f..3cc8c184 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -3813,7 +3813,6 @@ def _native_problem( "self_factor": 0.0 if kerr is None else kerr.self_factor, "conjugate_factor": 0.0 if kerr is None else kerr.conjugate_factor, "harmonic_factor": 0.0 if kerr is None else kerr.harmonic_factor, - "cubic_real": kerr is not None and kerr.cubic_form == "real", "take_real": kerr is not None and kerr.take_real, "plasma": plasma is not None, "raman": raman is not None, diff --git a/native/tests/native/test_core.cpp b/native/tests/native/test_core.cpp index 3b142699..ee9a9d5f 100644 --- a/native/tests/native/test_core.cpp +++ b/native/tests/native/test_core.cpp @@ -124,7 +124,6 @@ native::Problem base_problem(const native::FieldFamily family) { problem.self_factor = 0.5; problem.conjugate_factor = 0.25; problem.harmonic_factor = 0.0; - problem.cubic_real = family == native::FieldFamily::field_real; problem.take_real = family != native::FieldFamily::envelope; return problem; } From 2debed2637a1ccc6c8d77b84c3421dfa351dc725 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:49:03 +0000 Subject: [PATCH 029/122] Replace KERNEL_SCHEMA_VERSION with a source digest The number had 14 literal sites in 10 files, moved 11 times in 12 days, collided 3 times between parallel pull requests, and missed a change made only in C++. It ends at 45. The build embeds the digest of what the extension is compiled from: CMakeLists.txt and the C++ under src and include, hashed by a custom command that writes one small translation unit, so an edit neither reconfigures nor recompiles a heavy file. build_info() reports it as source_digest. In an editable checkout check_build() recomputes it, once per process, and refuses an extension built from other sources, naming uv sync; a wheel has no sources beside it and skips the comparison. The name tuples stay the seam's guard. KernelProblem.schema_version goes, with both backends' comparisons, the batch descriptor's key, the debug summary's number and check_build's problem argument. The kernel number never entered a configuration hash or a resume check. A native run's record now names source_digest in backend.settings, outside the hash, where it named schema_version. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- AGENTS.md | 15 +- changelog.d/345-kernel-schema-44.changed.md | 3 +- changelog.d/native-source-digest.changed.md | 7 + .../split-collar-kernel-schema.changed.md | 3 +- docs/dev/architecture.md | 2 +- docs/dev/backends.md | 26 +- docs/dev/contributing.md | 3 +- docs/dev/figures/build.py | 8 +- docs/dev/figures/kernel_contract.svg | 2 +- docs/dev/figures/one_run.svg | 5 +- docs/dev/kernel-contract.md | 36 +- docs/dev/kernel-problem.md | 9 +- docs/dev/seps/sep-0000-process.md | 3 +- docs/dev/seps/template.md | 7 +- docs/guide/performance/building-native.md | 15 +- docs/guide/performance/native.md | 15 +- native/AGENTS.md | 16 +- native/CMakeLists.txt | 26 +- native/README.md | 14 +- native/cmake/SourceDigest.cmake | 39 ++ native/src/bindings.cpp | 7 +- native/src/solprop_native/__init__.py | 94 +++-- native/src/solprop_native/backend.py | 13 +- native/tests/test_api_contract.py | 142 +++++++- native/tests/test_backend.py | 45 +-- src/solprop/backends/numpy_ref/backend.py | 22 +- src/solprop/driver/batch.py | 1 - src/solprop/translate/__init__.py | 2 - src/solprop/translate/problem.py | 335 +----------------- src/solprop/translate/responses.py | 4 +- tests/backends/conftest.py | 1 - tests/backends/test_carrier_frame.py | 7 - tests/backends/test_driver.py | 82 +---- tests/backends/test_mixture_channels.py | 1 - tests/backends/test_numpy_ref_backend.py | 24 -- tests/data/measure_polarised_rows.py | 3 +- tests/data/measure_power_identity.py | 27 +- tests/translate/test_dispersive_kerr_terms.py | 2 - tests/translate/test_problem.py | 58 +-- tests/translate/test_stage.py | 1 - 40 files changed, 435 insertions(+), 690 deletions(-) create mode 100644 changelog.d/native-source-digest.changed.md create mode 100644 native/cmake/SourceDigest.cmake diff --git a/AGENTS.md b/AGENTS.md index 1cdb2822..53bff147 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -353,11 +353,16 @@ The long form is [`backends.md`](docs/dev/backends.md). tolerance. Adaptive work counts may differ; accounting for actual work is exact. - **In-place and `out=`** only inside the backends, wrapped by the seam; every buffer is listed by `memory_ledger()`, which is tested. -- Device arrays never cross the seam; backends take and return host arrays. -- **`KERNEL_SCHEMA_VERSION` versions the whole seam** (the `KernelProblem` fields, the - protected `StepDriver` surface and the stage bundles of `translate/stage.py`, frozen in - `tests/backends/test_driver.py`). Changing any of them bumps the version, rewrites the - literal in the same commit and adds a release-note fragment. +- Device arrays never cross the `Backend` protocol, whose methods take and return host + arrays. Between `StepDriver` and its subclass the state is an opaque handle (an MLX + array, a native state), and the shared code reads it only through `_observed_field`, + which copies it to the host. +- **The seam has no version number** (ADR-066). A change to the `KernelProblem` fields, + the protected `StepDriver` surface or the stage bundles of `translate/stage.py` rewrites + the frozen tables in `tests/backends/test_driver.py` in the same commit and adds a + release-note fragment. solprop and solprop-native are released together and pinned to + one version, and an editable checkout's extension is compared with its `native/` sources + by digest (`check_build`). - Every hot-path change comes with a benchmark ledger entry ([`benchmarks.md`](docs/dev/benchmarks.md)), measured with `python -m benchmarks ab` on about ten steps or a short `ab --length` window; report the window measured and diff --git a/changelog.d/345-kernel-schema-44.changed.md b/changelog.d/345-kernel-schema-44.changed.md index a2108d21..fb41c193 100644 --- a/changelog.d/345-kernel-schema-44.changed.md +++ b/changelog.d/345-kernel-schema-44.changed.md @@ -1,3 +1,2 @@ Kernel schema 44: `StepDriver` gains `set_held_plane` and `_drop_held`, and the native -state `release_plane`. A solprop-native build of an earlier schema is refused at import; -rebuild it from this tree. +state `release_plane`. Rebuild solprop-native from this tree. diff --git a/changelog.d/native-source-digest.changed.md b/changelog.d/native-source-digest.changed.md new file mode 100644 index 00000000..16f1f956 --- /dev/null +++ b/changelog.d/native-source-digest.changed.md @@ -0,0 +1,7 @@ +`KERNEL_SCHEMA_VERSION`, which ended at 45, and `KernelProblem.schema_version` are gone, and +`solprop_native.BuildInfo.schema_version` is now `source_digest`, the SHA-256 of the C++ +sources and `CMakeLists.txt` the extension was compiled from: a native run's record names it +in `backend.settings` in place of the number. Neither enters a configuration hash or a resume +check. `solprop_native.check_build()` takes no problem, and in an editable checkout refuses +an extension built from other sources than `native/`, naming `uv sync --reinstall-package +solprop-native` (ADR-066). diff --git a/changelog.d/split-collar-kernel-schema.changed.md b/changelog.d/split-collar-kernel-schema.changed.md index de79c72d..3415d2fa 100644 --- a/changelog.d/split-collar-kernel-schema.changed.md +++ b/changelog.d/split-collar-kernel-schema.changed.md @@ -3,5 +3,4 @@ returns the energy and the guards' and collar's energies of the power each step' found (`solprop.monitor.BoundaryEnergies.found`) and the state's energy per walk-off bucket (`BoundaryEnergies.occupancy`), from the problem's new `split_crossing`; the native reading grows from six numbers to ten plus the buckets, and the native Adams march -takes the crossing cap per call. A solprop-native build of an earlier schema is refused -at import; rebuild it from this tree. +takes the crossing cap per call. Rebuild solprop-native from this tree. diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 4581d7ff..92a5ee17 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -255,7 +255,7 @@ user script / prop_capillary(...) (D) │ Boundaries, StepperSettings, Output ▼ Simulation(...) (D) - │ .translate() → KernelProblem (validated arrays, schema_version) (T) + │ .translate() → KernelProblem (validated arrays) (T) │ .backend = backends.select(name, problem) (B) ▼ run loop over save planes and stats periods (D) diff --git a/docs/dev/backends.md b/docs/dev/backends.md index a9c5c480..e41ef5c4 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -23,7 +23,7 @@ refused otherwise. Explicit names are preserved, so `backend="numpy_ref"` runs w absent and `backend="native"` on a processor below the baseline raises the package's refusal. `select` also accepts `"auto"`, checks `SOLPROP_REQUIRE_DEVICE` against the actual build before anything is allocated, and logs the actual backend at INFO after -successful construction. Import, schema, device and unsupported-problem errors stay visible and +successful construction. Import, build, device and unsupported-problem errors stay visible and stay distinct, and the registry finds the processor baseline out by running the package's own pure-Python check by path, without importing the package. This selection policy changes no kernel-contract member (ADR-029). @@ -373,7 +373,7 @@ It consumes the contract only through `linop.delta_phi`, `linop.unitary_phase`, `responses.prefactor_at` and the scalar tables `responses.density`, `transform.taper_scale`, `transform.aeff`, the plasma group's `preionisation` and the Raman decay rates, so a change of the operator's representation cannot reach it. It refuses, by -name and with `BackendError`: an unknown `schema_version`; a spectral response other than +name and with `BackendError`: a spectral response other than the compiled envelope-plasma assembly; scalar-only Raman on a two-component problem; general z-dependent response data; a transform and a state that would both carry the transverse components; and a batch axis above one. @@ -1186,17 +1186,21 @@ backend runs on the CPU (OpenMP) or a device. How to build it is [Building the native backend](../guide/performance/building-native.md); the native rules are in `native/AGENTS.md`. -The backend depends on solprop's versioned seam, `KERNEL_SCHEMA_VERSION` in -`solprop.translate.problem`: the `KernelProblem`, the protected surface of `StepDriver` and the stage bundles, all frozen under -`KERNEL_SCHEMA_VERSION` by `tests/backends/test_driver.py`. Its `check_build` refuses another -schema and looks the driver members, bundle fields and contract tuples it relies on up by -name. It records the repository version it was built at without comparing it, so an -editable build stays valid across solprop commits until the schema bumps. Solprop knows only the registry +The backend depends on solprop's seam: the `KernelProblem`, the protected surface of `StepDriver` +and the stage bundles, all frozen by `tests/backends/test_driver.py`. A change to any of them +rewrites those tables in the same commit and adds a release-note fragment. The two packages are +released together, and solprop's metadata pins this one to its own version. `check_build` looks +the driver members, bundle fields and contract tuples the adapter relies on up by name. In an editable +checkout it also recomputes the digest of `native/CMakeLists.txt` and the C++ under `native/src` +and `native/include`, once per process, and refuses an extension built from anything else +(`BuildInfo.source_digest`, ADR-066), which catches a C++-only change too. It records the repository +version it was built at without comparing it, so an editable build stays valid across solprop +commits that leave `native/` alone. Solprop knows only the registry row and imports the package only when it is selected. -**Every backend refuses what it cannot execute.** A schema check is not enough on the -general modal route: its optional leaves (the coupled flow, the moving guide and the -others) are part of the schema whether or not a backend executes them, and a backend that +**Every backend refuses what it cannot execute.** The general modal route's optional +leaves (the coupled flow, the moving guide and the others) are part of the contract whether +or not a backend executes them, and a backend that accepted a problem carrying one it ignores would silently run a different model. A backend therefore calls {py:func}`~solprop.backends.driver.refuse_general_leaves` before it runs, naming in diff --git a/docs/dev/contributing.md b/docs/dev/contributing.md index cc3bb8e0..f51c85b3 100644 --- a/docs/dev/contributing.md +++ b/docs/dev/contributing.md @@ -58,7 +58,8 @@ uncommitted changes, touches neither `native/` nor the solprop modules that `sol imports. Anything else is refused, and so is an expired artifact; each refusal names `uv sync` and the lightweight setup. A later plain `uv sync` or `uv run` builds native in its place, so run with `uv run --no-sync`. The wheel keeps the commit it was built from, and `check_build`'s -comparison of `KERNEL_SCHEMA_VERSION` remains the guard at run time. +lookup of the seam's names remains the guard at run time; the comparison of source digests is +for an editable checkout, and a wheel has no sources beside it to compare. ## Agree and implement a change diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 123ea933..e94d5a9c 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -30,8 +30,6 @@ from pathlib import Path from typing import NamedTuple -from solprop.translate.problem import KERNEL_SCHEMA_VERSION - try: import drawsvg except ImportError: # pragma: no cover - only on an install without the dev group @@ -877,7 +875,7 @@ class Stage(NamedTuple): "translate.assemble(...) → KernelProblem", "backends.select(name, problem, grid=, modes=, profiles=)", ), - "validated arrays, schema_version", + "validated arrays", ), Stage( "B", @@ -936,7 +934,7 @@ class Section(NamedTuple): Section( 1, "identity and validation", - ("schema_version", "grid_kind", "shape", "flags", "precision"), + ("grid_kind", "shape", "flags", "precision"), "translate.problem.Flags", ("derived from the content of §2 to §8", "by translate.assemble"), "T", @@ -1684,7 +1682,7 @@ def _key_mark(drawing: drawsvg.Drawing, x: float, y: float, mark: str) -> None: ( "T", "KernelProblem", - (f"plain arrays, frozen, validated, schema_version {KERNEL_SCHEMA_VERSION}",), + ("plain arrays, frozen, validated",), ), ("B", "StepSummary", ("z, n_steps, n_rejected, max_norm, mean_norm, wall_time",)), ( diff --git a/docs/dev/figures/kernel_contract.svg b/docs/dev/figures/kernel_contract.svg index 8efa3caf..192261d4 100644 --- a/docs/dev/figures/kernel_contract.svg +++ b/docs/dev/figures/kernel_contract.svg @@ -80,7 +80,7 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: derived from the content of §2 to §8 by translate.assemble -§1 schema_version · grid_kind · shape · flags · precision +§1 grid_kind · shape · flags · precision identity and validation translate.problem.Flags diff --git a/docs/dev/figures/one_run.svg b/docs/dev/figures/one_run.svg index cd606706..15292b73 100644 --- a/docs/dev/figures/one_run.svg +++ b/docs/dev/figures/one_run.svg @@ -93,11 +93,10 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: backends.select(name, problem, grid=, modes=, profiles=) KernelProblem -plain arrays, frozen, validated, schema_version 45 +plain arrays, frozen, validated -validated arrays, -schema_version +validated arrays diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md index edef9c1c..cabe44e8 100644 --- a/docs/dev/kernel-contract.md +++ b/docs/dev/kernel-contract.md @@ -18,9 +18,9 @@ operator, the quadrature, the compiled responses — are in [algorithms.md](algo already converted from the optics-convention user inputs; the output layer converts observations back. Backends never conjugate. -**The kernel schema and the saved-output schema are versioned independently.** The -kernel schema's current number is stated in §1 (`KERNEL_SCHEMA_VERSION`), the saved -output's in [the output guide](../guide/results/output.md#the-schema-version) +**The saved output has a schema version, and this contract has none.** §1 says how the +contract is guarded instead; the saved output's number is in +[the output guide](../guide/results/output.md#the-schema-version) (`solprop.output.SCHEMA_VERSION`). Internal modal amplitudes are vacuum normalised; the launch and observation boundaries carry the true-power conversion, and every backend reads the modal power weight (§3) in its @@ -43,7 +43,6 @@ hard-mask zeros, and explicitly exact zeros and no-ops. | field | type | meaning | |---|---|---| -| `schema_version` | `int` | incremented on every change to this contract, to the protected surface of `StepDriver` or to the stage bundles of `translate/stage.py` (both frozen in `tests/backends/test_driver.py`) | | `grid_kind` | `"field" \| "envelope"` | selects r2c/c2r vs c2c transforms and the response formulas | | `shape` | `(n_batch, n_modes, n_pol, n_omega)` | canonical state shape | | `flags` | frozen dataclass of static flags and counts | `single_mode`, `scalar_pol`, `single_batch`, `has_plasma`, `has_raman`, `has_spectral_responses`, `has_noise`, `has_temporal_absorber`, `constant_linop`, `mode_averaged`, `n_channels`, `n_raman` — kernels specialise on these at compile time. Derived from the content, never passed in | @@ -94,12 +93,16 @@ expansion. A total-degree expansion is not part of the contract: it failed the joint memory and build-cost criterion on the short fixture. -The current version is kernel schema **45** -(`solprop.translate.problem.KERNEL_SCHEMA_VERSION`), and the tables of this document -describe it. A version bump that adds an optional field -leaves every problem that does not use it unchanged, `schema_version` itself being the -one thing that moves. Removing an admitted value needs no bump, because no consumer can -misread a problem that no longer carries it. +**The contract has no version number** (ADR-066). `KERNEL_SCHEMA_VERSION` ended at 45; +"kernel schema N" elsewhere in these documents and in the code names the number a change +took, as history. The tables of this document describe the contract as it stands. A +change to it, to the protected surface of `StepDriver` or to the stage bundles of +`translate/stage.py` rewrites the frozen tables in `tests/backends/test_driver.py` in the +same commit and adds a release-note fragment. solprop and solprop-native are released +together and pinned to one version; `check_build` looks up every member the native adapter +relies on by name, and in an editable checkout compares the extension with its `native/` +sources by digest (`BuildInfo.source_digest`). An added optional field leaves every problem +that does not use it unchanged. **Batches and device memory.** `BatchBackend` batches independently translated and loaded member backends, which carry their own parameter tables and noise across construction; @@ -199,8 +202,8 @@ refuses such a problem by name through the shared admission list. **The frozen literals and the contract tuples.** `tests/backends/test_driver.py` freezes `DRIVER_SURFACE`, `LINEAR_OPERATOR_FIELDS` (`dataclasses.fields(LinearOperator)`), -`STAGE_TERMS_FIELDS`, `STAGE_SCALARS_FIELDS` and `SEPARABLE_OPERATOR_FIELDS` under -`KERNEL_SCHEMA_VERSION`; a bump rewrites the literal it changes. `solprop-native` exports +`STAGE_TERMS_FIELDS`, `STAGE_SCALARS_FIELDS` and `SEPARABLE_OPERATOR_FIELDS`; a change to +the seam rewrites the literal it changes. `solprop-native` exports its own tuples of the names it relies on — `LINEAR_OPERATOR_FIELDS`, `TRANSFORM_FIELDS`, `RESPONSE_FIELDS`, `STAGE_SCALARS_FIELDS`, and `MODAL_TABLES_FIELDS` (`electric`, `omega_fine`, `projection`, `region_index`, `source_frequency_factor`, `synthesis`), @@ -292,8 +295,7 @@ and shape; masks Boolean; breakpoints strictly increasing and covering `[0, z_ma kinks are breakpoints; every `ZProfile` table covers the same z range as `Phi`; every general-modal leaf tabulated along z covers `[0, z_max]` and carries `grids.omega` itself; a replayed -schedule lands on every save plane and hard kink; `schema_version` equals the backend's -expected version. A failed check raises `TranslationError` naming the field. Quadrature +schedule lands on every save plane and hard kink. A failed check raises `TranslationError` naming the field. Quadrature weights and mode matrices are checked in `modes.quadrature`; physical modal weights are also carried as `transform.node_weights` for species inventories. @@ -1101,8 +1103,7 @@ steps produce rows and passes their exact history indices: The defaults keep a host buffer and call `stats()`. No backend needs to override the march or read its private row buffer. Device arrays remain behind the -seam. The native backend inherits the host defaults. A backend must implement the current -schema (§1), +seam. The native backend inherits the host defaults. A backend must implement the contract, including the batch admission interface of §1, explicit handling or refusal of envelope source assemblies, and an explicit refusal of each general-modal leaf, section method and spatial kind it does not execute. @@ -1133,7 +1134,8 @@ the step history and over the same interval, and a row may not index past it. 1. Add the field to the dataclass with a default under which a problem that does not use it is unchanged. -2. Add the row here and bump `schema_version`. +2. Add the row here, rewrite the frozen tables the change moves + (`tests/backends/test_driver.py`) and add a release-note fragment. 3. Extend `validate()`. 4. Implement in `numpy_ref` first, with a test; then in the other backends, with the equivalence test. diff --git a/docs/dev/kernel-problem.md b/docs/dev/kernel-problem.md index f8955595..59c42684 100644 --- a/docs/dev/kernel-problem.md +++ b/docs/dev/kernel-problem.md @@ -152,7 +152,7 @@ A backend calls {meth}`solprop.translate.problem.KernelProblem.validate` and ref anything inconsistent. Most of the contract's promises are kept by construction — each group freezes and checks its own arrays, so a writeable array or a mismatched interval count cannot be built in the first place — and what `validate` adds is everything that spans -groups: the schema version; the shapes against the canonical state shape; finiteness; that +groups: the shapes against the canonical state shape; finiteness; that the $\Phi$ table covers the run; that every table along z spans the same range; and that a replayed fixed schedule lands on every save plane and every hard kink. A failure raises {class}`solprop.exceptions.TranslationError` naming the field. @@ -268,7 +268,7 @@ save planes [0.5, 1.0] and a backend built on it needs to know nothing else. -## Rebuilding, and the schema version +## Rebuilding When a mixture is updated — in a scan over fill fractions, say ({py:meth}`~solprop.driver.simulation.Simulation.with_mixture`) — the rebuild carries a @@ -279,6 +279,5 @@ the parameter ranges still cover the new paths; changing the fill fractions inva pressure expansion. Scalar weights and material tables are rebuilt. The record stays on the host and never crosses to the backend. -The problem carries a schema version, which every backend checks before it runs one; a -saved output file carries its own, independent metadata schema. The current numbers are -stated at the top of the [kernel contract](kernel-contract.md). +The problem carries no version number: the [kernel contract](kernel-contract.md) §1 says +what guards it. A saved output file carries its own metadata schema. diff --git a/docs/dev/seps/sep-0000-process.md b/docs/dev/seps/sep-0000-process.md index c09bbd71..d77e91cd 100644 --- a/docs/dev/seps/sep-0000-process.md +++ b/docs/dev/seps/sep-0000-process.md @@ -293,7 +293,8 @@ and compares options; it is not a line-by-line plan, which belongs to the implem A SEP is Final only when the backends that run the feature agree within tolerances derived from summation bounds, as for every other feature ([testing.md](../testing.md)). - **The seam.** A change to the kernel problem, the stage bundles or the protected driver - surface bumps `KERNEL_SCHEMA_VERSION`, as `AGENTS.md` requires; the SEP says in which phase. + surface rewrites the frozen tables and adds a release-note fragment, as `AGENTS.md` + requires; the SEP says in which phase. - **Tests.** Unit, invariant, convergence and parity tests as [testing.md](../testing.md) sets out, against the analytic and independent references the SEP names; every tolerance justified in a comment. diff --git a/docs/dev/seps/template.md b/docs/dev/seps/template.md index 9568b404..4632b37d 100644 --- a/docs/dev/seps/template.md +++ b/docs/dev/seps/template.md @@ -72,8 +72,9 @@ routes, backends, scans and outputs, and what is refused.* plan. Cover as applicable:* - *the layer the work belongs to (physics, translation, backends; architecture.md §3);* -- *the effect on the kernel contract and whether KERNEL_SCHEMA_VERSION changes;* -- *each Tier 1 backend in turn — numpy_ref, then the native CPU build — and anything +- *the effect on the kernel contract and the frozen tables of + tests/backends/test_driver.py;* +- *each Tier 1 backend — numpy_ref and the native CPU build, in either order — and anything that makes one of them hard; GPU considerations for later; whether mlx (Tier 3) should take the feature too, and why (ADR-061);* - *the expected cost in time and memory, and how it scales;* @@ -93,7 +94,7 @@ worked example belongs in a gallery.* ### 9. Compatibility and impact -*Changed defaults, breaking changes, the output schema, KERNEL_SCHEMA_VERSION, the effect on +*Changed defaults, breaking changes, the output schema, the kernel contract, the effect on existing validated results, and any deliberate difference from Luna.jl to record on the differences page.* diff --git a/docs/guide/performance/building-native.md b/docs/guide/performance/building-native.md index 5da0482e..66efa7c5 100644 --- a/docs/guide/performance/building-native.md +++ b/docs/guide/performance/building-native.md @@ -148,8 +148,9 @@ install does not rebuild itself: run `uv sync --reinstall-package solprop-native` after a native change, with any build options in `CMAKE_ARGS` ([below](#passing-cmake-options)); a device build is made the same way. `uv run poe check-native` runs the native gate and `uv run poe check-all` both gates. A build stays -valid across solprop changes until solprop's kernel schema (`KERNEL_SCHEMA_VERSION`) -changes or `check_build()` names a member the installed solprop lacks. Running solprop's test lanes +valid across solprop changes until the sources under `native/` change, when `check_build()` +refuses it and names that command, or until `check_build()` names a member the installed +solprop lacks. Running solprop's test lanes against the build is in the developer notes on [testing](../../dev/testing.md#tiers-and-backends), and the native package's own tests in `native/README.md`. @@ -189,7 +190,7 @@ python -c "import solprop_native as sn; sn.check_build(); print(sn.build_info(). object suitable for a run log: - **provenance:** `package_version`, `git_commit` (with `+dirty` for a modified tree), - `schema_version` (the kernel-contract version the build implements), + `source_digest` (the SHA-256 of the C++ sources and `CMakeLists.txt` it was compiled from), `solprop_version_built_against`; - **dependencies:** `kokkos_version`, `kokkos_kernels_version`, `kokkos_fft_version`, `nanobind_version`; @@ -217,10 +218,10 @@ counts; selection also accounts for the actual tail width. `native_arch_enabled` reports the package's option only; architecture flags passed by hand through `CXXFLAGS` appear in `compiler_flags`. -`check_build(problem=None)` raises `solprop.exceptions.BackendError` when the installed -solprop lacks a driver member or bundle field the build relies on (it names them); -when `problem` declares a different kernel-contract schema; when the build enables -fast-math; when the execution-space record is inconsistent; when a GPU build sees no +`check_build()` raises `solprop.exceptions.BackendError` when an editable checkout's +sources under `native/` are not the ones the extension was compiled from (it names +`uv sync`; a wheel skips this); when the installed solprop lacks a driver member or bundle +field the build relies on (it names them); when the build enables fast-math; when the execution-space record is inconsistent; when a GPU build sees no device; when the GEMM provider is unknown or its BLAS is missing or unidentified; when a reference build carries a vendor BLAS or machine-specific code, or a distribution build carries native code or no BLAS; and when a GPU build's BLAS is not the device's. diff --git a/docs/guide/performance/native.md b/docs/guide/performance/native.md index 9e029f98..c3ee28c3 100644 --- a/docs/guide/performance/native.md +++ b/docs/guide/performance/native.md @@ -98,9 +98,10 @@ python -c "import solprop_native as sn; sn.check_build()" ``` `check_build()` raises if the build is inconsistent (for example a GPU build that sees -no device) or does not fit the installed solprop: the two packages share a versioned -kernel contract, and `check_build()` names any solprop member the build relies on that -is missing. Install the two at the same version, from one release or one checkout. The build +no device) or does not fit the installed solprop: the two packages share a kernel +contract, and `check_build()` names any solprop member the build relies on that is +missing. In a checkout it also refuses an extension built from other sources than +`native/`. Install the two at the same version, from one release or one checkout. The build record reports the profile (`build_profile`), the BLAS (`blas_selection`, `blas_library`), FFTW, the compiler flags, the thread counts and, on a GPU build, the device. Then, in Python: @@ -130,8 +131,8 @@ one ([contributing](../../dev/contributing.md)). The editable install does not r itself: run `uv sync --reinstall-package solprop-native` after a native change. `uv run poe check-native` runs the native gate and `uv run poe check-all` both gates. A build stays valid across solprop changes until -solprop's kernel schema (`KERNEL_SCHEMA_VERSION`) changes or `check_build()` names a -member the installed solprop lacks. Running solprop's test lanes against the build is described in +the sources under `native/` change, when `check_build()` refuses it and names that +command, or until `check_build()` names a member the installed solprop lacks. Running solprop's test lanes against the build is described in the developer notes on [testing](../../dev/testing.md#tiers-and-backends). ## Running on a GPU @@ -216,10 +217,12 @@ large run, for a scan of many runs and on a cluster. ## When something goes wrong -- **`ImportError`, a schema mismatch, or `check_build()` naming a missing member.** The +- **`ImportError`, or `check_build()` naming a missing member.** The installed solprop-native does not fit the installed solprop; install both at the same version (`pip install --force-reinstall solprop`, or both from one checkout as in [Building the native backend](building-native.md)). +- **`check_build()` saying the extension was built from other sources.** A checkout's + `native/` changed after the build: run `uv sync --reinstall-package solprop-native`. - **`backend="auto"` refuses.** The message says why: the native package is missing, the processor is below the build's baseline, or its features could not be read ([installation](../../introduction/installation.md)). A contributor's lightweight setup has diff --git a/native/AGENTS.md b/native/AGENTS.md index 63a2743b..64ef34a9 100644 --- a/native/AGENTS.md +++ b/native/AGENTS.md @@ -31,10 +31,10 @@ contract and its conventions; where both apply, follow the stricter rule. -- with every parameter handed over by solprop and solprop's implementation as the reference, and it never chooses or defaults a parameter itself. - solprop imports this package only lazily, through its backend registry. The wheel - does not depend on solprop at run time; the contract is solprop's kernel-contract - schema plus the members named by the tuples above, which `check_build()` checks at - import, so mismatched halves (a stale extension beside newer sources) are refused by - name. + does not depend on solprop at run time; the contract is solprop's kernel contract and + the members named by the tuples above, which `check_build()` looks up. In an editable + checkout it also compares the extension with the sources by digest, so mismatched + halves (a stale extension beside newer sources) are refused, naming `uv sync`. - Kokkos runtime ownership is process-wide and locked. Initialise at most once, destroy plans and views before an owned finalisation, and never call Python during native teardown. @@ -149,9 +149,11 @@ the gate. ## Python and nanobind seam -- The contract with solprop is its kernel-contract schema plus the `StepDriver` - members and bundle fields listed in the contract tuples. `check_build()` refuses a - schema mismatch or a missing member by name and records solprop's version without +- The contract with solprop is its kernel contract plus the `StepDriver` members and + bundle fields listed in the contract tuples. `check_build()` refuses a missing member + by name and, in an editable checkout, an extension built from other sources than + `native/` (`build_info().source_digest`; `cmake/SourceDigest.cmake` and + `_source_digest` compute it, and stay in step). It records solprop's version without comparing it. Add a name to the tuples whenever the adapter starts reading another member; never pin solprop's version. - Accept the documented NumPy dtype, rank, shape and C-contiguity exactly and refuse diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index be68d3a4..f4e46eb1 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -220,9 +220,9 @@ if(SOLPROP_NATIVE_TREE_DIRTY) endif() # solprop and solprop-native are released together from one repository at one # version, so the solprop version this build belongs to is its own. The record is -# provenance: check_build() compares the kernel-contract schema and looks the driver -# members it relies on up by name, which still catches mismatched halves (an editable -# checkout beside a stale extension, a local device build). +# provenance: check_build() compares an editable checkout's sources by digest and looks +# the driver members it relies on up by name, which catches mismatched halves (an +# editable checkout beside a stale extension, a local device build). if(DEFINED SKBUILD_PROJECT_VERSION_FULL) set(SOLPROP_NATIVE_SOLPROP_BUILT_AGAINST "${SKBUILD_PROJECT_VERSION_FULL}") else() @@ -904,8 +904,26 @@ solprop_native_link_vector_math(solprop_native_core) solprop_native_mark_dependency_headers_system(solprop_native_core) solprop_native_configure_project_target(solprop_native_core) +# What the extension is compiled from: this file and the sources the digest's patterns +# match (cmake/SourceDigest.cmake says why a custom command and not a configure step). +include(cmake/SourceDigest.cmake) +list(TRANSFORM SOLPROP_NATIVE_DIGEST_GLOBS PREPEND "${PROJECT_SOURCE_DIR}/" + OUTPUT_VARIABLE _source_digest_globs) +file(GLOB_RECURSE _source_digest_inputs CONFIGURE_DEPENDS ${_source_digest_globs}) +set(SOLPROP_NATIVE_SOURCE_DIGEST_CPP "${CMAKE_CURRENT_BINARY_DIR}/source_digest.cpp") +add_custom_command( + OUTPUT "${SOLPROP_NATIVE_SOURCE_DIGEST_CPP}" + COMMAND "${CMAKE_COMMAND}" "-DSOURCE_ROOT=${PROJECT_SOURCE_DIR}" + "-DOUTPUT=${SOLPROP_NATIVE_SOURCE_DIGEST_CPP}" + -P "${PROJECT_SOURCE_DIR}/cmake/SourceDigest.cmake" + DEPENDS "${PROJECT_SOURCE_DIR}/CMakeLists.txt" + "${PROJECT_SOURCE_DIR}/cmake/SourceDigest.cmake" ${_source_digest_inputs} + COMMENT "Hashing the native sources" + VERBATIM +) + nanobind_add_module(_core STABLE_ABI NOMINSIZE NB_SUPPRESS_WARNINGS src/bindings.cpp - src/helpers.cpp) + src/helpers.cpp "${SOLPROP_NATIVE_SOURCE_DIGEST_CPP}") target_link_libraries(_core PRIVATE solprop_native_core ${CMAKE_DL_LIBS}) solprop_native_mark_dependency_headers_system(_core) solprop_native_configure_project_target(_core) diff --git a/native/README.md b/native/README.md index c7d793ba..b8393425 100644 --- a/native/README.md +++ b/native/README.md @@ -107,12 +107,14 @@ with `SOLPROP_TEST_BACKEND=native`). ### The kernel contract -This source implements solprop's kernel-contract schema 34. A build pins no solprop -version: it requires the schema version (`build_info().schema_version`) and the solprop -members listed in the exported tuples `DRIVER_SURFACE`, `STAGE_TERMS_FIELDS`, -`STAGE_SCALARS_FIELDS` and the other `*_FIELDS` tuples, which `check_build()` checks at -import, naming any that are missing. Rebuild when solprop's schema version changes or -`check_build()` names a missing member. The version recorded at configure time +This source implements solprop's kernel contract. A build pins no solprop version: it +requires the solprop members listed in the exported tuples `DRIVER_SURFACE`, +`STAGE_TERMS_FIELDS`, `STAGE_SCALARS_FIELDS` and the other `*_FIELDS` tuples, which +`check_build()` looks up, naming any that are missing. In an editable checkout it also +compares `build_info().source_digest`, the digest of what the extension was compiled from, +with this directory's sources. Rebuild with `uv sync --reinstall-package solprop-native` +when it refuses for either reason; a wheel carries no sources, and solprop's metadata pins it +to solprop's own version. The version recorded at configure time (`solprop_version_built_against`: the repository's version, or `unknown` for a direct CMake configure) is provenance only. The TDSE electronic response is refused by name before anything is allocated; TDSE propagation uses `backend="numpy_ref"`. diff --git a/native/cmake/SourceDigest.cmake b/native/cmake/SourceDigest.cmake new file mode 100644 index 00000000..9440f25c --- /dev/null +++ b/native/cmake/SourceDigest.cmake @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# The digest of what the extension is compiled from, which `check_build()` compares with +# an editable checkout's sources (`solprop_native._source_digest` computes it in Python: +# keep the two in step). The record is one "{path}:{sha256 of its bytes}\n" for +# CMakeLists.txt and each file these patterns match, the paths relative to native/ and +# sorted bytewise; the digest is the SHA-256 of the record. +# +# CMakeLists.txt includes this file for the patterns. A custom command runs it as a script +# to write the translation unit that holds the digest, so an edit recompiles that one file +# and neither reconfigures nor touches a heavy one: +# +# cmake -DSOURCE_ROOT= -DOUTPUT= -P SourceDigest.cmake +set(SOLPROP_NATIVE_DIGEST_GLOBS + src/*.cpp src/*.hpp src/*.h include/*.cpp include/*.hpp include/*.h) +if(NOT CMAKE_SCRIPT_MODE_FILE) + return() +endif() +cmake_policy(VERSION 3.23...4.4) + +list(TRANSFORM SOLPROP_NATIVE_DIGEST_GLOBS PREPEND "${SOURCE_ROOT}/" OUTPUT_VARIABLE globs) +file(GLOB_RECURSE sources RELATIVE "${SOURCE_ROOT}" ${globs}) +list(APPEND sources CMakeLists.txt) +list(SORT sources COMPARE STRING CASE SENSITIVE) +set(record "") +foreach(source IN LISTS sources) + file(SHA256 "${SOURCE_ROOT}/${source}" hash) + string(APPEND record "${source}:${hash}\n") +endforeach() +string(SHA256 digest "${record}") +# Written only when it changes, so a touched file recompiles nothing. +file(CONFIGURE OUTPUT "${OUTPUT}" @ONLY CONTENT +"// Generated by cmake/SourceDigest.cmake: the digest of what the extension is compiled from. +namespace solprop_native { +const char* source_digest() noexcept { return \"@digest@\"; } +} // namespace solprop_native +") diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index 131c843f..c1e26afd 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -56,6 +56,11 @@ namespace nb = nanobind; using namespace nb::literals; +namespace solprop_native { +// The digest of what this extension is compiled from (cmake/SourceDigest.cmake). +const char* source_digest() noexcept; +} // namespace solprop_native + namespace { namespace native = solprop_native; @@ -1832,7 +1837,7 @@ nb::dict build_record() { nb::dict result; result["backend_commit"] = SOLPROP_NATIVE_BACKEND_COMMIT; result["solprop_built_against"] = SOLPROP_NATIVE_SOLPROP_BUILT_AGAINST; - result["schema_version"] = 45; + result["source_digest"] = native::source_digest(); result["kokkos_version"] = SOLPROP_NATIVE_KOKKOS_VERSION; result["kokkos_fft_version"] = SOLPROP_NATIVE_KOKKOSFFT_VERSION; result["kokkos_kernels_version"] = SOLPROP_NATIVE_KOKKOSKERNELS_VERSION; diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py index 42f06f4a..d8ee0632 100644 --- a/native/src/solprop_native/__init__.py +++ b/native/src/solprop_native/__init__.py @@ -13,6 +13,7 @@ from __future__ import annotations import functools +import hashlib import importlib.util import logging import os @@ -495,12 +496,15 @@ class BuildInfo: Installed ``solprop-native`` distribution version. git_commit Backend repository commit embedded when CMake configured the extension. - schema_version - Supported solprop kernel-contract schema. + source_digest + SHA-256 of what the extension was compiled from: ``CMakeLists.txt`` and the C++ + sources and headers under ``src`` and ``include`` + (``cmake/SourceDigest.cmake``). :func:`check_build` compares it with an editable + checkout's sources. solprop_version_built_against The solprop version beside which CMake configured the extension, or - ``unknown``. Informational: the contract is ``schema_version`` and the seam - members :func:`check_build` looks for, not a version string. + ``unknown``. Informational: the contract is the seam members + :func:`check_build` looks for, not a version string. kokkos_version, kokkos_fft_version, kokkos_kernels_version, nanobind_version Native dependency versions. build_profile @@ -605,7 +609,7 @@ class BuildInfo: package_version: str git_commit: str - schema_version: int + source_digest: str solprop_version_built_against: str kokkos_version: str kokkos_fft_version: str @@ -711,7 +715,7 @@ def build_info() -> BuildInfo: return BuildInfo( package_version=__version__, git_commit=str(record["backend_commit"]), - schema_version=int(record["schema_version"]), + source_digest=str(record["source_digest"]), solprop_version_built_against=str(record["solprop_built_against"]), kokkos_version=str(record["kokkos_version"]), kokkos_fft_version=str(record["kokkos_fft_version"]), @@ -791,32 +795,83 @@ def build_info() -> BuildInfo: ) -def check_build(problem: object | None = None) -> None: - """Validate this build and, optionally, a solprop kernel problem. +def _source_digest(root: Path) -> str: + """Return the digest of the sources an extension is compiled from. + + The computation of ``cmake/SourceDigest.cmake``, which embeds it in the build; the + two must agree. The record is one ``{path}:{sha256 of its bytes}`` line for + ``CMakeLists.txt`` and each C++ source and header under ``src`` and ``include``, the + paths relative to `root` and sorted bytewise, and the digest is the SHA-256 of the + record. Parameters ---------- - problem - A :class:`solprop.translate.problem.KernelProblem`, or ``None`` to check only - the installed extension. + root + The ``native`` directory of a checkout. + + Returns + ------- + str + The digest in lowercase hexadecimal. + """ + paths = ["CMakeLists.txt"] + for folder in ("src", "include"): + paths.extend( + path.relative_to(root).as_posix() + for path in (root / folder).rglob("*") + digest = _checkout_digest() + if digest is not None and digest != info.source_digest: + raise BackendError( + "the solprop-native extension was built from other sources than this " + f"checkout's native/ ({Path(__file__).parents[2]}): rebuild it with " + "`uv sync --reinstall-package solprop-native`" + ) + if path.suffix in {".cpp", ".hpp", ".h"} and path.is_file() + ) + # Code points sort as their UTF-8 bytes do, so this is CMake's bytewise order. + record = "".join( + f"{path}:{hashlib.sha256((root / path).read_bytes()).hexdigest()}\n" + for path in sorted(paths) + ) + return hashlib.sha256(record.encode()).hexdigest() + + +@functools.cache +def _checkout_digest() -> str | None: + """Return this editable checkout's source digest, computed once per process. + + Returns + ------- + str or None + The digest, or ``None`` for an installed wheel, which has no sources beside it + and which solprop's metadata pins to its own version. + """ + root = Path(__file__).parents[2] + return _source_digest(root) if (root / "CMakeLists.txt").is_file() else None + + +def check_build() -> None: + """Validate this build against this checkout's sources and the installed solprop. Raises ------ solprop.exceptions.BackendError - If the installed solprop lacks a seam member this backend relies on + If an editable checkout's ``native`` sources are not the ones the extension + was compiled from, if the installed solprop lacks a seam member this backend + relies on (:data:`DRIVER_SURFACE`, :data:`ADAMS_MARCH_SURFACE`, :data:`MARCH_POLICY_FIELDS`, :data:`STAGE_TERMS_FIELDS`, :data:`STAGE_SCALARS_FIELDS`, :data:`SEPARABLE_OPERATOR_FIELDS`, :data:`LINEAR_OPERATOR_FIELDS`, :data:`TRANSFORM_FIELDS`, :data:`RESPONSE_FIELDS`, or the leaves' members in :data:`MODAL_TABLES_FIELDS`, :data:`REGIONAL_RESPONSE_FIELDS`, :data:`COUPLED_FLOW_FIELDS` and - :data:`DISPERSIVE_KERR_FIELDS`), the - problem's schema differs, + :data:`DISPERSIVE_KERR_FIELDS`), execution-space metadata is inconsistent, a reference build carries vendor BLAS or machine-specific code, a distribution build carries native code or no BLAS, fast-math is enabled, or an accelerator build sees no device. The - solprop *version* is recorded, never compared: the seam is versioned by the - schema, and solprop bumps it whenever that surface changes. + solprop *version* is recorded, never compared here: the two packages are + released together and pinned to one version, and the members are looked up by + name. """ from solprop.exceptions import BackendError @@ -874,13 +929,6 @@ def check_build(problem: object | None = None) -> None: raise BackendError( "the selected BLAS provider cannot access this accelerator's memory" ) - if problem is not None: - schema = getattr(problem, "schema_version", None) - if schema != info.schema_version: - raise BackendError( - "solprop-native implements kernel contract version " - f"{info.schema_version} and the problem declares version {schema}" - ) def _check_seam(info: BuildInfo, error: type[Exception]) -> None: diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 3cc8c184..68df9e6e 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -128,11 +128,7 @@ from solprop.translate.plasma_source import ( _BENCHMARK_GATED, # pyright: ignore[reportPrivateUsage] - refuse the private NumPy benchmark hook ) -from solprop.translate.problem import ( - KERNEL_SCHEMA_VERSION, - KernelProblem, - PropagatorMode, -) +from solprop.translate.problem import KernelProblem, PropagatorMode from solprop.translate.responses import CompiledResponses, ResponseStage from solprop.translate.stage import ( StageScalars, @@ -305,7 +301,7 @@ def __init__( ) -> None: if _BENCHMARK_GATED.get(): raise BackendError("the gated plasma benchmark requires numpy_ref") - check_build(problem) + check_build() _refuse_unsupported(problem) problem.validate() # solprop SEP 29 G1: a z table is evaluated natively on a CPU build; a device @@ -3241,11 +3237,6 @@ def _refuse_omitted_stages(responses: CompiledResponses) -> None: def _refuse_unsupported(problem: KernelProblem) -> None: - if problem.schema_version != KERNEL_SCHEMA_VERSION: - raise BackendError( - f"solprop-native implements kernel schema {KERNEL_SCHEMA_VERSION}; " - f"problem declares {problem.schema_version}" - ) _refuse_omitted_stages(problem.responses) if problem.precision.single: raise BackendError( diff --git a/native/tests/test_api_contract.py b/native/tests/test_api_contract.py index 76fdbcb7..a1c6893f 100644 --- a/native/tests/test_api_contract.py +++ b/native/tests/test_api_contract.py @@ -8,12 +8,15 @@ import dataclasses import gc +import hashlib import json import os +import shutil import subprocess import sys import threading import time +from collections.abc import Iterator from concurrent.futures import ThreadPoolExecutor from importlib.metadata import version from pathlib import Path @@ -23,6 +26,7 @@ import pytest from bounds import assert_within, step_bound from conftest import ComplexArray, PhaseACase +from solprop.exceptions import BackendError from solprop.stepper import FixedSchedule from thread_sizing import Sizing, assert_threaded @@ -88,7 +92,7 @@ def test_build_info_is_frozen_and_complete() -> None: assert dataclasses.is_dataclass(info) with pytest.raises(dataclasses.FrozenInstanceError): info.thread_count = 99 # type: ignore[misc] - assert info.schema_version == 45 + assert len(info.source_digest) == 64 assert info.response_full_thread_columns > 0 assert ( info.response_full_thread_columns @@ -234,6 +238,140 @@ def test_build_info_is_frozen_and_complete() -> None: } +def _digest_of(root: Path, names: list[str]) -> str: + """The digest's definition, spelled out with ``hashlib``.""" + record = "".join( + f"{name}:{hashlib.sha256((root / name).read_bytes()).hexdigest()}\n" + for name in names + ) + return hashlib.sha256(record.encode()).hexdigest() + + +@pytest.fixture +def source_tree(tmp_path: Path) -> tuple[Path, list[str]]: + """A small ``native`` tree, and the files the digest reads in the order it reads.""" + for name, text in { + "CMakeLists.txt": "project(p)\n", + "README.md": "not compiled", + "build/generated.cpp": "not a source: outside src and include", + "include/solprop_native/deep.h": "// h\n", + "include/solprop_native/kept.txt": "not a source", + "src/B.cpp": "// sorts before a lower-case name, bytewise\n", + "src/a.hpp": "// \u00e9 and CRLF\r\n", + "src/solprop_native/module.py": "not a source", + }.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(text.encode()) + return tmp_path, [ + "CMakeLists.txt", + "include/solprop_native/deep.h", + "src/B.cpp", + "src/a.hpp", + ] + + +def test_the_digest_is_the_sorted_record_of_the_compiled_sources( + source_tree: tuple[Path, list[str]], +) -> None: + root, names = source_tree + digest = native._source_digest(root) # pyright: ignore[reportPrivateUsage] + assert digest == _digest_of(root, names) + (root / "src/B.cpp").write_bytes(b"// one byte more\n ") + assert native._source_digest(root) != digest # pyright: ignore[reportPrivateUsage] + + +def test_the_build_script_and_python_agree_on_the_digest( + source_tree: tuple[Path, list[str]], tmp_path_factory: pytest.TempPathFactory +) -> None: + cmake = shutil.which("cmake") + if cmake is None: + pytest.skip("no CMake here to run the build's script") + root, names = source_tree + output = tmp_path_factory.mktemp("generated") / "source_digest.cpp" + script = Path(__file__).parents[1] / "cmake" / "SourceDigest.cmake" + subprocess.run( + [cmake, f"-DSOURCE_ROOT={root}", f"-DOUTPUT={output}", "-P", str(script)], + check=True, + capture_output=True, + ) + assert f'"{_digest_of(root, names)}"' in output.read_text(encoding="utf-8") + + +def _editable_root() -> Path: + """The checkout's ``native``, skipping a test that needs one under a wheel.""" + root = Path(native.__file__).parents[2] + if not (root / "CMakeLists.txt").is_file(): + pytest.skip("an installed wheel has no sources to compare") + return root + + +@pytest.fixture +def fresh_checkout_digest() -> Iterator[None]: + """Read the checkout's digest afresh, and leave no patched value in the cache.""" + native._checkout_digest.cache_clear() # pyright: ignore[reportPrivateUsage] + yield + native._checkout_digest.cache_clear() # pyright: ignore[reportPrivateUsage] + + +@pytest.mark.usefixtures("fresh_checkout_digest") +def test_an_editable_build_records_its_checkouts_digest() -> None: + root = _editable_root() + source_digest = native._source_digest # pyright: ignore[reportPrivateUsage] + assert native.build_info().source_digest == source_digest(root) + assert native.check_build() is None + + +@pytest.mark.usefixtures("fresh_checkout_digest") +def test_an_extension_built_from_other_sources_is_refused_naming_uv_sync( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _editable_root() + + def other(root: Path) -> str: + return f"not {root}'s" + + monkeypatch.setattr(native, "_source_digest", other) + with pytest.raises(BackendError, match=r"other sources.*`uv sync "): + native.check_build() + + +@pytest.mark.usefixtures("fresh_checkout_digest") +def test_the_digest_is_computed_once_per_process( + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = _editable_root() + calls: list[Path] = [] + source_digest = native._source_digest # pyright: ignore[reportPrivateUsage] + + def counted(where: Path) -> str: + calls.append(where) + return source_digest(where) + + monkeypatch.setattr(native, "_source_digest", counted) + native.check_build() + native.check_build() + assert calls == [root] + + +@pytest.mark.usefixtures("fresh_checkout_digest") +def test_a_wheel_layout_skips_the_comparison( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A wheel has no ``CMakeLists.txt`` two levels up: nothing is read or compared.""" + installed = tmp_path / "site-packages" / "solprop_native" / "__init__.py" + monkeypatch.setattr(native, "__file__", str(installed)) + + def refuse(root: Path) -> str: + raise AssertionError(f"a wheel has no sources to hash, asked for {root}") + + monkeypatch.setattr(native, "_source_digest", refuse) + info = dataclasses.replace(native.build_info(), source_digest="a wheel's own") + monkeypatch.setattr(native, "build_info", lambda: info) + assert native._checkout_digest() is None # pyright: ignore[reportPrivateUsage] + assert native.check_build() is None + + def test_module_entry_point_prints_one_json_record() -> None: completed = subprocess.run( [sys.executable, "-m", "solprop_native"], @@ -245,7 +383,7 @@ def test_module_entry_point_prints_one_json_record() -> None: assert completed.returncode == 0, completed.stderr lines = completed.stdout.splitlines() assert len(lines) == 1 - assert json.loads(lines[0])["schema_version"] == 45 + assert len(json.loads(lines[0])["source_digest"]) == 64 @pytest.mark.parametrize( diff --git a/native/tests/test_backend.py b/native/tests/test_backend.py index d3c5b63a..6955e782 100644 --- a/native/tests/test_backend.py +++ b/native/tests/test_backend.py @@ -47,10 +47,8 @@ ) -def test_check_build_accepts_the_compiled_contract(phase_a_case: PhaseACase) -> None: - problem, _ = phase_a_case +def test_check_build_accepts_the_compiled_contract() -> None: assert check_build() is None - assert check_build(problem) is None def test_rk_source_dispatch_counter_resets_and_excludes_fsal_reuse( @@ -220,7 +218,7 @@ def elsewhere(name: str) -> str: monkeypatch.setattr( solprop_native.backend, "_build_facts", functools.cache(facts.__wrapped__) ) - assert check_build(problem) is None + assert check_build() is None backend = NativeBackend(problem, grid=grid) try: record = backend.metadata() @@ -230,43 +228,6 @@ def elsewhere(name: str) -> str: assert record["solprop_version_built_against"] != "solprop-elsewhere" -@pytest.mark.parametrize( - "declared", - [ - 2, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 26, - 27, - 28, - 29, - 30, - 31, - 32, - 34, - 35, - 36, - 37, - 39, - 40, - 41, - ], -) -def test_schema_mismatch_names_both_versions( - phase_a_case: PhaseACase, declared: int -) -> None: - # The two independently qualified schema-18 branches are not the combined seam. - problem, _ = phase_a_case - spoiled = dataclasses.replace(problem, schema_version=declared) - with pytest.raises(BackendError, match=rf"version 45.*version {declared}"): - check_build(spoiled) - - @pytest.mark.parametrize("mode", ["tabulate", "recompute"]) def test_the_low_storage_march_steps_as_the_reference( phase_a_case: PhaseACase, mode: str @@ -781,7 +742,7 @@ def test_metadata_and_memory_ledger_are_self_consistent( n = problem.shape[-1] stages = problem.stepper.tableau.n_stages assert metadata["backend"] == "native" - assert metadata["schema_version"] == 45 + assert metadata["source_digest"] == build_info().source_digest assert metadata["openmp_wait_policy"] == solprop_native.WAIT_POLICY assert metadata["openmp_proc_bind"] == solprop_native.PROC_BIND # NumPy's own BLAS pool is held while the backend lives (solprop_native._host_blas). diff --git a/src/solprop/backends/numpy_ref/backend.py b/src/solprop/backends/numpy_ref/backend.py index 9dcd0c01..7e0b392d 100644 --- a/src/solprop/backends/numpy_ref/backend.py +++ b/src/solprop/backends/numpy_ref/backend.py @@ -107,11 +107,7 @@ from solprop.translate.linop import power_weight_at from solprop.translate.modal import DEFAULT_NODE_BUDGET from solprop.translate.plasma_source import _BENCHMARK_GATED -from solprop.translate.problem import ( - KERNEL_SCHEMA_VERSION, - MAX_N_POL, - KernelProblem, -) +from solprop.translate.problem import MAX_N_POL, KernelProblem from solprop.translate.spatial import separable_values from solprop.translate.stage import StageScalars, combine from solprop.translate.tdse import AtomicCallLedger, TDSEBinding, TDSEPurpose @@ -149,11 +145,10 @@ class NumpyRefBackend(StepDriver[Complex[np.ndarray, "n_batch n_modes n_pol n_om same pipeline and not a second code path. **Refusals.** A problem is refused by name, with - :class:`solprop.exceptions.BackendError`, when its ``schema_version`` is not the one - this backend knows, when its flags declare a spectral response, when a Raman kernel - meets two polarisation components, when the transform and the state would both carry - the transverse components, or when its batch axis is larger than one. A term - silently dropped would run, converge and be wrong. + :class:`solprop.exceptions.BackendError`, when its flags declare a spectral + response, when a Raman kernel meets two polarisation components, when the transform + and the state would both carry the transverse components, or when its batch axis is + larger than one. A term silently dropped would run, converge and be wrong. **Convention.** Every array held or returned is in the FFT convention of ``docs/dev/conventions.md`` section 2; the backend never conjugates. The @@ -2917,13 +2912,6 @@ def _refuse_unsupported(problem: KernelProblem) -> None: BackendError Naming what is unsupported. """ - if problem.schema_version != KERNEL_SCHEMA_VERSION: - msg = ( - "this backend implements kernel contract version " - f"{KERNEL_SCHEMA_VERSION} and the problem declares version " - f"{problem.schema_version}" - ) - raise BackendError(msg) refuse_general_leaves(problem, "numpy_ref", admits=_GENERAL_LEAVES) _refuse_adams(problem) # One list for every backend: the coupled flow first, then the diff --git a/src/solprop/driver/batch.py b/src/solprop/driver/batch.py index 21fc3cd9..9cce1257 100644 --- a/src/solprop/driver/batch.py +++ b/src/solprop/driver/batch.py @@ -82,7 +82,6 @@ def _signature(member: BatchMember) -> str: transform = problem.transform descriptor = { "backend": backend, - "schema": problem.schema_version, "grid_kind": problem.grid_kind, "shape": problem.shape, "flags": problem.flags, diff --git a/src/solprop/translate/__init__.py b/src/solprop/translate/__init__.py index 27e374ed..4d174c7a 100644 --- a/src/solprop/translate/__init__.py +++ b/src/solprop/translate/__init__.py @@ -49,7 +49,6 @@ multimode_transform, ) from solprop.translate.problem import ( - KERNEL_SCHEMA_VERSION, Flags, Initial, KernelProblem, @@ -94,7 +93,6 @@ "DEFAULT_NODE_BUDGET", "DEFAULT_SPATIAL_NODE_BUDGET", "DEFAULT_TOL", - "KERNEL_SCHEMA_VERSION", "MAX_DECAY_EXPONENT", "AbsorberScheme", "CompiledResponses", diff --git a/src/solprop/translate/problem.py b/src/solprop/translate/problem.py index ae0cfb2c..ca25bf22 100644 --- a/src/solprop/translate/problem.py +++ b/src/solprop/translate/problem.py @@ -184,7 +184,6 @@ "DEFAULT_FLOW_CONDITION_LIMIT", "DEFAULT_STRETCH", "EXPONENT_BUDGET", - "KERNEL_SCHEMA_VERSION", "LOG_DBL_MAX", "LOG_FLT_MAX", "MAX_GENERAL_FLOW_EXPONENT", @@ -207,315 +206,6 @@ logger = logging.getLogger(__name__) -KERNEL_SCHEMA_VERSION = 45 -"""Version of the kernel contract this module implements. - -Incremented on every change to the field list of ``docs/dev/kernel-contract.md``. A -backend refuses a problem whose version it does not know. Version 2 replaced the -``prefactor`` array with a table along z and added ``prefactor_constant`` beside it, so -that the effective index's z profile is carried rather than frozen at the entrance. -Version 3 replaced the z tables of the operator and the prefactor with expansions in -the scalar parameters they depend on, the z table staying behind the same evaluation -functions, moved the harmonic carrier into the grids as ``carrier_2w0``, and gathered -the Raman rows into ``raman_coefficients``. - -Version 4 adds spatial geometry, transverse integration and absorber data, and the -constant separable spatial operator/prefactor in the problem and stage bundles. - -Version 5 adds required material and transverse square residuals to the separable -record, resolving Cartesian cutoff arithmetic from unsquared binary inputs. - -Version 6 adds mixture channels and their stage bundles. Version 7 adds the three -periodic statistics storage hooks to StepDriver, allowing device rows to be copied -once per plane. Problem fields, stage bundles and numerical conventions are unchanged. -Version 8 adds the batch protocol, independent member observations and metadata, and -the backend free-device-memory query for admission of fixed-schedule fibre batches. - -The number also covers the seam a compiled backend subclasses and -packs from: the protected surface of :class:`solprop.backends.driver.StepDriver` a -subclass overrides or reads, and the fields of -:class:`solprop.translate.stage.StageTerms` and -:class:`solprop.translate.stage.StageScalars`. ``tests/backends/test_driver.py`` -holds that surface as a literal, so a change to it fails there until this number is -bumped and the literal rewritten in the same commit. A separately built backend -refuses a problem of another version and looks the members it relies on up by name; it -never compares solprop's own version. - -Version 9 adds envelope plasma cycle data, labelled channels and explicit spectral -assembly, and retains rate conventions and species bindings in rate tables. Field -plasma channels and stage scalar field names remain unchanged; the population -scalar tuples cover whichever plasma representation is active. - -Version 10 adds the explicit numeric Raman tensor weights and the -per-component capability declaration; scalar Raman retains its operation order. -Version 11 adds the modal power-weight row (see ``docs/dev/decisions.md``) in its three -representations and the endpoint block of a cumulative parameter expansion; the -driver surface and the stage bundles are unchanged and every backend consumes the -weight in its statistics pass alone. - -Version 12 adds the four optional leaves of the general -modal route -- ``linear.coupled_flow``, ``linear.modal_metric``, -``transform.modal_tables`` and ``responses.regional_responses`` -- each ``None`` by -default, and admits ``int32`` across the seam for the region and support indices they -carry. A problem with all four ``None`` is the version-11 problem, bit for bit, and -allocates no new buffer. The driver surface and the stage bundles are unchanged. - -Version 13 adds the two coupled-flow rows of the stage -bundle -- ``flow_section``, which section of ``linear.coupled_flow`` the step's anchor -lies in, and ``flow_weights``, the weights its ``(n_intervals, n_deg)`` coefficient -block is combined over at the stage's position -- so that a varying guide's frozen -generator and explicit residual are evaluated from host scalars rather than from a -Clenshaw pass over a leaf. Nothing else moves: no ``LinearOperator`` row, no -``StepDriver`` signature, no dtype. A problem carrying no coupled flow reports -``flow_section = -1`` and ``flow_weights = None`` and is the version-12 problem bit for -bit. ``numpy_ref`` and ``native`` execute both ``"frozen_residual"`` and -``"constant_schur"`` sections. The ``"cf4"`` sections move nothing either: -the Gauss nodes of a CF4 step are not stage positions, and ``numpy_ref`` evaluates the -leaf's own expansion at them from ``z_edges``, the degree axis and the step's -``flow_section``, which the leaf and the bundle already carry. - -Version 14 adds one row of the stage bundle, -``transform_weights``: the weights a **moving** guide's ``transform.modal_tables`` -synthesis and projection maps are combined over at the stage's position, their -``(n_intervals, n_deg)`` coefficient axes flattened, separate from ``flow_weights`` -so that the transform's sectioning is its own. A problem whose maps do not move -reports ``None`` and is the version-13 problem bit for bit; nothing else moves. - -Version 15 adds the hybrid planar geometry: the -``SpatialGeometry.kind`` ``"slab"`` (slab modes in x on the transform's own -synthesis and projection, an FFT in y, columns ``(p, k_y)`` with the mode index -fastest) and one appended ``SeparableOperator`` field, ``kappa_sq``, each channel's -complex, frequency-dependent wall term subtracted from the column's longitudinal -square. It is ``None`` on every other kind, whose problems are the version-14 -problems bit for bit. No ``KernelProblem`` field, stage bundle or ``StepDriver`` -signature moves. ``numpy_ref`` and native CPU execute the kind. - -Version 16 appends ``StepperSettings.integrator``: SEP 13's low-call Adams method -stepping the problem instead of the tableau, or ``None`` for the Runge-Kutta march, -whose problems are the version-15 problems bit for bit. Only ``numpy_ref`` executed an -integrator at this version; the driver's settings refused it before translation on any -other backend. No ``StepDriver`` signature moves: -the Adams march is the reference backend's own ``_march_to``. - -Version 17 appends ``StepperSettings.interval_cap`` and ``auto_init`` (SEP 14 phase -1): whether the march caps a step at the width of the operator-table interval it -starts in, and whether the start-up rule takes the step after the first from that -step's own error estimate. ``assemble`` resolves both from the route: off and on on -the diagonal-flow routes, on and off for a coupled flow or a reject-free controller. A -problem with the cap on and the rule off is the version-16 problem bit for bit. Every -backend inherits the shared march, so none moves; Kokkos follows the version only. - -Version 18 appends ``StepperSettings.interpolate_planes`` (SEP 14 phase 3): ``True`` -observes a save plane inside an accepted step -- through DOPRI5's continuous extension, -or the Adams march's own polynomial -- instead of landing on it; ``False`` (the -version-17 problem bit for bit) lands. It replaces SEP 13's ``Multistep.dense``. The -shared march does it with one more ``StepDriver`` hook, ``_dense_stages``, the accepted -step's stage derivatives on the host. It also appends ``StepperSettings.step_planes``: -a positive ``k`` stops ``propagate_to`` short of its target at the end of every -``k``-th accepted step, so the driver saves the march's own steps as planes; ``0`` -(the version-17 march) never stops short. - -Version 20 (SEP 16 phase 2) gives ``StepperSettings.integrator`` a ``formulation`` -field, ``"interaction"`` (the version-19 method bit for bit) or ``"exponential"``, and -adds one protected ``StepDriver`` hook, ``_adams_lab_source``: one source evaluation -returned in the lab frame, untransported, which the exponential formulation integrates -against the linear flow. A backend without the hook refuses the exponential -formulation by name; no ``KernelProblem`` field or stage bundle moves. - -Version 21 (SEP 16 phase 3b) adds the protected hook ``_adams_engine``: a backend may -return an engine that keeps the Adams history and every state-sized operation of the -fused and exponential paths where its state lives -(``solprop.backends.multistep.AdamsEngine``); ``None``, the default, keeps the host -engine over the other Adams hooks, the version-20 arithmetic bit for bit. No -``KernelProblem`` field or stage bundle moves. - -Version 22 (SEP 16 phase 5) adds the protected hook ``_adams_block``: a backend that -holds a coupled flow returns the frozen block of the section a step is anchored in, -whose matrix moments the exponential formulation's trial takes. ``None``, the default, -is a problem without one. No ``KernelProblem`` field or stage bundle moves. -Version 23 adds ``grids.norm_mask``, the bins the error norm scores: the controller's -``region``, by default every active bin rather than the requested band alone, which -``grids.inband_mask`` still carries. - -Version 24 (SEP 6 phase 1) appends ``responses.dispersive_kerr``, the -frequency-dependent electronic response's leaf -(``translate.responses.DispersiveKerrTerms``): its evaluator and sector, the fine-grid -multipliers of each population's channel and their density tables, beside the -``dispersive_kerr`` stage of ``response_order``. It appends one row to the stage -bundle, ``StageScalars.dispersive_kerr_densities``, each channel's number -density at the stage. ``grids.carrier_2w0`` is also present for the dispersive full -cubic on an envelope grid. A problem without the leaf is the version-23 problem bit for -bit, its new bundle row empty. On the general modal route the leaf's ``regions`` names -each channel's node region, a gas region's species. ``numpy_ref`` executes the leaf on -every route; the native backend refuses it by name. - -Version 25 (SEP 18 phase 1) appends ``responses.plasma_integral``, the running-integral -rule of the field-grid plasma column -(:class:`solprop.nonlinear.ionisation.RunningIntegral`): one per problem, because the -channels share the two current integrals. The ``"trapezoid"`` rule is the version-24 -column bit for bit. The ``"band"`` rule filters each of the three integrands with the -grid's symmetric taps before the trapezoid, closes the window's ends by point -reflection and floors the exposure at zero, so ``plasma_scan`` and ``plasma_current`` -read ``K`` samples ahead of the one they finalise. ``numpy_ref`` and native CPU (SEP -18 phase 3) execute both rules; a native device build refuses -``"band"`` by name. No stage bundle or ``StepDriver`` hook moves. - -Version 26 (SEP 19 phase 1) appends ``transform.kind``, what the transform represents, -stored instead of inferred from the node count: ``"mode_averaged"`` for the -effective-area route alone, ``"modal"`` for every resolved quadrature of any node count -and for the general modal tables, and the spatial geometry's kind otherwise. A one-node -quadrature is therefore an ordinary modal transform, with no area conversion in its -prefactor. Every existing problem keeps its arithmetic bit for bit. - -Version 27 (SEP 20 phase 1) widens what ``tdse`` may carry without changing any -field: a ``solprop.tdse-descriptor.v3`` descriptor names a haCC model and its pinned -setup record, and may carry an atomic window whose gate, read through -``TDSEDescriptor.source_gate()``, replaces ``grids.t_window_fine`` as the -``time_collar`` source gate. A version-26 consumer would misread such a descriptor, -so the version moves; version-1 and version-2 descriptors are unchanged bit for bit. - -Version 28 (SEP 25) moves no field: ``StepDriver._stage_bundles``, a trial's stage -bundles read together, joins the driver surface a separately built backend is built -against, so a version-27 consumer refuses the problem. - -Version 29 (SEP 24 phase 1) widens ``StepperSettings.integrator`` to -``Multistep | LowStorageRungeKutta | None``: the low-storage interaction-picture RK4 -march, which replays a schedule in three registers and records no estimate. It adds the -protected hook ``StepDriver._low_storage_engine`` and the driver attributes -``_low_storage`` and ``_in_flight`` to the surface, and ``_stage_bundles`` keeps the -bundles of every anchor it is asked for until the next hoist (the march reads three). -A backend that does not execute the march refuses it by name, because a widened field -would otherwise read as no integrator and run DOPRI5. Every problem without it keeps -its arithmetic bit for bit. - -Version 30 (SEP 26 phase 1) moves no field: ``tdse`` may carry a -``solprop.tdse-descriptor.v4`` descriptor, the ``gated_derivative`` source filter of an -SAE or haCC response, windowed or on the grid collar. Its source subtracts the gate's -edge term, ``F[g' S] / (i omega)``, using ``TDSEDescriptor.source_gate_derivative()``, -so a version-29 consumer would misread it; translation admits it only when its -steady-current certificate is at most 1e-10 (SEP 26's further fixed 128-sample taper -minimum was later dropped as a defect, with no field change). - -Version 31 (SEP 22 phase 2) appends ``SeparableOperator.kperp_rate``, a transverse -collar's power rate per column, which joins the field attenuation on the active band -(``D -= kperp_rate / 2`` there); zeros, the default, are the version-30 operator to -the last bit. ``StepDriver.observe`` gains the keyword ``field``: ``False`` observes -the step history and statistics' position without crossing the field or the noise -phase to the host, and :class:`solprop.backends.protocol.Observation` carries -``field=None``. ``StepDriver.held_observation`` returns the state a low-storage march -held at its last field-free plane. ``SeparableOperator.evanescent_rate`` may now be -set by the run (``SimulationSettings.evanescent_rate``). - -Version 32 (SEP 24 phase 3) moves no field of the problem: solprop-native executes the -low-storage march with a resident engine, so an older wheel, which refuses it, must be -refused in turn. :class:`solprop.backends.low_storage.LowStorageEngine` gains -``atomic``, whether its step is one native call no signal interrupts (the march then -takes the step, the finish and the move in one commit), and ``StepDriver`` gains -``_hold`` and ``_held_field`` on its surface, so a backend may hold the field-free -plane's state where its own state lives. - -Within version 32 the source filter was removed with descriptor versions 1 to 4: -every ``tdse`` descriptor is now ``solprop.tdse-descriptor.v5``, and a native -descriptor's source is always gated and edge-free (``TDSEDescriptor.gated``). Removing -values no consumer could misread needs no bump. - -Version 33 (SEP 27) adds ``grids.t_window_fine_derivative`` and changes the field -plasma column's outputs to the two products ``g J`` and ``g' J`` alongside collared -non-plasma polarisation. Route assembly combines their spectra with ``1/(i omega)`` -and ``-1/(i omega)**2`` only on positive active bins. - -Version 34 (SEP 29 phase 2) moves no field of the problem: ``StepDriver`` gains the -class attribute ``helpers``, the backend's implementation of the per-stage Chebyshev -rows and the Adams basis, which the shared driver, march and engines pass down -(``solprop._helpers``). An older native build does not set it, so it is refused. - -Version 35 (SEP 34) adds ``flux``, the spatial run's plane-measure coefficients -(:class:`solprop.translate.spatial.FluxPlan`): every spatial statistic is the axial -Poynting flux of the medium at its plane, weighted by the admittance -``c k_z / omega`` of that plane's root, with the complex admittance for local flux. -A radial gas path's cumulative operator gains the forward amplitude term -``-ln(Y_z(z)/Y_z(0))/2``. A problem without spatial geometry carries no plan. - -Version 36 (SEP 33) adds ``precision``, the arithmetic the run is held in -(:class:`solprop.translate.precision.Precision`). Every table stays binary64 at the -seam; a backend that admits single precision narrows its tables on upload, and every -other backend refuses a single-precision problem by name. - -Version 37 (SEP 35) gives ``StepperSettings.integrator`` a ``carrier_frame`` field, -``"off"`` (the version-36 method bit for bit) or ``"scalar"`` (a ``"harmonic"`` value -existed during SEP 35's development and was removed by its gate): the carrier phase the -exponential formulation removes from its history inside each step. A backend that does -not deliver the shift refuses it by name, and a build against an older version refuses -the problem rather than step a requested shift unshifted; no ``KernelProblem`` field or -stage bundle moves. - -Version 38 adds two members to the driver's protected surface: the boundary monitor's -reading moves into the backend. ``StepDriver._bind_monitor`` hands the backend a run's -monitor (or ``None``), and ``StepDriver._boundary_energies`` returns the monitor's five -numbers of a state and what a split-step collar took since the last reading -(:class:`solprop.monitor.BoundaryEnergies`), so a device backend reduces where its state -lives; no ``KernelProblem`` field or stage bundle moves. - -Version 39 (SEP 39) lets a backend run the exponential Adams march natively: the -march's policy is a record (:class:`solprop.stepper.multistep.MarchPolicy`), every -number its algorithm reads, and the driver's protected surface gains -``StepDriver._record_attempt`` and ``StepDriver._skip_step``, the bookkeeping of one -attempt and of an accepted step on which the driver has no work, beside the frozen -``_take_step_stats``, ``_natural_stop`` and ``_natural_stop_due``; no ``KernelProblem`` -field or stage bundle moves. - -Version 40 gives ``StepDriver.set_initial`` its ``resume`` keyword: a stored field never -takes the ``input`` draw again, at the launch position included, where a file holding -only its launch plane resumes. No ``KernelProblem`` field or stage bundle moves. - -Version 41 (SEP 40) resolves a free-space run's transverse boundary at translation. -``KernelProblem.transverse`` holds the design -(:class:`solprop.spatial_absorber.SpatialAbsorberDesign`), host-side: a backend meets -it only through fields it already reads. ``SeparableOperator.kperp_rate``, the safety -ramp, is admitted on the radial kind, where it rides the dense operator, and on the -hybrid planar kind per ``(p, k_y)`` column, which the slab element now takes as its -``transverse_rate``. The geometry's ``absorber_rate`` is the design's collar, zero -without one, and an adaptive run's ``dz_max`` is held to the collar's crossing limit. - -Version 42 lets ``initial.field0`` be strided: a broadcast input is held as a -zero-stride view of the block it repeats rather than as a full copy, and -``StepDriver.set_initial`` accepts a state of any strides, copying it contiguous. Every -other array stays C-contiguous; no ``KernelProblem`` field, stage bundle or -``StepDriver`` signature moves. - -Version 43 widens the boundary monitor's reading under the ``"split_step"`` scheme. -A backend's ``_boundary_energies`` returns, beside the removal, the energy and the -guards' and collar's energies of the power the last accepted step's split found, -halfway through its factor (:attr:`solprop.monitor.BoundaryEnergies.found`), kept by -its finish in ``StepDriver._split_found``; the monitor judges the occupancies on them -and refuses a split reading without them. ``KernelProblem.split_crossing`` -(:class:`solprop.boundaries.SplitCrossing`) carries each bin's walk-off bucket, a -``uint8`` leaf, and the launch's crossing limit, which holds an adaptive ``dz_max`` and -a fixed schedule; under the split every reading also returns the state's energy per -bucket (``BoundaryEnergies.occupancy``), whose limit caps the steps until the next -check: ``StepDriver._split_capped`` on the host marches, the native Adams march's -required ``step_cap`` bound per call. The native reading, after ``bind_occupancy`` -hands it the table, grows from six numbers to ten and the 128 buckets. No stage -bundle or existing driver signature moves. - -Version 44 adds ``StepDriver.set_held_plane``, which a driver calls at the start of a -run to say whether the low-storage march holds its last field-free plane's state, and -``StepDriver._drop_held``, which releases the buffer; the native backend overrides the -second with ``State.release_plane``. No ``KernelProblem`` field, stage bundle or -existing driver signature moves. - -Version 45 lets a far-field aperture set hold other outlines beside circles: optional -fields of the ``stats_spec.apertures`` leaf, unset on every existing request, so no -problem that does not use them changes. By the letter a leaf's fields are not rows of -the contract, but solprop-native's row format moves with them, and only a modulus -guards a row's width: its aperture tables grow from six constants to eleven and carry -each row's profile and shape, so a native call takes no profile argument, and a stale -wheel beside this tree must be refused rather than misread the rows. It also appends -``SeparableOperator.user_rate``, the user's share of ``spectral_rate``, zeros by -default, which only ``absorbed_power`` reads: the operator takes the total, to the last -bit. No stage bundle or driver signature moves. -""" - DEFAULT_COMPONENTS = ("x",) """Canonical component of an unrotated HE_1m mode. @@ -1236,8 +926,6 @@ class KernelProblem: Attributes ---------- - schema_version : int - The contract version, :data:`KERNEL_SCHEMA_VERSION`. grid_kind : {'field', 'envelope'} Selects r2c/c2r against c2c transforms and the response formulas. shape : tuple of int @@ -1295,11 +983,10 @@ class KernelProblem: ... controller=PIController(rtol=1e-8, atol=1e-14), save_planes=[1.0], ... dz_max=0.1, dz_init=1e-3, shock=False, prefactor_index=1.0, density=1.0, ... ) - >>> problem.shape, problem.grid_kind, problem.schema_version - ((1, 1, 1, 3072), 'envelope', 45) + >>> problem.shape, problem.grid_kind + ((1, 1, 1, 3072), 'envelope') """ - schema_version: int grid_kind: GridKind shape: tuple[int, int, int, int] flags: Flags @@ -1363,9 +1050,9 @@ def validate(self) -> None: """Check every promise the contract makes, raising on the first that fails. A backend calls this and refuses anything inconsistent - (``docs/dev/architecture.md`` section 7). What is checked: the schema version; - every array finite, read-only, of the stated dtype and shape, and C-contiguous - but for the initial state, which may be a broadcast view (:class:`Initial`); + (``docs/dev/architecture.md`` section 7). What is checked: every array finite, + read-only, of the stated dtype and shape, and C-contiguous but for the initial + state, which may be a broadcast view (:class:`Initial`); the masks Boolean; the breakpoints strictly increasing and covering the run including its margin; the hard kinks interval edges; every table along z spanning the same range and, for the prefactor, carrying the state's value @@ -1406,12 +1093,6 @@ def validate(self) -> None: >>> problem.validate() is None True """ - if self.schema_version != KERNEL_SCHEMA_VERSION: - msg = ( - f"schema_version {self.schema_version} is not the kernel schema " - f"{KERNEL_SCHEMA_VERSION} this translation layer writes" - ) - raise TranslationError(msg) if (self.tdse is None) != (self.current_numerator is None): raise TranslationError("TDSE descriptor and current numerator must coexist") self._check_integrator() @@ -6977,7 +6658,6 @@ def assemble( # noqa: PLR0913, PLR0915 - one keyword per physics object of a ru step_planes=step_planes, ) problem = KernelProblem( - schema_version=KERNEL_SCHEMA_VERSION, grid_kind=grid.kind, shape=(n_batch, n_modes, n_pol, n_omega), flags=Flags.from_content( @@ -7055,7 +6735,7 @@ def debug_summary(problem: KernelProblem) -> str: ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, ... ) >>> print(debug_summary(simulation.translate()).splitlines()[0]) - kernel problem schema 45: envelope grid, route modal + kernel problem: envelope grid, route modal """ linear, transform = problem.linear, problem.transform tables, metric = transform.modal_tables, linear.modal_metric @@ -7067,8 +6747,7 @@ def debug_summary(problem: KernelProblem) -> str: else "modal" ) rows = [ - f"kernel problem schema {problem.schema_version}: " - f"{problem.grid_kind} grid, route {route}", + f"kernel problem: {problem.grid_kind} grid, route {route}", f" shape: {problem.shape} (batch, modes, pol, omega)", f" flow: phi {linear.phi_representation}, " f"{linear.n_intervals} interval(s), {linear.nbytes / 2**20:.3f} MB", diff --git a/src/solprop/translate/responses.py b/src/solprop/translate/responses.py index 29d7577d..8504e532 100644 --- a/src/solprop/translate/responses.py +++ b/src/solprop/translate/responses.py @@ -179,8 +179,8 @@ The form is a **physical model** choice and so lives in ``model_id``, which is already a representation tag inside the resume identity. It is not a new -leaf field: adding one would move a contract row and bump the kernel schema, and there -is nothing else the form needs to carry. +leaf field: adding one would move a contract row, and there is nothing else the form needs +to carry. """ KERR_MODEL_FORMS: Final[Mapping[str, str]] = { diff --git a/tests/backends/conftest.py b/tests/backends/conftest.py index 65682cf9..ce78de01 100644 --- a/tests/backends/conftest.py +++ b/tests/backends/conftest.py @@ -291,7 +291,6 @@ def replace_groups(base, *, flags=None, validate=True, **groups): parts["initial"], ) problem = KernelProblem( - schema_version=base.schema_version, grid_kind=base.grid_kind, shape=base.shape, flags=derived, diff --git a/tests/backends/test_carrier_frame.py b/tests/backends/test_carrier_frame.py index b16f7861..c8da66f4 100644 --- a/tests/backends/test_carrier_frame.py +++ b/tests/backends/test_carrier_frame.py @@ -364,13 +364,6 @@ def test_a_compiled_cpu_backend_accepts_the_shift(backend_name): ).close() -def test_a_problem_of_the_older_schema_is_refused(): - simulation = capillary(SCALAR) - stale = dataclasses.replace(simulation.translate(), schema_version=35) - with pytest.raises(BackendError, match="version 35"): - NumpyRefBackend(stale, grid=simulation.grid, modes=simulation.modes) - - # --- the reference rule (section 6.1) ------------------------------------------------- diff --git a/tests/backends/test_driver.py b/tests/backends/test_driver.py index b08f4447..318e0807 100644 --- a/tests/backends/test_driver.py +++ b/tests/backends/test_driver.py @@ -35,7 +35,6 @@ from solprop.stepper.multistep import MarchPolicy from solprop.stepper.schedule import FixedSchedule from solprop.translate.linop import LinearOperator, delta_phi -from solprop.translate.problem import KERNEL_SCHEMA_VERSION from solprop.translate.spatial import SeparableOperator from solprop.translate.stage import StageScalars, StageTerms, scalars @@ -951,11 +950,11 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): } """Every ``StepDriver`` member a subclass overrides or calls, with its signature. -The literal is the seam a separately built backend is compiled against, and -``KERNEL_SCHEMA_VERSION`` is its version: change a signature here only together with a -bump of the version and a changelog line (``AGENTS.md``, "Backends, kernels and hot -paths"). ``solprop-native`` looks these names up at construction (its -``DRIVER_SURFACE``). +The literal is the seam a compiled backend is built against: change a signature here +only together with a release-note fragment (``AGENTS.md``, "Backends, kernels and hot +paths"). ``solprop-native`` looks the names it relies on up at construction (its +``DRIVER_SURFACE``, which :func:`test_the_native_surface_is_inside_the_frozen_one` +holds inside this one). """ DRIVER_ATTRIBUTES = ( @@ -1081,72 +1080,13 @@ def test_a_trials_bundles_taken_together_are_kept_until_the_next_hoist(driver): def test_the_surface_a_backend_subclasses_is_frozen(driver): - """The protected driver surface and stage bundles are ``KERNEL_SCHEMA_VERSION``'s. - - A separately built backend is compiled against these names and shapes, and its build - stays valid across solprop commits because this test, not a version pin, guards - them: a change here is a change of the kernel contract and bumps the version. - Version 6 added the four mixture bundles -- ``kerr_density``, ``plasma_densities``, - ``plasma_preionisation`` and ``raman_densities`` -- beside the total ``density`` and - per-term ``raman_rates`` of version 5, so a compiled backend packs one scalar per - plasma channel and per Raman term rather than one. Version 13 added ``flow_section`` - and ``flow_weights`` at the end, which is the whole of that bump: a varying coupled - flow's frozen section and the weights its residual is evaluated over are host - scalars, and no driver signature and no `LinearOperator` row moved with them. - Version 14 added ``transform_weights`` at the end, the weights a moving guide's - synthesis and projection maps are combined over, and nothing else. Version 15 moved - none of these: it added the ``"slab"`` spatial kind and appended ``kappa_sq`` to the - separable leaf, whose literal is :data:`SEPARABLE_OPERATOR_FIELDS`. Version 17 added - the five ``_adams_*`` hooks of the shared Adams march (SEP 14 phase 2) and version - 18 ``_dense_stages``, the accepted step's stages for DOPRI5's - continuous extension. Version 20 added ``_adams_lab_source``, the exponential - Adams formulation's untransported source (SEP 16), and version 21 ``_adams_engine``, - the Adams history's engine (SEP 16 phase 3b), and version 22 ``_adams_block``, a - coupled flow's frozen block (SEP 16 phase 5). Version 23 moved no hook. Version 24 - appended ``dispersive_kerr_densities``, each dispersive electronic channel's - number density at the stage (SEP 6 phase 1), and moved no hook. Version 25 moved - none of these: it appended the plasma column's running-integral rule (SEP 18). - Version 26 moved none either: it stored the transform's kind (SEP 19). Version 27 - moved none: it widened the TDSE descriptor to haCC and atomic windows (SEP 20). - Version 28 added ``_stage_bundles``, a trial's stage bundles read together (SEP 25). - Version 29 added ``_low_storage_engine`` and the attributes ``_low_storage`` and - ``_in_flight``, the low-storage march's engine hook, march and in-flight guard - (SEP 24 phase 1). Version 30 moved none: it widened the TDSE descriptor to the - version-4 ``gated_derivative`` source filter (SEP 26 phase 1). Version 31 gave - ``observe`` its ``field`` keyword and added ``held_observation`` (SEP 22 phase 2), - and appended ``kperp_rate`` to the separable leaf. Version 32 added ``_hold`` and - ``_held_field``, where a backend holds the low-storage march's field-free plane - and copies it to the host, and the engine protocol's ``atomic`` (SEP 24 phase 3). - Version 33 adds the analytic collar derivative to Grids (SEP 27), without - changing these driver or stage signatures. Version 34 adds the class attribute - ``helpers``, the backend's per-stage and per-step evaluations (SEP 29 phase 2). - Version 35 adds the problem's plane measure ``flux`` (SEP 34), without changing - these driver or stage signatures. - Version 36 adds the problem's ``precision`` (SEP 33), without changing these - driver or stage signatures. - Version 38 adds ``_bind_monitor`` and ``_boundary_energies``: the boundary - monitor's reading is the backend's. Version 39 adds ``_record_attempt`` and - ``_skip_step`` and freezes ``_take_step_stats``, ``_natural_stop`` and - ``_natural_stop_due``, which a native Adams march books its attempts through, and - the march's policy record (SEP 39). Version 40 gives ``set_initial`` its ``resume`` - keyword: a stored field never takes the ``input`` draw again. Version 41 adds the - problem's ``transverse`` design and the class attribute ``reads_transverse_edge``, - whether a backend reads a free-space run's transverse edge (SEP 40), without - changing these signatures. Version 42 lets the initial state be strided, which - ``set_initial`` accepts, without changing its signature. - Version 43 widens what ``_boundary_energies`` returns under the split scheme, - without changing its signature: the energies of what each step's split found - (``_split_found``) and the state's occupancy of the split collar's walk-off buckets - (``KernelProblem.split_crossing``); the native reading's ``bind_occupancy`` and its - ``10 + 128`` numbers, and the native march's required ``step_cap`` bound. It adds - ``_split_capped``, the cap on adaptive steps. Version 44 adds ``set_held_plane``, - with which a driver turns the low-storage march's held plane off for a run, and - ``_drop_held``, which releases its buffer. Version 45 appends ``user_rate`` to the - separable leaf, without changing these signatures. + """The protected driver surface and stage bundles are the seam's literal. + + A compiled backend is built against these names and shapes, and its build stays + valid across solprop commits because this test, not a version pin, guards them: a + change here is a change of the seam, which rewrites these literals and adds a + release-note fragment in the same commit. """ - assert KERNEL_SCHEMA_VERSION == 45, ( - "the seam changed: rewrite the literals of this test" - ) assert StepDriver.reads_transverse_edge is False for name, signature in { "observe_members": ( diff --git a/tests/backends/test_mixture_channels.py b/tests/backends/test_mixture_channels.py index bc9efb14..54386a50 100644 --- a/tests/backends/test_mixture_channels.py +++ b/tests/backends/test_mixture_channels.py @@ -29,7 +29,6 @@ def case(): def test_compiled_channels_share_labels_rates_and_linear_density(case): mixture, _, mode, _, problem = case - assert problem.schema_version == 45 assert problem.flags.n_channels == 2 assert problem.flags.n_raman == 0 assert problem.responses.labels == mixture.labels diff --git a/tests/backends/test_numpy_ref_backend.py b/tests/backends/test_numpy_ref_backend.py index 7bc21e17..260f9b4b 100644 --- a/tests/backends/test_numpy_ref_backend.py +++ b/tests/backends/test_numpy_ref_backend.py @@ -25,7 +25,6 @@ from solprop.stats import StatsSpec, collect from solprop.stepper import FixedSchedule, PIController from solprop.translate.linop import unitary_phase -from solprop.translate.problem import KERNEL_SCHEMA_VERSION from solprop.translate.responses import prefactor_at LENGTH = 0.01 # m @@ -153,29 +152,6 @@ def test_a_declared_raman_kernel_is_executed_rather_than_refused( backend.close() -@pytest.mark.parametrize("declared", [2, 3, KERNEL_SCHEMA_VERSION - 1, 99]) -def test_an_unknown_contract_version_is_refused_before_validation( - backend_class, soliton_problem, narrow_grid, declared -): - """The version is checked first: validate assumes the version it knows. - - The previous version is refused too: the check is an exact match, so a consumer of - the previous version equally refuses a problem of this one. - - Version 2 is refused by name and not merely unrecognised: its section 3 and section - 5 carry z tables where version 3 carries expansions, and its Raman rows are three - fields rather than a leaf, so a backend that read it would read the wrong things. - """ - problem = dataclasses.replace( - soliton_problem(narrow_grid, 1, save_planes=[LENGTH]), schema_version=declared - ) - expected = ( - f"version {KERNEL_SCHEMA_VERSION} and the problem declares version {declared}" - ) - with pytest.raises(BackendError, match=expected): - backend_class(problem) - - def test_a_transform_and_a_state_cannot_both_carry_the_components( backend_class, soliton_problem, narrow_grid, substitute ): diff --git a/tests/data/measure_polarised_rows.py b/tests/data/measure_polarised_rows.py index d82f67c0..cbae7cb7 100644 --- a/tests/data/measure_polarised_rows.py +++ b/tests/data/measure_polarised_rows.py @@ -47,7 +47,7 @@ from solprop.nonlinear.responses import KerrResponse from solprop.pulses import GaussianPulse from solprop.stepper import FixedSchedule, PIController -from solprop.translate.problem import KERNEL_SCHEMA_VERSION, assemble +from solprop.translate.problem import assemble LENGTH = 0.001 STEPS = 10 @@ -114,7 +114,6 @@ def main(): arguments.output.with_suffix(".json").write_text( json.dumps( { - "schema": KERNEL_SCHEMA_VERSION, "backend": arguments.backend, "solprop_source": solprop.__file__, "native_source": None if native is None else native.__file__, diff --git a/tests/data/measure_power_identity.py b/tests/data/measure_power_identity.py index c2d77c49..9f3fd0ec 100644 --- a/tests/data/measure_power_identity.py +++ b/tests/data/measure_power_identity.py @@ -2,12 +2,11 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""Record a ten-step M2 identity from either the schema-10 or schema-11 source. +"""Record a ten-step M2 identity. Run in separate processes with PYTHONPATH selecting each solprop source and the -matching Kokkos wheel, plus this checkout's tests directory for power_identity. -The baseline receives the independently pre-divided launch in its assembled -contract, retaining the same response tables. Compare the resulting NPZ arrays +matching wheel, plus this checkout's tests directory for power_identity. Compare the +resulting NPZ arrays with ``tests/bounds.py``'s ``assert_within`` at a rounding bound fixed from the ten fixed steps (``step_budget``); they are evidence, not a portable golden result. @@ -19,7 +18,6 @@ import argparse import json import warnings -from dataclasses import replace from pathlib import Path # Import the optional native package first, as a run would. @@ -37,7 +35,6 @@ from power_identity import constant_fill_m2 from solprop.backends.registry import select from solprop.exceptions import MaterialRangeWarning -from solprop.translate.problem import KERNEL_SCHEMA_VERSION def main(): @@ -49,23 +46,6 @@ def main(): warnings.simplefilter("ignore", MaterialRangeWarning) simulation = constant_fill_m2() problem = simulation.translate() - root_weight = np.ones(problem.shape) - for mode_index, mode in enumerate(simulation.modes): - active = simulation.grid.active_mask - root_weight[0, mode_index, 0, active] = np.sqrt( - np.real(mode.neff(simulation.grid.omega[active], 0.0)) - ) - assert KERNEL_SCHEMA_VERSION in (10, 11, 12, 13) - if KERNEL_SCHEMA_VERSION == 10: - assert problem.initial.noise0 is not None - problem = replace( - problem, - initial=replace( - problem.initial, - field0=problem.initial.field0 / root_weight, - noise0=problem.initial.noise0 / root_weight, - ), - ) owner = select( arguments.backend, problem, grid=simulation.grid, modes=simulation.modes ) @@ -91,7 +71,6 @@ def main(): arguments.output.with_suffix(".json").write_text( json.dumps( { - "schema": KERNEL_SCHEMA_VERSION, "backend": arguments.backend, "solprop_source": solprop.__file__, "native_source": None if native is None else native.__file__, diff --git a/tests/translate/test_dispersive_kerr_terms.py b/tests/translate/test_dispersive_kerr_terms.py index 5b0c9527..26aed2fe 100644 --- a/tests/translate/test_dispersive_kerr_terms.py +++ b/tests/translate/test_dispersive_kerr_terms.py @@ -29,7 +29,6 @@ from solprop.stepper import PIController from solprop.translate.modal import BUFFERS_PER_NODE from solprop.translate.problem import ( - KERNEL_SCHEMA_VERSION, _dispersive_chunk, assemble, dispersive_node_buffers, @@ -226,7 +225,6 @@ def test_the_problem_carries_the_leaf_and_its_stage_row(field_grid, mode): problem = problem_for( field_grid, mode, [DispersiveKerrResponse(ARGON, "polynomial")] ) - assert problem.schema_version == KERNEL_SCHEMA_VERSION == 45 assert problem.grids.carrier_2w0 is None bundle = scalars(problem, 0.0, 0.05) expected = float(mode.density(np.array([0.05]))[0]) diff --git a/tests/translate/test_problem.py b/tests/translate/test_problem.py index b9b97bcd..4eef5c61 100644 --- a/tests/translate/test_problem.py +++ b/tests/translate/test_problem.py @@ -38,7 +38,6 @@ from solprop.translate.modal import Transform from solprop.translate.problem import ( DEFAULT_COMPONENTS, - KERNEL_SCHEMA_VERSION, Flags, Initial, KernelProblem, @@ -199,21 +198,17 @@ def test_the_contract_document_lists_every_flag(soliton): assert documented == {entry.name for entry in dataclasses.fields(Flags)} -def test_every_page_states_the_schemas_the_code_writes(): - """A bump of a schema without its pages is exactly what this test is for. +def test_every_page_states_the_schema_the_code_writes(): + """A bump of the output schema without its pages is what this test is for. - A page states a current schema number in bold after the schema's name, and each - is stated once: the kernel's in the contract (``KERNEL_SCHEMA_VERSION``), the - saved output's in the output guide (``solprop.output.SCHEMA_VERSION``). A - sentence that calls a plain number current, or says what this release writes, - is held to the same numbers, so a stale statement elsewhere fails here too. - History, which names the version a change took, is not a statement of the - current one; the SEP records are history throughout. + A page states the current number in bold after the schema's name, once: in the + output guide (``solprop.output.SCHEMA_VERSION``). A sentence that calls a plain + number current, or says what this release writes, is held to it, so a stale + statement elsewhere fails here too. History, which names the version a change took, + is not a statement of the current one; the SEP records are history throughout. The + kernel contract no longer has a number (ADR-066): it names the last one as history. """ - assert KERNEL_SCHEMA_VERSION == 45 assert OUTPUT_SCHEMA_VERSION == 2 - assert soliton_schema_version_row().startswith("incremented on every change") - written = {"kernel": KERNEL_SCHEMA_VERSION, "output": OUTPUT_SCHEMA_VERSION} stated: dict[str, list[str]] = {} for page in sorted(DOCS.rglob("*.md")): name = page.relative_to(DOCS).as_posix() @@ -221,36 +216,25 @@ def test_every_page_states_the_schemas_the_code_writes(): continue text = " ".join(page.read_text(encoding="utf-8").split()) for kind, number in CURRENT_SCHEMA.findall(text): - assert int(number) == written[kind], (name, kind, number) + assert kind == "output" and int(number) == OUTPUT_SCHEMA_VERSION, ( + name, + kind, + number, + ) stated.setdefault(name, []).append(kind) for sentence in re.split(r"(?<=\.)\s+", text): if not re.search(r"\b(current|currently|this release)\b", sentence, re.I): continue for kind, number in NUMBERED_SCHEMA.findall(sentence): - schema = "kernel" if kind == "kernel" else "output" - assert int(number) == written[schema], (name, sentence) - assert stated == { - "dev/kernel-contract.md": ["kernel"], - "guide/results/output.md": ["output"], - } - assert f"current version is kernel schema **{KERNEL_SCHEMA_VERSION}**" in ( - " ".join(CONTRACT.read_text(encoding="utf-8").split()) - ) - - -def soliton_schema_version_row(): - """The meaning column of the schema_version row, as the document states it.""" - for line in CONTRACT.read_text(encoding="utf-8").splitlines(): - if line.startswith("| `schema_version`"): - return line.split("|")[3].strip() - raise AssertionError("the contract document has no schema_version row") + assert kind != "kernel", (name, sentence) + assert int(number) == OUTPUT_SCHEMA_VERSION, (name, sentence) + assert stated == {"guide/results/output.md": ["output"]} # ------------------------------------------------------------------------- assembly def test_the_soliton_problem_assembles(soliton, envelope_grid): - assert soliton.schema_version == KERNEL_SCHEMA_VERSION assert soliton.grid_kind == "envelope" assert soliton.shape == (1, 1, 1, envelope_grid.n_omega) assert soliton.flags == Flags( @@ -435,13 +419,6 @@ def corrupt(problem, group, name, value): return copy -def test_validate_rejects_a_wrong_schema_version(soliton): - stale = dataclasses.replace(soliton) - object.__setattr__(stale, "schema_version", KERNEL_SCHEMA_VERSION + 1) - with pytest.raises(TranslationError, match="schema_version"): - stale.validate() - - def test_validate_rejects_a_not_finite_array(soliton): spoiled = np.array(soliton.grids.omega, copy=True) spoiled[3] = np.nan @@ -733,7 +710,7 @@ def test_validate_allows_a_fixed_fibre_to_go_without_the_fast_path(soliton): def test_the_problem_cannot_be_mutated(soliton): with pytest.raises(dataclasses.FrozenInstanceError): - soliton.schema_version = 2 + soliton.grid_kind = "field" with pytest.raises(dataclasses.FrozenInstanceError): soliton.grids.dt = 1.0 with pytest.raises(ValueError, match="read-only"): @@ -1068,7 +1045,6 @@ def test_the_stats_request_is_the_catalogue_object(envelope_grid, gnlse, soliton def test_a_kernel_problem_can_be_built_by_hand_from_its_groups(soliton): """The contract is a dataclass, so a backend test can substitute one group.""" rebuilt = KernelProblem( - schema_version=soliton.schema_version, grid_kind=soliton.grid_kind, shape=soliton.shape, flags=soliton.flags, diff --git a/tests/translate/test_stage.py b/tests/translate/test_stage.py index 82ec5587..e2d5c946 100644 --- a/tests/translate/test_stage.py +++ b/tests/translate/test_stage.py @@ -423,7 +423,6 @@ def tabulated(): prefactor_constant=None, ) rebuilt = KernelProblem( - schema_version=problem.schema_version, grid_kind=problem.grid_kind, shape=problem.shape, flags=Flags.from_content( From c28eeac8c071a5431d2f9d06aa79e9d4ab1308ff Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:49:03 +0000 Subject: [PATCH 030/122] Declare the run layer's backend hooks and call them directly The driver and the run layer reached these by getattr, as if a backend might lack them, but every backend is a StepDriver. set_stop, set_held_plane, attach_monitor, held_observation, march_snapshot, march_snapshot_nbytes, march_export_refusal, restore_march and reads_transverse_edge join the Backend protocol as they are. _in_flight and _closed are read through new in_flight and closed properties, and the estimate's reach into _low_storage and _trial_step becomes timed_step. StepDriver._stop, AdamsEngine.carrier and StepDriver.far_field_statistics are declared, with defaults, and BoundaryMonitor.transverse was declared all along. Behaviour is unchanged, but a test of a backend without set_held_plane has nothing left to test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/backend-protocol-hooks.changed.md | 5 + docs/dev/architecture.md | 6 +- docs/dev/backends.md | 16 ++ docs/dev/figures/backends.svg | 162 ++++++++++-------- docs/dev/figures/build.py | 19 +- native/src/solprop_native/backend.py | 2 +- src/solprop/backends/driver.py | 68 +++++++- src/solprop/backends/multistep.py | 8 +- src/solprop/backends/protocol.py | 152 +++++++++++++++- src/solprop/backends/registry.py | 16 +- src/solprop/driver/estimate.py | 28 +-- src/solprop/driver/run.py | 40 ++--- src/solprop/driver/simulation.py | 9 +- tests/backends/adams_traces.py | 3 +- tests/backends/test_backend_protocol.py | 30 ++++ tests/backends/test_native_march_traces.py | 3 +- tests/driver/test_held_plane.py | 23 --- tests/driver/test_run.py | 31 ++++ tests/output/test_snapshot_failures.py | 1 - 19 files changed, 437 insertions(+), 185 deletions(-) create mode 100644 changelog.d/backend-protocol-hooks.changed.md diff --git a/changelog.d/backend-protocol-hooks.changed.md b/changelog.d/backend-protocol-hooks.changed.md new file mode 100644 index 00000000..34af6963 --- /dev/null +++ b/changelog.d/backend-protocol-hooks.changed.md @@ -0,0 +1,5 @@ +`solprop.backends.protocol.Backend` now declares what the run layer asks of a backend, which +it used to look up by name: `set_stop`, `set_held_plane`, `attach_monitor`, `held_observation`, +`in_flight`, `march_snapshot`, `march_snapshot_nbytes`, `march_export_refusal`, `restore_march`, +`timed_step`, `closed` and `reads_transverse_edge`. `StepDriver` defines all of them, so a +backend built on it changes nothing. diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 92a5ee17..335bbc12 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -260,7 +260,7 @@ Simulation(...) (D) ▼ run loop over save planes and stats periods (D) │ backend.set_initial(field) host → device once - │ for z_save in planes: backend.propagate_to(z_save) shared step loop + │ for z_save in planes: backend.propagate_to(z_save) the driver's marches │ obs = backend.observe() small host arrays │ stats = backend.stats(spec) one fused pass │ output.save(z_save, obs, stats) @@ -545,7 +545,7 @@ class Backend(Protocol): def set_initial(self, field, noise, *, z=0.0, resume=False) -> None: ... def set_progress(self, reporter) -> None: ... # an observer, never an input - def propagate_to(self, z: float) -> StepSummary: ... # shared step loop + def propagate_to(self, z: float) -> StepSummary: ... # the driver's way in def observe(self, *, field: bool = True) -> Observation: ... # host arrays only def stats(self, spec) -> dict[str, np.ndarray]: ... # one fused pass def metadata(self) -> dict[str, object]: ... # what the execution was @@ -553,6 +553,8 @@ class Backend(Protocol): def available_device_memory(self) -> int | None: ... def memory_ledger(self) -> dict[str, int]: ... def close(self) -> None: ... + # And what the run layer asks beside them: the stop flag, the held plane, the boundary + # monitor, the march's snapshot and an estimate's step (backends.md section 1). ``` - `numpy_ref`: the readable oracle. Plain NumPy + `scipy.fft`, no fusion, no in-place diff --git a/docs/dev/backends.md b/docs/dev/backends.md index e41ef5c4..6aa43810 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -37,6 +37,7 @@ statistic), and that structure is precisely what a fast backend exists to change class Backend(Protocol): name: str max_batch: int # 1 for backends that step one problem + reads_transverse_edge: bool # whether a requested real-space collar can run on it def set_initial( self, @@ -57,6 +58,21 @@ class Backend(Protocol): def memory_ledger(self) -> dict[str, int]: ... def available_device_memory(self) -> int | None: ... def close(self) -> None: ... # idempotent + + # What the run layer asks beside those; StepDriver gives each its default. + def set_stop(self, flag: StopFlag | None) -> None: ... # polled between trials + def set_held_plane(self, held: bool) -> None: ... # the low-storage march's hold + def attach_monitor(self, monitor: BoundaryMonitor) -> None: ... + @property + def in_flight(self) -> tuple[float, float] | None: ... # a step abandoned there + def held_observation(self) -> Observation | None: ... + def march_snapshot(self) -> MarchSnapshot | None: ... # the Adams history, for a resume + def march_snapshot_nbytes(self, z: float) -> int | None: ... # copying nothing + def march_export_refusal(self) -> str | None: ... + def restore_march(self, snapshot: MarchSnapshot) -> str | None: ... + def timed_step(self) -> Callable[[], object]: ... # one step, for an estimate + @property + def closed(self) -> bool: ... # a failure can close a backend ``` The listing is abbreviated; `solprop/backends/protocol.py` holds the exact signatures and diff --git a/docs/dev/figures/backends.svg b/docs/dev/figures/backends.svg index 68f7e314..cfcee1ff 100644 --- a/docs/dev/figures/backends.svg +++ b/docs/dev/figures/backends.svg @@ -1,6 +1,6 @@ + width="1200" height="903.5" viewBox="0 0 1200 903.5"> - -One seam, one step loop, and the backends behind them + +One seam, a shared driver, and the backends behind them backends.md §1: the protocol is drawn at the level of operations on the propagated state, never at the array-namespace level. A backend is constructed from a validated KernelProblem and host arrays and returns host arrays; its buffers, layout and plans are private. - + the seam · backends.protocol.Backend set_initial the state and the noise realisation, host to device, once set_progress the observer each attempted step reports to; never an input -propagate_to -the shared step loop, returning a StepSummary -observe -host arrays: the field at the plane and the step history -restore_call_ledgers -resume cumulative calls; numerical history starts afresh -stats -one fused pass, a dict of small host arrays -metadata -what the execution was: pools, plans, digests, propagator mode -memory_ledger -every named buffer and its size -available_device_memory -free device bytes for batch admission; None on hosts -close -idempotent - - -backends.registry.select(name, problem, grid=, modes=, profiles=) - -backends/driver.py · StepDriver, and no backend reimplements it -the march and the landing -_march_to -the replay -_replay_to · _schedule_index_at -the history -_record · observe -the progress reporter -set_progress -the statistics channel -_take_step_stats · stats -the noise -_noise_field · _noise_at · _noise_phase -the per-stage bundle -_hoist · _stage_scalars · _delta_phi · _prefactor · -_unitary_phase - -the stepper it drives -stepper.tableau -DOPRI5, Tsit5, the order-condition checks -stepper.controller -PIController: propose, accepts, rejection_factor, -equivalent_norm -stepper.schedule -landing, discover, calibrate, replay -what a backend supplies -_trial_step · _apply_mask · _finish · _begin_plane -· _on_accepted_step - - - -numpy_ref · the oracle -plain NumPy and scipy.fft -one named function per pass -the general representation, the z table included -propagator_mode auto → tabulate -_trial_step · _apply_mask · _finish · _observed_field - - - -native · what auto selects -solprop-native distribution, native/ -C++20 Kokkos + persistent Kokkos-FFT plans -one native call per seven-stage trial -OpenMP on CPUs; CUDA and HIP on GPUs -_trial_step · _apply_mask · _finish · _observed_field -Three things every backend owes beyond the methods: deterministic reductions, the device form of the stage, and one step loop. The error norm is control flow, so a sum -whose association depends on the thread count forks the trajectory. +set_stop +the flag polled between trials, or none +set_held_plane +whether the low-storage march holds a field-free plane +attach_monitor +watch the time window's edges from here on +propagate_to +the driver's way in, returning a StepSummary +observe +host arrays: the field at the plane and the step history +held_observation +the held plane, for a checkpoint after a failed step +restore_call_ledgers +resume cumulative calls; numerical history starts afresh +march_snapshot +the Adams history for a resume, or None +march_snapshot_nbytes +its size, copying nothing +march_export_refusal +why the march cannot carry its history, or None +restore_march +continue the history, or say why it starts afresh +timed_step +one step of the march, for an estimate to time +stats +one fused pass, a dict of small host arrays +metadata +what the execution was: pools, plans, digests, propagator mode +memory_ledger +every named buffer and its size +available_device_memory +free device bytes for batch admission; None on hosts +close +idempotent + + +backends.registry.select(name, problem, grid=, modes=, profiles=) + +backends/driver.py · StepDriver, and no backend reimplements it +the march and the landing +_march_to +the replay +_replay_to · _schedule_index_at +the history +_record · observe +the progress reporter +set_progress +the statistics channel +_take_step_stats · stats +the noise +_noise_field · _noise_at · _noise_phase +the per-stage bundle +_hoist · _stage_scalars · _delta_phi · _prefactor · +_unitary_phase + +the stepper it drives +stepper.tableau +DOPRI5, Tsit5, the order-condition checks +stepper.controller +PIController: propose, accepts, rejection_factor, +equivalent_norm +stepper.schedule +landing, discover, calibrate, replay +what a backend supplies +_trial_step · _apply_mask · _finish · _begin_plane +· _on_accepted_step + + + +numpy_ref · the oracle +plain NumPy and scipy.fft +one named function per pass +the general representation, the z table included +propagator_mode auto → tabulate +_trial_step · _apply_mask · _finish · _observed_field + + + +native · what auto selects +solprop-native distribution, native/ +C++20 Kokkos + persistent Kokkos-FFT plans +one native call per seven-stage trial +OpenMP on CPUs; CUDA and HIP on GPUs +_trial_step · _apply_mask · _finish · _observed_field +Three things every backend owes beyond the methods: deterministic reductions, the device form of the stage, and the driver's bookkeeping. The error norm is control flow, so +a sum whose association depends on the thread count forks the trajectory. \ No newline at end of file diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index e94d5a9c..b729aa7f 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -1135,12 +1135,21 @@ class Pass(NamedTuple): SEAM_METHODS = ( ("set_initial", "the state and the noise realisation, host to device, once"), ("set_progress", "the observer each attempted step reports to; never an input"), - ("propagate_to", "the shared step loop, returning a StepSummary"), + ("set_stop", "the flag polled between trials, or none"), + ("set_held_plane", "whether the low-storage march holds a field-free plane"), + ("attach_monitor", "watch the time window's edges from here on"), + ("propagate_to", "the driver's way in, returning a StepSummary"), ("observe", "host arrays: the field at the plane and the step history"), + ("held_observation", "the held plane, for a checkpoint after a failed step"), ( "restore_call_ledgers", "resume cumulative calls; numerical history starts afresh", ), + ("march_snapshot", "the Adams history for a resume, or None"), + ("march_snapshot_nbytes", "its size, copying nothing"), + ("march_export_refusal", "why the march cannot carry its history, or None"), + ("restore_march", "continue the history, or say why it starts afresh"), + ("timed_step", "one step of the march, for an estimate to time"), ("stats", "one fused pass, a dict of small host arrays"), ("metadata", "what the execution was: pools, plans, digests, propagator mode"), ("memory_ledger", "every named buffer and its size"), @@ -2467,7 +2476,7 @@ def _step_panel( def backends() -> drawsvg.Drawing: - """Draw figure 5: the seam, the one step loop, and the backends behind it. + """Draw figure 5: the seam, the shared driver, and the backends behind it. Returns ------- @@ -2515,8 +2524,8 @@ def backends() -> drawsvg.Drawing: ) footer = ( "Three things every backend owes beyond the methods: deterministic " - "reductions, the device form of the stage, and one step loop. The error norm " - "is control flow, so a sum whose association depends on the thread count " + "reductions, the device form of the stage, and the driver's bookkeeping. The " + "error norm is control flow, so a sum whose association depends on the thread count " "forks the trajectory." ) top = header_height(subtitle) + 20 @@ -2534,7 +2543,7 @@ def backends() -> drawsvg.Drawing: ) drawing = _canvas(height) - _header(drawing, "One seam, one step loop, and the backends behind them", subtitle) + _header(drawing, "One seam, a shared driver, and the backends behind them", subtitle) y = top _table( diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 68df9e6e..6f0aafa4 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -2683,7 +2683,7 @@ def _bounds(self, target: float, slack: float | None) -> dict[str, object]: # steps are capped at its spacing, the march returns before a step # would carry past its next read, and a collar's current is taken at # every committed step. - edge = getattr(monitor, "transverse", None) + edge = monitor.transverse if edge is not None: if edge.every_step: steps.append(1) diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index ae583096..be9df85b 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -28,8 +28,9 @@ import time from abc import ABC, abstractmethod from collections import deque -from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Literal, TypeVar, cast +from collections.abc import Callable, Mapping, Sequence +from functools import partial +from typing import TYPE_CHECKING, ClassVar, Literal, TypeVar, cast import numpy as np from jaxtyping import Complex, Float @@ -313,12 +314,18 @@ class StepDriver[_StateT](ABC): # noqa: UP049 - the protected state type is pri march and engines pass down rather than choosing one (SEP 29 section 6.6). Each backend sets its own: ``PLAIN`` (the default) for the NumPy reference, the C++ bindings for the native backend. + far_field_statistics : frozenset of str + The far-field statistics a compiled backend declares it forms (SEP 23), which + the registry reads for the native backend alone + (:func:`solprop.backends.registry.statistics_refusal`): ``numpy_ref`` forms them + all and ``mlx`` refuses them in validation. """ name: str = "driver" max_batch: int = 1 propagator_default: PropagatorMode = "tabulate" helpers: Helpers = PLAIN + far_field_statistics: ClassVar[frozenset[str]] = frozenset() def __init__( self, @@ -378,7 +385,6 @@ def __init__( self._clock = time.perf_counter() self._progress: ProgressReporter = NullReporter() self._progress_started = False - self._stop: StopFlag | None = None # The statistics channel between planes. The spec a row is evaluated with is # the periodic subset promoted to `names`, so the row goes through the very # same `stats` the save planes use and is bitwise what a plane would record. @@ -1304,6 +1310,9 @@ def set_progress(self, reporter: ProgressReporter) -> None: self._progress = reporter self._progress_started = False + _stop: StopFlag | None = None + """The flag every trial step polls (:meth:`set_stop`); ``None`` by default.""" + def set_stop(self, flag: StopFlag | None) -> None: """Attach the flag every trial step polls, or detach it with ``None``. @@ -1350,8 +1359,7 @@ def _poll_stop(self) -> None: solprop.exceptions.StopRequested If the flag is set. """ - # A harness that builds a driver without __init__ has no flag. - poll(getattr(self, "_stop", None)) + poll(self._stop) def propagate_to(self, z: float) -> StepSummary: """Step the state to a position, landing on it exactly. @@ -1552,6 +1560,39 @@ def held_observation(self) -> Observation | None: n_rejected=0, ) + @property + def in_flight(self) -> tuple[float, float] | None: + """Return the low-storage step a failure abandoned in flight, or ``None``. + + Returns + ------- + tuple of float or None + The step's start and length, m, while the state is neither of its ends + (``solprop.backends.low_storage``). + """ + return self._in_flight + + def timed_step(self) -> Callable[[], object]: + """Return one step of this run's march, for an estimate to time. + + A low-storage march times its own step, which accepts in place, so repeating it + advances the discarded state: the caller closes the backend afterwards. Every + other run times the Runge-Kutta trial step, which leaves the state alone. The + native low-storage state holds none of that trial's views. + + Returns + ------- + collections.abc.Callable + Takes one step, from the loaded state. + """ + if self._low_storage is None: + z, dz = float(self._problem.initial.z), float(self._problem.stepper.dz_init) + return partial(self._trial_step, z, dz) + schedule = self._problem.stepper.schedule + assert schedule is not None # the translation refuses the march without one + z, dz = float(schedule.edges[0]), float(schedule.steps[0]) + return partial(self._low_storage.engine.step, z, dz) + def _hold(self, state: _StateT | None) -> None: """Hold a low-storage march's state at a field-free plane, or release the hold. @@ -1992,7 +2033,7 @@ def attach_monitor(self, monitor: BoundaryMonitor) -> None: self._set_collar_quiet(monitor.collar_quiet) # A resumed fixed schedule is held to the state it resumes from at once. self._refuse_scheduled_crossing(monitor) - edge = getattr(monitor, "transverse", None) + edge = monitor.transverse if edge is not None: state = self._observed_field(self._require_state()) floor = self._input_floor @@ -2016,7 +2057,7 @@ def _read_transverse(self, monitor: BoundaryMonitor, *, plane: bool) -> None: plane : bool Whether a saved plane has just been reached, which is always read. """ - edge = getattr(monitor, "transverse", None) + edge = monitor.transverse if edge is None: return # Read before the next step would carry past the read (`TransverseEdge.due`). @@ -2070,7 +2111,7 @@ def _transverse_capped(self, proposal: float) -> float: The proposal, or the read spacing, m. """ monitor = self._monitor - edge = None if monitor is None else getattr(monitor, "transverse", None) + edge = None if monitor is None else monitor.transverse if edge is None: return proposal return min(proposal, edge.ceiling(self._z)) @@ -2647,6 +2688,17 @@ def _refuse_in_flight(self) -> None: ) raise BackendError(msg) + @property + def closed(self) -> bool: + """Return whether the backend has released its buffers, as a failure may. + + Returns + ------- + bool + Whether it can run another input. + """ + return self._closed + def _check_open(self) -> None: """Refuse a backend that has been closed. diff --git a/src/solprop/backends/multistep.py b/src/solprop/backends/multistep.py index 9031d33c..04acde75 100644 --- a/src/solprop/backends/multistep.py +++ b/src/solprop/backends/multistep.py @@ -1789,6 +1789,10 @@ class AdamsEngine(Protocol): compensated: bool """Whether trials carry a varying operator's linear compensation (SEP 16 4.2).""" + carrier: CarrierReference | None + """The run's carrier frame (SEP 35), or ``None`` when off; an exponential trial + removes its phase from the samples it reads.""" + @property def n_slots(self) -> int: """The history store's rows.""" @@ -3024,7 +3028,7 @@ def __init__( # section 4.2); the decisions are `adams_core`'s functions of it. settings = backend._problem.stepper linear = backend._problem.linear - carrier = getattr(self._engine, "carrier", None) + carrier = self._engine.carrier self._policy: MarchPolicy = march_policy( method, dz_min=settings.dz_min, @@ -3527,7 +3531,7 @@ def _capped(self, proposal: float) -> float: """ if not self._policy.carrier: return proposal - carrier = cast("CarrierReference", getattr(self._engine, "carrier", None)) + carrier = cast("CarrierReference", self._engine.carrier) return adams_core.carrier_capped( self._policy, self._backend._z, proposal, carrier.chord_phase ) diff --git a/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py index fc065cc3..53b861a2 100644 --- a/src/solprop/backends/protocol.py +++ b/src/solprop/backends/protocol.py @@ -27,7 +27,7 @@ import dataclasses import logging -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from typing import Protocol, runtime_checkable @@ -36,9 +36,10 @@ from solprop._process import parallel_rows from solprop.exceptions import BackendError, BoundaryError, BoundaryWarning -from solprop.log import ProgressReporter +from solprop.log import ProgressReporter, StopFlag from solprop.monitor import BoundaryMonitor from solprop.noise import NoiseRealisation +from solprop.output.schema import MarchSnapshot from solprop.stats import StatsSpec __all__ = [ @@ -602,20 +603,29 @@ class Backend(Protocol): max_batch : int Largest batch axis this backend steps at once; 1 for a backend that steps one problem. + reads_transverse_edge : bool + Whether it reads a free-space run's transverse edge (SEP 40 section 5.3), which + a run with a requested real-space collar needs. Examples -------- >>> from solprop.backends.protocol import Backend - >>> [name for name in sorted(dir(Backend)) if not name.startswith("_")][:4] - ['available_device_memory', 'close', 'memory_ledger', 'metadata'] - >>> [name for name in sorted(dir(Backend)) if not name.startswith("_")][4:7] - ['observe', 'propagate_to', 'restore_call_ledgers'] - >>> [name for name in sorted(dir(Backend)) if not name.startswith("_")][7:] - ['set_initial', 'set_progress', 'stats'] + >>> names = [name for name in sorted(dir(Backend)) if not name.startswith("_")] + >>> names[:4] + ['attach_monitor', 'available_device_memory', 'close', 'closed'] + >>> names[4:8] + ['held_observation', 'in_flight', 'march_export_refusal', 'march_snapshot'] + >>> names[8:12] + ['march_snapshot_nbytes', 'memory_ledger', 'metadata', 'observe'] + >>> names[12:16] + ['propagate_to', 'restore_call_ledgers', 'restore_march', 'set_held_plane'] + >>> names[16:] + ['set_initial', 'set_progress', 'set_stop', 'stats', 'timed_step'] """ name: str max_batch: int + reads_transverse_edge: bool def set_initial( self, @@ -669,8 +679,39 @@ def set_progress(self, reporter: ProgressReporter) -> None: """ ... + def set_stop(self, flag: StopFlag | None) -> None: + """Attach the flag every trial step polls, or detach it with ``None``. + + Parameters + ---------- + flag : solprop.log.StopFlag or None + Any object with an ``is_set()`` method, such as a + :class:`threading.Event`. + """ + ... + + def set_held_plane(self, held: bool) -> None: + """Say whether the low-storage march holds the last field-free plane's state. + + Parameters + ---------- + held : bool + Whether to hold it; other marches hold nothing either way. + """ + ... + + def attach_monitor(self, monitor: BoundaryMonitor) -> None: + """Watch the time window's edges from here on, starting with the current state. + + Parameters + ---------- + monitor : solprop.monitor.BoundaryMonitor + The monitor, fed every committed step. + """ + ... + def propagate_to(self, z: float) -> StepSummary: - """Step the state to a position through the shared step loop. + """Step the state to a position through the shared driver's marches. Parameters ---------- @@ -705,6 +746,27 @@ def observe(self, *, field: bool = True) -> Observation: """ ... + @property + def in_flight(self) -> tuple[float, float] | None: + """Return the low-storage step a failure abandoned in flight, or ``None``. + + Returns + ------- + tuple of float or None + The step's start and length, m, while the state is neither of its ends. + """ + ... + + def held_observation(self) -> Observation | None: + """Return the state the low-storage march held at its last field-free plane. + + Returns + ------- + Observation or None + The held plane with an empty step history, or ``None`` when nothing is held. + """ + ... + def stats(self, spec: StatsSpec) -> dict[str, np.ndarray]: """Compute the requested statistics in one fused pass. @@ -753,6 +815,67 @@ def restore_call_ledgers( """ ... + def march_snapshot(self) -> MarchSnapshot | None: + """Return the Adams march's state at the accepted position, for a resume. + + Returns + ------- + solprop.output.schema.MarchSnapshot or None + The snapshot, or ``None`` wherever the march has nothing to continue. + """ + ... + + def march_snapshot_nbytes(self, z: float) -> int | None: + """Return the bytes :meth:`march_snapshot` would copy at `z`, copying nothing. + + Parameters + ---------- + z : float + The position the snapshot is wanted at, m. + + Returns + ------- + int or None + The size, or ``None`` where a snapshot would be ``None`` or stand elsewhere. + """ + ... + + def march_export_refusal(self) -> str | None: + """Return why this backend's Adams march cannot carry its history, or ``None``. + + Returns + ------- + str or None + The reason, or ``None`` when it can or when the march keeps none. + """ + ... + + def restore_march(self, snapshot: MarchSnapshot) -> str | None: + """Continue the Adams march from a snapshot at the loaded position. + + Parameters + ---------- + snapshot : solprop.output.schema.MarchSnapshot + The snapshot a file holds. + + Returns + ------- + str or None + ``None`` when the history was continued, otherwise why it starts afresh. + """ + ... + + def timed_step(self) -> Callable[[], object]: + """Return one step of this run's march, for an estimate to time. + + Returns + ------- + collections.abc.Callable + Takes one step from the loaded state. It may advance that state, so the + caller closes the backend afterwards. + """ + ... + def available_device_memory(self) -> int | None: """Return currently free device bytes, or None for a host backend. @@ -779,6 +902,17 @@ def close(self) -> None: """Release the buffers and plans. Idempotent.""" ... + @property + def closed(self) -> bool: + """Return whether the backend has released its buffers, as a failure may. + + Returns + ------- + bool + Whether it can run another input. + """ + ... + @runtime_checkable class BatchBackend(Backend, Protocol): diff --git a/src/solprop/backends/registry.py b/src/solprop/backends/registry.py index 01155a2a..04d774f3 100644 --- a/src/solprop/backends/registry.py +++ b/src/solprop/backends/registry.py @@ -102,10 +102,9 @@ class BackendEntry: FAR_FIELD_BACKENDS: tuple[str, ...] = ("numpy_ref", "native") """The backends that form the far-field statistics of SEP 23. -``numpy_ref`` always does; the native backend does from SEP 23's third phase, which a -build declares by its backend's ``far_field_statistics`` attribute. An -older build is refused them by name on this side of the seam -(:func:`statistics_refusal`), before it is built. +``numpy_ref`` always does; the native backend declares what it forms by its class's +``far_field_statistics`` attribute. A statistic it does not declare is refused by name +on this side of the seam (:func:`statistics_refusal`), before it is built. """ @@ -162,9 +161,9 @@ def _plugin_far_field(name: str) -> frozenset[str] | None: Returns ------- frozenset of str or None - What the plugin's backend class declares in ``far_field_statistics`` (empty for - a build that predates the attribute), or ``None`` when the name is not a known, - installed plugin. + What the plugin's backend class declares in + :attr:`~solprop.backends.driver.StepDriver.far_field_statistics`, or ``None`` + when the name is not a known, installed plugin. """ entry = BACKENDS.get(name) if name != "native" or entry is None or not _satisfied(entry): @@ -173,8 +172,7 @@ def _plugin_far_field(name: str) -> frozenset[str] | None: NativeBackend, ) - declared = getattr(NativeBackend, "far_field_statistics", frozenset()) - return frozenset(cast(Collection[str], declared)) + return frozenset(cast(Collection[str], NativeBackend.far_field_statistics)) def available(precision: str = "double") -> tuple[str, ...]: diff --git a/src/solprop/driver/estimate.py b/src/solprop/driver/estimate.py index 81dcef2d..79fe5570 100644 --- a/src/solprop/driver/estimate.py +++ b/src/solprop/driver/estimate.py @@ -16,9 +16,9 @@ import time from collections.abc import Callable, Mapping from dataclasses import dataclass -from functools import partial from typing import TYPE_CHECKING, Final +from solprop.backends.protocol import Backend from solprop.backends.registry import select from solprop.driver.run import field_retention from solprop.exceptions import DriverError @@ -100,18 +100,13 @@ def step(self) -> float: def _timed_step( - backend: object, problem: KernelProblem + backend: Backend, problem: KernelProblem ) -> tuple[Callable[[], object], int]: """Return the step to time for a run, and the evaluations it makes. - A low-storage march times its own step, which accepts in place, so repeating it - advances the discarded state: the backend is closed after the estimate. Every other - run times the Runge-Kutta trial step, which leaves the state alone. The native - low-storage state holds none of that trial's views. - Parameters ---------- - backend : object + backend : solprop.backends.protocol.Backend The constructed backend, with its initial state loaded. problem : solprop.translate.problem.KernelProblem The translated problem. @@ -121,18 +116,13 @@ def _timed_step( tuple A callable taking one step, and the right-hand-side evaluations in it. """ - march = getattr(backend, "_low_storage") # noqa: B009 - every StepDriver has it - if march is None: - z = float(problem.initial.z) - dz = float(problem.stepper.dz_init) - trial = getattr(backend, "_trial_step") # noqa: B009 - every StepDriver has it - return partial(trial, z, dz), len(problem.stepper.tableau.nodes) - schedule = problem.stepper.schedule - assert schedule is not None # the translation refuses the march without one - z, dz = float(schedule.edges[0]), float(schedule.steps[0]) - return partial(march.engine.step, z, dz), _calls_per_step( - problem.stepper.integrator + integrator = problem.stepper.integrator + evaluations = ( + _calls_per_step(integrator) + if isinstance(integrator, LowStorageRungeKutta) + else len(problem.stepper.tableau.nodes) ) + return backend.timed_step(), evaluations def _calls_per_step(integrator: object) -> int: diff --git a/src/solprop/driver/run.py b/src/solprop/driver/run.py index 1552632b..1a4f2f24 100644 --- a/src/solprop/driver/run.py +++ b/src/solprop/driver/run.py @@ -489,13 +489,9 @@ def run_problem( # noqa: PLR0912, PLR0913, PLR0915 - the loop's inputs and comm estimated=not isinstance(problem.stepper.integrator, LowStorageRungeKutta), ) backend.set_progress(reporter) - set_stop = getattr(backend, "set_stop", None) - if set_stop is not None: - set_stop(stop) + backend.set_stop(stop) # An output with nowhere to put a checkpoint would never read the held state. - set_held_plane = getattr(backend, "set_held_plane", None) - if set_held_plane is not None: - set_held_plane(held_plane and isinstance(output, CheckpointOutput)) + backend.set_held_plane(held_plane and isinstance(output, CheckpointOutput)) started = time.perf_counter() clock = RunClock.start() logger.debug( @@ -583,8 +579,7 @@ def run_problem( # noqa: PLR0912, PLR0913, PLR0915 - the loop's inputs and comm clock=clock, ) watch.show(pending, output.n_saved - 1) - attach = getattr(backend, "attach_monitor", None) - if boundary_monitor is not None and attach is not None: + if boundary_monitor is not None: if resumed: # The record continues across the resume (SEP 21, ADR-057). boundary_monitor.resume( @@ -592,7 +587,7 @@ def run_problem( # noqa: PLR0912, PLR0913, PLR0915 - the loop's inputs and comm plane=output.n_saved > 0 and float(output["z"][-1]) == reached, unwatched=unwatched, ) - attach(boundary_monitor) + backend.attach_monitor(boundary_monitor) index = start_plane while index < planes.size: # Before the first step and between planes, for a backend that does not @@ -664,7 +659,7 @@ def persist(z: float = reached) -> None: # A natural step can stop short of the requested plane (SEP 14). if math.isclose(summary.z, target, rel_tol=1e-12, abs_tol=0.0): index += 1 - if boundary_monitor is not None and attach is not None: + if boundary_monitor is not None: boundary_monitor.finish() with committing(): _record_execution( @@ -714,8 +709,7 @@ def persist(z: float = reached) -> None: reporter.interrupted(reached, error) raise finally: - if set_stop is not None: - set_stop(None) + backend.set_stop(None) try: output.close() except BaseException as error: @@ -1037,11 +1031,10 @@ def _refuse_unread_collar(problem: KernelProblem, backend: Backend) -> None: design = problem.transverse if design is None or not design.has_collar: return - if getattr(backend, "reads_transverse_edge", False): + if backend.reads_transverse_edge: return - name = getattr(backend, "name", type(backend).__name__) msg = ( - f"the {name} backend does not carry a requested real-space collar " + f"the {backend.name} backend does not carry a requested real-space collar " "(SpatialAbsorber with real_space=True): run it on one of solprop's own " "backends, or ask for the safety ramp alone" ) @@ -1190,7 +1183,7 @@ class _MarchKeeper: output : solprop.output.schema.Output The run's handler; only one with ``save_march`` keeps a snapshot. backend : solprop.backends.protocol.Backend - The run's backend; only one with ``march_snapshot`` has one to keep. + The run's backend, whose march has a snapshot to keep or none. policy : bool or None The run's ``resume_history``. """ @@ -1198,8 +1191,8 @@ class _MarchKeeper: def __init__(self, output: Output, backend: Backend, policy: bool | None) -> None: """Hold what keeping a snapshot needs.""" self._save = getattr(output, "save_march", None) - self._snapshot = getattr(backend, "march_snapshot", None) - self._snapshot_nbytes = getattr(backend, "march_snapshot_nbytes", None) + self._snapshot = backend.march_snapshot + self._snapshot_nbytes = backend.march_snapshot_nbytes self._policy = policy self._kept_z: float | None = None @@ -1221,11 +1214,7 @@ def keep(self, z: float, *, required: bool = False) -> None: """ if required and self._kept_z == z: return - if ( - self._save is None - or self._snapshot is None - or self._snapshot_nbytes is None - ): + if self._save is None: if required: raise OutputError( "snapshot requires a backend and output with Adams history" @@ -1293,12 +1282,11 @@ def _persist_failure( if output is None: return None pending = failure.pending - in_flight = getattr(backend, "_in_flight", None) + in_flight = backend.in_flight held = None if pending is None and in_flight is not None: - holder = getattr(backend, "held_observation", None) try: - held = None if holder is None else holder() + held = backend.held_observation() except Exception as held_error: # A held state that cannot be observed (one not finite outside the norm # mask, say) must not replace the original exception: the failure is diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 241d0ee8..929c097d 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -4059,9 +4059,6 @@ def _continue_march( another integrator keeps no history, by its own choice, which is recorded as a numerics change and needs no warning (SEP 42 section 5.6). """ - restore = getattr(backend, "restore_march", None) - if restore is None: - return if self.settings.resume_history is False: if state.march is not None: logger.info("the Adams history starts afresh: resume_history=False") @@ -4075,7 +4072,7 @@ def _continue_march( "with another integrator" ) return - refusal = getattr(backend, "march_export_refusal", lambda: None)() + refusal = backend.march_export_refusal() if refusal is not None: warnings.warn( f"the Adams history starts afresh: {refusal}", @@ -4085,7 +4082,7 @@ def _continue_march( return if state.march is None: return - reason = restore(state.march) + reason = backend.restore_march(state.march) if reason is None: logger.info( "continuing the Adams history at order %d, %d samples", @@ -7276,7 +7273,7 @@ def run( # noqa: PLR0913 - one input's run, as with_input and run_problem take ) except BaseException: # A backend that closed itself cannot run another input. - if getattr(self._backend, "_closed", False): + if self._backend.closed: self._close_held() raise finally: diff --git a/tests/backends/adams_traces.py b/tests/backends/adams_traces.py index 08fec898..751203aa 100644 --- a/tests/backends/adams_traces.py +++ b/tests/backends/adams_traces.py @@ -246,9 +246,10 @@ def __init__(self, **values: Any) -> None: class _Monitor: """The boundary monitor's interval and crossing cap, the parts the march's caps - read; no split collar caps these scenarios.""" + read; no split collar caps these scenarios, and no transverse edge is watched.""" step_cap = math.inf + transverse = None def __init__(self, interval: float) -> None: self.interval = interval diff --git a/tests/backends/test_backend_protocol.py b/tests/backends/test_backend_protocol.py index ef4cc0f1..8641f6ae 100644 --- a/tests/backends/test_backend_protocol.py +++ b/tests/backends/test_backend_protocol.py @@ -319,6 +319,9 @@ class Stub: name = "stub" max_batch = 1 + reads_transverse_edge = False + in_flight = None + closed = False def set_initial(self, field, noise): self.field = field @@ -329,6 +332,33 @@ def restore_call_ledgers(self, logical, atomic=None): def set_progress(self, reporter): self.reporter = reporter + def set_stop(self, flag): + return None + + def set_held_plane(self, held): + return None + + def attach_monitor(self, monitor): + return None + + def held_observation(self): + return None + + def march_snapshot(self): + return None + + def march_snapshot_nbytes(self, z): + return None + + def march_export_refusal(self): + return None + + def restore_march(self, snapshot): + return None + + def timed_step(self): + return lambda: None + def propagate_to(self, z): return StepSummary(z, 1, 0, 0.5, 0.5, 0.0, True) diff --git a/tests/backends/test_native_march_traces.py b/tests/backends/test_native_march_traces.py index e06c9372..8b5a3985 100644 --- a/tests/backends/test_native_march_traces.py +++ b/tests/backends/test_native_march_traces.py @@ -61,9 +61,10 @@ class _Progress(NullReporter): class _Monitor: """The quiet collar's step cap, its checks due every step; no split collar's - crossing cap.""" + crossing cap, and no transverse edge.""" step_cap = math.inf + transverse = None def __init__(self, interval: float) -> None: self.interval = interval diff --git a/tests/driver/test_held_plane.py b/tests/driver/test_held_plane.py index ea8d5294..4cb961d2 100644 --- a/tests/driver/test_held_plane.py +++ b/tests/driver/test_held_plane.py @@ -282,26 +282,3 @@ def plane(self, z, summary) -> None: ) assert "no state was written there" in caplog.text assert "resume=True continues" not in caplog.text - - -def test_a_backend_that_has_no_such_setting_runs_as_ever(): - """The driver asks only a backend that has it; one of its own need not.""" - - class Plain(NumpyRefBackend): - set_held_plane = None # pyright: ignore[reportAssignmentType] - without it - - simulation = built() - problem = simulation.translate() - backend = Plain(problem, grid=simulation.grid, modes=simulation.modes) - backend.set_initial(problem.initial.field0, None) - output = MemoryOutput() - run_problem( - problem, - backend, - output, - metadata=simulation.metadata(), - progress="none", - fields="none", - ) - backend.close() - assert output.n_saved == 3 diff --git a/tests/driver/test_run.py b/tests/driver/test_run.py index 73b17a19..848724a1 100644 --- a/tests/driver/test_run.py +++ b/tests/driver/test_run.py @@ -127,6 +127,10 @@ class NullBackend: name = "null" max_batch = 1 + reads_transverse_edge = False + in_flight = None + closed = False + def __init__(self, failure=None, *, shape=(1, 1, 1, 8)): self.failure = failure self.shape = shape @@ -137,6 +141,33 @@ def set_initial(self, field, noise, *, z=0.0, resume=False): def set_progress(self, reporter): self.reporter = reporter + def set_stop(self, flag): + pass + + def set_held_plane(self, held): + pass + + def attach_monitor(self, monitor): + pass + + def held_observation(self): + return None + + def march_snapshot(self): + return None + + def march_snapshot_nbytes(self, z): + return None + + def march_export_refusal(self): + return None + + def restore_march(self, snapshot): + return None + + def timed_step(self): + return lambda: None + def propagate_to(self, z): if self.failure is not None: raise self.failure diff --git a/tests/output/test_snapshot_failures.py b/tests/output/test_snapshot_failures.py index 526340d3..88a2ab6f 100644 --- a/tests/output/test_snapshot_failures.py +++ b/tests/output/test_snapshot_failures.py @@ -330,7 +330,6 @@ def test_missing_backend_history_stops_clearly(working): keeper._kept_z = None keeper._policy = None keeper._save = None - keeper._snapshot = None with pytest.raises(OutputError, match="backend and output"): keeper.keep(0.125, required=True) keeper._save = lambda state: None From a006d4a3f4723fff991c798cc174c3792b3d3376 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:49:03 +0000 Subject: [PATCH 031/122] Correct DRIVER_SURFACE and its frozen table The tuple now names what the adapter overrides or calls. It gains _adams_block, _axis_statistics, _collar_changed, _delta_phi, _march_to, _unitary_phase and available_device_memory. It loses observe, propagate_to, set_progress, restore_call_ledgers, _reserve_source_calls and _release_source_calls, which the adapter only inherits: solprop's own code calls them, and the table in test_driver.py still freezes their signatures. The table gains every other method a subclass or the shared marches override or call, and the run layer's hooks; the attribute list gains what the adapter reads. A test holds the tuple inside the table, and another holds every override inside the tuple. The class-level members the adapter reads are looked up beside the methods. The version history in the two docstrings goes with the version. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/solprop_native/__init__.py | 129 ++++++++------------------ native/tests/test_backend.py | 15 ++- tests/backends/test_driver.py | 66 ++++++++++++- 3 files changed, 117 insertions(+), 93 deletions(-) diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py index d8ee0632..6744d08e 100644 --- a/native/src/solprop_native/__init__.py +++ b/native/src/solprop_native/__init__.py @@ -172,6 +172,7 @@ def _band_request() -> int: DRIVER_SURFACE: tuple[str, ...] = ( "_adams_adopt", + "_adams_block", "_adams_engine", "_adams_lab_source", "_adams_propagator", @@ -179,9 +180,12 @@ def _band_request() -> int: "_adams_source", "_adams_state", "_apply_mask", + "_axis_statistics", "_begin_plane", "_bind_monitor", "_boundary_energies", + "_collar_changed", + "_delta_phi", "_dense_stages", "_drop_held", "_finish", @@ -189,103 +193,49 @@ def _band_request() -> int: "_hoist", "_hold", "_low_storage_engine", - "_observed_field", + "_march_to", "_natural_stop", "_natural_stop_due", + "_observed_field", "_on_accepted_step", "_on_axis_synthesis", "_record_attempt", - "_release_source_calls", "_require_grid", "_require_state", - "_reserve_source_calls", "_skip_step", "_stage_bundles", "_stage_scalars", "_take_step_stats", "_trial_step", + "_unitary_phase", + "available_device_memory", "metadata", - "observe", - "propagate_to", - "restore_call_ledgers", "set_initial", - "set_progress", "stats", ) -"""Every ``StepDriver`` member this backend overrides, calls or inherits for resume. - -Schema 19 combines the Adams source/adoption and dense-stage hooks with the response- -ledger resume and source-reservation hooks. Schema 20 (solprop SEP 16) adds the -exponential Adams formulation and its lab-frame source hook, which this backend -implements through its native stage-zero source, on every route but a coupled flow. -Schema 21 (solprop SEP 16 phase 3b) adds the Adams engine hook, through which the -interaction formulation's history stays resident in the native state on the modal -routes. Other Adams runs use the shared host march. Schema 22 (solprop SEP 16 phase 5) -adds the coupled-block hook; a coupled flow stays refused here with an Adams integrator. -Both inherit its no-op atomic reservation hooks; all TDSE descriptors, including the -explicit source-filter identity, remain refused before allocation. Atomic response -ledgers are unsupported here. Schema 28 (solprop SEP 25) adds ``_stage_bundles``. -Schema 29 (solprop SEP 24 phase 1) widens the integrator to the low-storage march and -adds its engine hook and in-flight guard; this backend refuses that march by name -until SEP 24's phase 3. Schema 30 (solprop SEP 26 phase 1) widens the TDSE descriptor -to version 4 and the ``gated_derivative`` source filter; TDSE stays refused here by -name, as every descriptor version is. Schema 31 (solprop SEP 22 phase 2) gives -``observe`` its ``field`` keyword: with ``field=False`` the inherited observation never -calls ``_observed_field``, so no device-to-host copy is made. Schema 32 (solprop SEP 24 -phase 3) adds ``_hold`` and ``_held_field`` and the engine protocol's ``atomic``: this -backend runs the low-storage march with a resident engine (``_low_storage_engine``) -whose step is one native call, and holds the march's field-free plane in its memory -space. Schema 33 (solprop SEP 27) adds the analytic collar derivative and held-current -source identity; its grid member is checked explicitly. Schema 34 (solprop SEP 29 -phase 2) adds the class attribute ``StepDriver.helpers``, through which solprop's -shared stage and Adams code takes this backend's Chebyshev and basis helpers -(``NativeBackend.helpers``); it is checked explicitly too. Schema 35 (solprop SEP 34) -adds ``KernelProblem.flux``, the plane measure every spatial field statistic reads: -this backend measures energy, spectra and local flux by it natively. Schema 36 (solprop -SEP 33) adds ``KernelProblem.precision``: this backend computes in binary64 only and -refuses a single-precision problem by name. Schema 37 (solprop SEP 35) adds -``Multistep.carrier_frame``: CPU builds step the scalar shift in the resident engine; -device builds refuse it by name until they are qualified. Schema 38 adds -``_bind_monitor`` and ``_boundary_energies``: this backend reduces the boundary -monitor's five numbers in its memory space and adds a split-step collar's removal at -every accepted step there, so a check copies five numbers rather than the field. -Schema 39 (solprop SEP 39 phase 2) adds ``_record_attempt`` and ``_skip_step``, the -bookkeeping of one attempt and of an accepted step on which the driver has no work, and -freezes ``_take_step_stats``, ``_natural_stop`` and ``_natural_stop_due``: this -backend's native Adams march runs many attempts per call and books them through these, -with the policy of :data:`MARCH_POLICY_FIELDS` and the march's members of -:data:`ADAMS_MARCH_SURFACE`. Schema 40 gives ``set_initial`` its ``resume`` keyword, -which this backend hands to the driver's load: a stored field never takes the ``input`` -draw again. Schema 41 (solprop SEP 40) adds ``KernelProblem.transverse``, the resolved -transverse boundary, and ``reads_transverse_edge``: this backend carries the safety ramp -and a requested collar in its operators, reads the transverse edge on the host from its -state, and its Adams march holds its steps to the edge's next read and stops there. -Schema 42 lets the problem's initial state be strided, a broadcast view of the block it -repeats: ``set_initial`` takes a field of any strides, which the driver's load copies -contiguous before it crosses. -Schema 43 widens the boundary monitor's reading under the split scheme and adds the -split collar's crossing limit. ``StepDriver`` keeps ``_split_found``, the energy and -the guards' and collar's energies of the power the last step's split found, halfway -through its factor, and caps adaptive steps with ``_split_capped``; -``KernelProblem.split_crossing`` carries each bin's walk-off bucket and the launch's -limit. This backend's finish forms the found sums in its removal pass; -``bind_occupancy`` hands the state the bucket table, ``(n_modes * n_omega,)`` ``uint8``, -and ``monitor_reading`` then returns ten numbers (the energy, four weighted sums, the -removal, the four found sums) followed by the state's energy in each of the -``SPLIT_RATE_BUCKETS`` buckets, ``10 + 128`` in all, where it returned six. The Adams -march's bounds carry a required ``step_cap``, the cap the last check read, which every -adaptive step is held to. -Schema 45 widens this backend's far-field aperture rows from six constants to eleven, -each row's own profile and shape among them, and drops the call's profile code: a -far-field aperture set may hold other outlines beside circles. It also appends -``SeparableOperator.user_rate``, the user's share of the spectral rate, which only the -statistic ``absorbed_power`` reads, in a fifth channel. - -solprop freezes this surface under its ``KERNEL_SCHEMA_VERSION`` (the test in its -``tests/backends/test_driver.py``); :func:`check_build` looks each member up so a -solprop that renamed one is refused by name rather than failing inside a step. +"""Every ``StepDriver`` method this backend overrides or calls. + +solprop freezes the signatures in its ``tests/backends/test_driver.py``, and a change to +one rewrites that table and adds a release note in the same commit. :func:`check_build` +looks each name up, so a solprop that renamed one is refused by name rather than failing +inside a step. What the run layer calls on a backend and this one inherits unchanged +(``propagate_to``, ``observe``, ``set_stop``, ...) is solprop's to keep consistent with +itself, and is not listed. """ +_DRIVER_CLASS_MEMBERS: tuple[str, ...] = ( + "_collar_quiet", + "_monitor", + "_propagator_mode", + "far_field_statistics", + "helpers", + "name", + "reads_transverse_edge", +) +"""The ``StepDriver`` attributes and properties of the class this backend sets or reads, +which :func:`check_build` looks up as it does :data:`DRIVER_SURFACE`; the attributes set +at construction are frozen beside it in solprop's test.""" + MARCH_POLICY_FIELDS: tuple[str, ...] = ( "order", "variable_order", @@ -819,13 +769,6 @@ def _source_digest(root: Path) -> str: paths.extend( path.relative_to(root).as_posix() for path in (root / folder).rglob("*") - digest = _checkout_digest() - if digest is not None and digest != info.source_digest: - raise BackendError( - "the solprop-native extension was built from other sources than this " - f"checkout's native/ ({Path(__file__).parents[2]}): rebuild it with " - "`uv sync --reinstall-package solprop-native`" - ) if path.suffix in {".cpp", ".hpp", ".h"} and path.is_file() ) # Code points sort as their UTF-8 bytes do, so this is CMake's bytewise order. @@ -876,6 +819,13 @@ def check_build() -> None: from solprop.exceptions import BackendError info = build_info() + digest = _checkout_digest() + if digest is not None and digest != info.source_digest: + raise BackendError( + "the solprop-native extension was built from other sources than this " + f"checkout's native/ ({Path(__file__).parents[2]}): rebuild it with " + "`uv sync --reinstall-package solprop-native`" + ) _check_seam(info, BackendError) if info.fast_math_enabled: raise BackendError("solprop-native was built with forbidden fast-math") @@ -957,8 +907,11 @@ def _check_seam(info: BuildInfo, error: type[Exception]) -> None: for name in DRIVER_SURFACE if not callable(getattr(StepDriver, name, None)) ] - if not hasattr(StepDriver, "helpers"): - missing.append("StepDriver.helpers") + missing.extend( + f"StepDriver.{name}" + for name in _DRIVER_CLASS_MEMBERS + if not hasattr(StepDriver, name) + ) missing.extend( f"AdamsMarch.{name}" for name in ADAMS_MARCH_SURFACE diff --git a/native/tests/test_backend.py b/native/tests/test_backend.py index 6955e782..8ad5df18 100644 --- a/native/tests/test_backend.py +++ b/native/tests/test_backend.py @@ -151,6 +151,17 @@ def test_a_solprop_without_a_driver_member_is_refused_by_name( check_build() +def test_the_driver_surface_names_every_method_the_adapter_overrides() -> None: + overridden = { + name + for name, member in vars(NativeBackend).items() + if not name.startswith("__") + and callable(member) + and callable(getattr(StepDriver, name, None)) + } + assert overridden <= set(solprop_native.DRIVER_SURFACE) + + def test_stage_observation_owns_its_host_copy_and_checks_lifetime( phase_a_case: PhaseACase, ) -> None: @@ -445,9 +456,9 @@ def forbidden(*_arguments: object, **_keywords: object) -> NoReturn: @pytest.mark.parametrize( "member", - ["restore_call_ledgers", "_reserve_source_calls", "_release_source_calls"], + ["_adams_block", "_march_to", "_propagator_mode", "reads_transverse_edge"], ) -def test_schema_requires_the_inherited_response_accounting_hooks( +def test_the_build_requires_each_kind_of_driver_member_the_adapter_uses( monkeypatch: pytest.MonkeyPatch, member: str ) -> None: monkeypatch.delattr(StepDriver, member) diff --git a/tests/backends/test_driver.py b/tests/backends/test_driver.py index 318e0807..9bc18811 100644 --- a/tests/backends/test_driver.py +++ b/tests/backends/test_driver.py @@ -947,8 +947,42 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): "set_progress": "(self, reporter: solprop.log.ProgressReporter) -> None", "propagate_to": "(self, z: float) -> solprop.backends.protocol.StepSummary", "available_device_memory": "(self) -> int | None", + # A subclass's and the shared marches' bookkeeping. + "_axis_statistics": ( + "(self, field: jaxtyping.Complex[ndarray, 'n_modes n_pol n_omega'], " + "scan: tuple[numpy.ndarray, numpy.ndarray] | None) -> dict[str, numpy.ndarray]" + ), + "_check_open": "(self) -> None", + "_collar_changed": "(self) -> None", + "_march_to": "(self, target: float) -> None", + "_monitor_plane": "(self) -> None", + "_noise_at": ( + "(self, bundle: solprop.translate.stage.StageScalars) -> " + "jaxtyping.Complex[ndarray, 'n_batch n_modes n_pol n_omega'] | None" + ), + "_poll_stop": "(self) -> None", + "_quiet_capped": "(self, proposal: float) -> float", + "_record": "(self, z: float, dz: float, norm: float, accepted: bool) -> None", + "_refuse_in_flight": "(self) -> None", + "_replay_to": ( + "(self, schedule: solprop.stepper.schedule.FixedSchedule, target: float) -> " + "None" + ), + "_set_collar_quiet": "(self, quiet: bool) -> None", + "_split_capped": "(self, proposal: float) -> float", + "_transverse_capped": "(self, proposal: float) -> float", + # What the run layer calls on every backend (the `Backend` protocol). + "attach_monitor": "(self, monitor: solprop.monitor.BoundaryMonitor) -> None", + "march_export_refusal": "(self) -> str | None", + "march_snapshot": "(self) -> solprop.output.schema.MarchSnapshot | None", + "march_snapshot_nbytes": "(self, z: float) -> int | None", + "restore_march": ( + "(self, snapshot: solprop.output.schema.MarchSnapshot) -> str | None" + ), + "set_stop": "(self, flag: solprop.log.StopFlag | None) -> None", + "timed_step": "(self) -> collections.abc.Callable[[], object]", } -"""Every ``StepDriver`` member a subclass overrides or calls, with its signature. +"""Every ``StepDriver`` method a subclass overrides or calls, or the run layer calls. The literal is the seam a compiled backend is built against: change a signature here only together with a release-note fragment (``AGENTS.md``, "Backends, kernels and hot @@ -970,12 +1004,33 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): "_state", "_noise", "_closed", + "_accepted_norms", + "_accepted_steps", + "_clock", + "_collar_quiet", + "_held_z", + "_history", + "_monitor", + "_natural_count", + "_norm_controller", + "_progress", + "_propagator_mode", + "_proposal", + "_row_period", + "_row_spec", + "_schedule_index", + "_split_monitor", + "closed", + "far_field_statistics", "helpers", + "in_flight", + "name", + "reads_transverse_edge", ) """The attributes a subclass reads or sets, present from construction. -``helpers`` (schema 34) is a class attribute: each backend sets its own record of the -per-stage and per-step evaluations, which the shared code passes down. +``helpers`` is a class attribute: each backend sets its own record of the per-stage and +per-step evaluations, which the shared code passes down. """ SEPARABLE_OPERATOR_FIELDS = ( @@ -1108,6 +1163,11 @@ def test_the_surface_a_backend_subclasses_is_frozen(driver): assert tuple(f.name for f in dataclasses.fields(MarchPolicy)) == MARCH_POLICY_FIELDS +def test_the_native_surface_is_inside_the_frozen_one(): + native = pytest.importorskip("solprop_native") + assert set(native.DRIVER_SURFACE) <= set(DRIVER_SURFACE) + + def test_separable_operator_fields_are_frozen_with_the_driver_seam(): assert tuple(field.name for field in dataclasses.fields(SeparableOperator)) == ( SEPARABLE_OPERATOR_FIELDS From 9817b528a5086b219189fdc54f961d723f6c20c0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:49:03 +0000 Subject: [PATCH 032/122] ADR-066: native is the primary backend numpy_ref remains the oracle for numbers and stays comprehensive, but the order of implementation is free and native may shape the contract; mlx doesn't constrain native. The written rules now say so. The contract and the protected driver surface are the crossings, a SEP lands on the two backends in either order, and backends.md counts the seven marches that exist in place of "one step loop", and says what stays common to them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- AGENTS.md | 23 ++++++++------ docs/dev/backends.md | 35 ++++++++++++++++----- docs/dev/decisions.md | 51 ++++++++++++++++++++++++++++++- docs/dev/kernel-contract.md | 4 +-- docs/dev/seps/index.md | 2 +- docs/dev/seps/sep-0000-process.md | 6 ++-- docs/dev/seps/template.md | 6 ++-- native/AGENTS.md | 11 ++++--- 8 files changed, 107 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 53bff147..0e124e41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,8 +217,8 @@ The long form is [SEP 0](docs/dev/seps/sep-0000-process.md); the index is requests. CI runs on every push to it and need not be green while it is a draft; a push that needs no run has `[skip ci]` in its head commit's message. The branch may start on top of an open pull request's branch when pull requests are stacked behind CI. Within - each phase: `numpy_ref` first with its tests and manual page, then native CPU in - `native/`; `mlx` only where the SEP says so (the backend rules, below). A + each phase: `numpy_ref` and native CPU in `native/`, in either order (ADR-066), with + tests and the manual page; `mlx` only where the SEP says so (the backend rules, below). A backend without the feature refuses it by name, never falls back silently; `Final` needs parity on `numpy_ref` and native CPU, or an exception written into the accepted SEP. @@ -259,7 +259,8 @@ to it; a function that departs from it says so in the first line of its docstrin sentinel where raising is correct. - Respect the layers (`architecture.md §3`): physics code never contains performance tricks; backend code never contains physics knowledge; the kernel contract - (`kernel-contract.md`) is the only crossing. + (`kernel-contract.md`) and the protected driver surface (frozen in + `tests/backends/test_driver.py`) are the crossings. ## Environment and dependencies @@ -317,13 +318,15 @@ no blanket `# noqa`). What is left to judgement: The long form is [`backends.md`](docs/dev/backends.md). -- **Where features land** (ADR-061, ADR-064, `backends.md §10`). `numpy_ref` and `native`: - every new feature lands on both, and they come first for testing and development. - `mlx`: experimental; keep it runnable and correct, add nothing as part of other work. A - backend without a feature refuses it by name. -- The **NumPy reference backend is the oracle**. Every other backend is validated against - it (one linear application, one nonlinear evaluation, one step, eight steps, each - statistic, a full run) with tolerances derived from summation bounds and asserted `> 0`. +- **Where features land** (ADR-061, ADR-064, ADR-066, `backends.md §10`). `native` is the + primary backend. `numpy_ref` and `native`: every new feature lands on both, in either + order, and `native` may shape the contract. `mlx`: experimental; keep it runnable and + correct, add nothing as part of other work. A backend without a feature refuses it by + name. +- The **NumPy reference backend is the oracle** for numbers. Every other backend is + validated against it (one linear application, one nonlinear evaluation, one step, eight + steps, each statistic, a full run) with tolerances derived from summation bounds and + asserted `> 0`. - Hot code lives only under `src/solprop/backends/`, `src/solprop/translate/` and `native/`. - **Native kernels** follow [`native/AGENTS.md`](native/AGENTS.md). The floating-point diff --git a/docs/dev/backends.md b/docs/dev/backends.md index 6aa43810..183a42a8 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -197,9 +197,30 @@ both backends in one process, with every other implementation made to raise afte translation, and a native z-table run of each march with the plain forms forbidden. -**3. One step loop.** The march, the replay, the landing, the controller bookkeeping, the -history, the noise models, **the progress reporter and the statistics channel between -planes** are `backends/driver.py` and are not reimplemented. A backend supplies +**3. One driver, seven marches.** `StepDriver.propagate_to` is the one way in, and it hands +the steps to one of these loops: + +| Loop | Steps | Where its state-sized work runs | +|---|---|---| +| `StepDriver._march_to`, in `backends/driver.py` | adaptive Runge-Kutta | the backend's `_trial_step` | +| `StepDriver._replay_to` | Runge-Kutta on a fixed schedule | the backend's `_trial_step` | +| `AdamsMarch.march_to`, in `backends/multistep.py` | adaptive Adams | an `AdamsEngine`: `HostHistory`, or `native`'s resident `_ResidentAdams` | +| `AdamsMarch._replay_to` | Adams on a fixed schedule | the same engines | +| `LowStorageMarch.replay_to`, in `backends/low_storage.py` | low-storage, always on a schedule | a `LowStorageEngine`: `HostLowStorage`, `MlxLowStorage`, or `native`'s `_ResidentLowStorage`, whose step is one native call | +| `LockstepRunner._replay_to`, in `backends/batch.py` | a batch's members on one schedule | `_trial_step` under Runge-Kutta; each member's own march otherwise | +| `_NativeAdamsMarch.march_to`, with `native/src/adams_march.cpp` | Adams, adaptive and replayed, compiled (ADR-060) | the resident engine; it returns to Python only at a host event | + +Only the last loop is compiled. The driver's Runge-Kutta march is the reference for the march's +bookkeeping. `AdamsMarch` is the reference for the compiled Adams march, which holds to it +through kernel contract §11 (ADR-060), and steps every configuration the compiled march doesn't +cover. A new compiled loop needs a contract that specifies it in full and a reference to compare +it with. + +What stays common, and is never reimplemented, is what the loops do around a step: +`propagate_to` and `observe`, the history (`_record`, or `_record_attempt` and `_skip_step` +for the attempts a compiled march books together), the committed step's work +(`_take_step_stats`: **the statistics channel between planes** and the boundary monitor), the +natural stops, the noise models and **the progress reporter**. A backend supplies `_trial_step`, `_apply_mask` and `stats`, overrides `_finish` if it has boundary treatments, and hooks `_begin_plane` and `_on_accepted_step` if it has something to score. @@ -521,7 +542,7 @@ once-per-run FFTW planning and the output's planes. ### 3.1a What exists The adapter `native/src/solprop_native/backend.py` and the engine `native/src/general.cpp` -over the shared step loop of `backends/driver.py`. The public default is `"auto"`: the +over the shared driver of `backends/driver.py`. The public default is `"auto"`: the native backend, with no fallback (§1, ADR-029). `numpy_ref` stays selectable by name. @@ -757,9 +778,9 @@ Kokkos regions. ### 3.5 The step driver: Python first, compiled when measured -**One loop, for every backend.** `backends/driver.py` is the march, the replay, the -landing, the controller bookkeeping, the history and the noise models, and every backend -inherits it. Per stage a backend asks the translation layer for one bundle of host +**One driver, for every backend.** `backends/driver.py` is the Runge-Kutta march and replay, +the controller bookkeeping, the history and the noise models, and every backend inherits it +(§1, point 3, lists the other marches). Per stage a backend asks the translation layer for one bundle of host scalars and combines the terms it holds over them (§1); the driver applies the boundary treatments of `_finish` to a step it keeps, so a rejected step does not pay the temporal absorber's transform pair for a state that is thrown away. diff --git a/docs/dev/decisions.md b/docs/dev/decisions.md index 1e9d465a..43608291 100644 --- a/docs/dev/decisions.md +++ b/docs/dev/decisions.md @@ -1650,7 +1650,8 @@ Withdrawn (development-process record, not a design decision). - **Status:** accepted (maintainer, 2026-10-31); amends [SEP 0](seps/sep-0000-process.md)'s completion rule, which asked for every feature on `numpy_ref`, `numba_cpu` and the native CPU build; Tier 2 and the statement that the - documentation build installs no native backend are superseded by ADR-064 + documentation build installs no native backend are superseded by ADR-064; ADR-066 makes + `native` the primary backend - **Context:** solprop has four backends, and each new feature was implemented three times before its SEP could be final: in the reference, in Numba and in C++. The native backend is where production runs happen; `numba_cpu` runs the same CPU problems more @@ -1854,3 +1855,51 @@ Withdrawn (development-process record, not a design decision). - A run keeping its planes in memory can run out partway through, and a general modal guide fails while its tables are built. Both were previously refused or warned about before they started. + +## ADR-066 — Native is the primary backend; `numpy_ref` stays the oracle for numbers + +- **Status:** accepted (maintainer), 2026-11-04. Amends ADR-061 and + [SEP 0](seps/sep-0000-process.md)'s implementation rules. Those records, and the SEPs + implemented under them, stay as written. +- **Context:** production runs on `native`, and the costs of the split between Python and + native decide what those runs can do. The written rules still steered work as if the + reference came first: a SEP was implemented on `numpy_ref` and then on native, the kernel + contract was the only crossing, and there was one step loop. None of those describes the + code. There are seven marches (`backends.md` §1), and the protected driver surface is as + much a crossing as the contract. `KERNEL_SCHEMA_VERSION` had 14 literal sites in 10 files, + moved 11 times in 12 days, collided 3 times between parallel pull requests, and missed a + change made only in C++. +- **Decision:** + - `native` is the primary backend. `numpy_ref` remains the oracle for numbers and stays + comprehensive: every other backend is validated against it, and every new feature lands + on it. The order of implementation is free, and native may shape the contract. + - `numba_cpu` is gone (ADR-064) and constrains nothing. `mlx` doesn't constrain native + either: it stays as it is, kept runnable and correct, and its future is undecided. + - The kernel contract and the protected surface of `StepDriver` are the two crossings. A + backend that lacks a feature still refuses it by name, whichever backend that is, and + nothing falls back silently. + - The seam has no version number. A change to it rewrites the frozen tables in + `tests/backends/test_driver.py` in the same commit and adds a release-note fragment. The + two packages are released together and pinned to one version. `check_build` looks up the + members the adapter relies on by name. In an editable checkout it also compares the + extension with the digest of what it is compiled from (`native/CMakeLists.txt` and the C++ + sources and headers under `native/src` and `native/include`), which CMake embeds and + Python recomputes once per process. `KERNEL_SCHEMA_VERSION` ended at 45. +- **Why:** development time goes where the runs are, and correctness is still decided + against the reference. A number that changes with every seam change costs a merge conflict + each time and guards little: within one repository at one pinned version, the frozen tables + catch a seam change in the commit that makes it, and the digest catches the case that + mattered at run time, a stale extension beside newer sources. +- **Consequences:** + - A SEP may land on native first, and the manual page comes with whichever lands first. A + SEP is still `Final` only with parity on `numpy_ref` and native CPU, or an exception + written into it. + - A native run's record names `source_digest` in `backend.settings` where it named + `schema_version`. Neither enters the configuration hash or a resume check. + - An editable checkout refuses to construct a native backend after any edit to the digested + sources until it is rebuilt, including a comment, so a C++ change can't be tested by + accident against an old build. Python under `native/src/solprop_native` is not digested, + since it isn't compiled. + - A wheel skips the comparison, and has only the pin and the name lookup between it and a + mismatched solprop. `poe fetch-wheel` keeps its own check on the tree. + - "Kernel schema N" in the code and the documents is history: the number a change took. diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md index cabe44e8..c63eeedf 100644 --- a/docs/dev/kernel-contract.md +++ b/docs/dev/kernel-contract.md @@ -1137,8 +1137,8 @@ the step history and over the same interval, and a row may not index past it. 2. Add the row here, rewrite the frozen tables the change moves (`tests/backends/test_driver.py`) and add a release-note fragment. 3. Extend `validate()`. -4. Implement in `numpy_ref` first, with a test; then in the other backends, with the - equivalence test. +4. Implement in `numpy_ref` and `native`, in either order (ADR-066), with a test and the + equivalence test; a backend that does not have it yet refuses it by name. 5. Update the memory budget in [architecture.md §8](architecture.md) if a buffer is added. ## 11. The Adams march diff --git a/docs/dev/seps/index.md b/docs/dev/seps/index.md index fa76b40c..72b425f2 100644 --- a/docs/dev/seps/index.md +++ b/docs/dev/seps/index.md @@ -4,7 +4,7 @@ A solprop Enhancement Proposal (SEP) is the design document for one major change model, route or numerical scheme, a changed default or convention, a redesigned interface. It sets out the motivation, the theory with its references, the implementation options and how the result will be verified; once accepted it is implemented in phases, -`numpy_ref` first, then the native backend, and each phase appends its +on `numpy_ref` and the native backend in either order, and each phase appends its report to the SEP. Finished SEPs are archived and linked from the manual pages they produced. [SEP 0](sep-0000-process.md) describes the process and says when a SEP is needed; new SEPs start from [the template](template.md). The [roadmap](../roadmap.md) diff --git a/docs/dev/seps/sep-0000-process.md b/docs/dev/seps/sep-0000-process.md index d77e91cd..72b70379 100644 --- a/docs/dev/seps/sep-0000-process.md +++ b/docs/dev/seps/sep-0000-process.md @@ -283,8 +283,10 @@ and compares options; it is not a line-by-line plan, which belongs to the implem ### Implementation rules -- **Backends in order.** A feature is implemented first on `numpy_ref`, the oracle, with - its tests and its manual page; then on the native CPU build in `native/`. Device (GPU) +- **Backends in either order** ([ADR-066](../decisions.md)). A feature is implemented on + `numpy_ref`, the oracle for numbers, and on the native CPU build in `native/`, each with + its tests, and the manual page with whichever lands first; `native` may shape the + contract. Device (GPU) qualification follows the standing policy of [backends.md](../backends.md), later and in one pass. `mlx` (Tier 3) takes the feature only where the SEP says so ([ADR-061](../decisions.md)); the SEP weighs who would run it there. diff --git a/docs/dev/seps/template.md b/docs/dev/seps/template.md index 4632b37d..36ce2e03 100644 --- a/docs/dev/seps/template.md +++ b/docs/dev/seps/template.md @@ -104,9 +104,9 @@ differences page.* ### 11. Phases -*The proposed phases, each independently mergeable with a green gate. The usual order is -numpy_ref with tests and the manual page first, then the native CPU build. For each -phase: its deliverables and what it must verify.* +*The proposed phases, each independently mergeable with a green gate. numpy_ref and the +native CPU build come in either order, each with tests, and the manual page with whichever +lands first. For each phase: its deliverables and what it must verify.* ### 12. Open questions diff --git a/native/AGENTS.md b/native/AGENTS.md index 64ef34a9..e0e45495 100644 --- a/native/AGENTS.md +++ b/native/AGENTS.md @@ -50,11 +50,12 @@ solprop's ADR-059), nanobind and scikit-build-core; GoogleTest is test-only. Adding any other dependency needs approval. Major changes follow solprop's enhancement proposal process (SEPs, -[`docs/dev/seps/`](../docs/dev/seps/), SEP 0). A SEP is implemented `numpy_ref` first, -then here. A phase that touches both halves is one branch named for -the SEP (`feature/sep-NNNN-…`) and one pull request, whose commits cite the SEP number; -its phase report — deviations, decisions, verification, open questions — is appended to -the SEP. Until the native phase lands, the backend refuses the new problem by name. +[`docs/dev/seps/`](../docs/dev/seps/), SEP 0). A SEP is implemented here and on `numpy_ref` +in either order (solprop's ADR-066). A phase that touches both halves is one branch named +for the SEP (`feature/sep-NNNN-…`) and one pull request, whose commits cite the SEP +number; its phase report — deviations, decisions, verification, open questions — is +appended to the SEP. Until a backend has its phase, that backend refuses the new problem +by name. ## Scope From a112bb4e0947d9992fea8aa1090f91190d713131 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:51:16 +0000 Subject: [PATCH 033/122] Clear stale statements of a kernel schema; say what closed returns Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/backends.md | 2 +- native/src/solprop_native/backend.py | 2 +- src/solprop/backends/driver.py | 2 +- src/solprop/backends/numpy_ref/general_modal.py | 4 ++-- src/solprop/backends/protocol.py | 2 +- tests/backends/test_registry.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/dev/backends.md b/docs/dev/backends.md index 183a42a8..10566cdf 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -485,7 +485,7 @@ complex admittance before forming `Re(E conj(G))/2`, signed and not clipped. The refuse a fluence negative beyond its rounding budget. The helper `backends.spatial.collar_field_fraction` stays an electric-field-norm share and is named as one; its end-of-plane log is retired, and the boundary monitor's transverse edge -(SEP 40) judges the boundary instead. A backend whose own statistics do not apply this measure refuses the schema. +(SEP 40) judges the boundary instead. A backend whose own statistics do not apply this measure refuses the problem. **The transverse edge is read on the host** ([SEP 40](seps/archive/sep-0040-transverse-absorbing-boundaries.md)). A backend whose `StepDriver.reads_transverse_edge` is set hands the monitor's diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 6f0aafa4..a3b8b067 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -260,7 +260,7 @@ class NativeBackend(StepDriver[_core.State]): Parameters ---------- problem - Validated schema-22 kernel problem. Accepts one batch member, + Validated kernel problem. Accepts one batch member, modal or spatial scalar or two-component propagation (radial, Cartesian, or the hybrid planar kind of slab modes in x times an FFT in y), instantaneous Kerr, plasma, scalar time-domain Raman, weighted control, both absorber diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index be9df85b..b6b79864 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -2695,7 +2695,7 @@ def closed(self) -> bool: Returns ------- bool - Whether it can run another input. + ``True`` when it can run no other input. """ return self._closed diff --git a/src/solprop/backends/numpy_ref/general_modal.py b/src/solprop/backends/numpy_ref/general_modal.py index 17cb6b90..b95c4041 100644 --- a/src/solprop/backends/numpy_ref/general_modal.py +++ b/src/solprop/backends/numpy_ref/general_modal.py @@ -483,8 +483,8 @@ def node_kerr_form(regional: RegionalResponseTables | None) -> KerrForm: The form is a physical-model choice and travels in ``model_id``, which is a representation tag inside the resume identity already; it is **not** a leaf - field, because adding one would move a contract row and bump the kernel schema - for a string the record can carry. + field, because adding one would move a contract row for a string the record can + carry. Parameters ---------- diff --git a/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py index 53b861a2..d9011b9f 100644 --- a/src/solprop/backends/protocol.py +++ b/src/solprop/backends/protocol.py @@ -909,7 +909,7 @@ def closed(self) -> bool: Returns ------- bool - Whether it can run another input. + ``True`` when it can run no other input. """ ... diff --git a/tests/backends/test_registry.py b/tests/backends/test_registry.py index 0af0f790..a4e33c54 100644 --- a/tests/backends/test_registry.py +++ b/tests/backends/test_registry.py @@ -417,7 +417,7 @@ def construct(name, problem, **context): def test_auto_raises_other_native_failures_above_the_baseline( linear_problem, narrow_grid, monkeypatch, tmp_path, error ): - """A refusal at import, a schema mismatch or a broken extension still raise.""" + """A refusal at import, a stale build or a broken extension still raise.""" install_native(monkeypatch, tmp_path, "met") calls = [] From ec6d6e93cac125fdba9a76cd5ad2aa3d708267f3 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 14:55:07 +0000 Subject: [PATCH 034/122] Say in the contract and backends.md section 10 what ADR-066 changed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/backends.md | 5 +++-- docs/dev/kernel-contract.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/dev/backends.md b/docs/dev/backends.md index 10566cdf..d0ac2206 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -1873,8 +1873,9 @@ plasma do not cover it. The far-field statistics' device pass is not qualified ( These rules set where development goes, not what a backend may run (ADR-061; ADR-064 retired `numba_cpu` and superseded the statement that the documentation build installs no native -backend). Every backend keeps what it runs, correct and in parity with the reference, and -refuses the rest by name. +backend; ADR-066 makes `native` the primary backend). A feature lands on `native` and +`numpy_ref` in either order. Every backend keeps what it runs, correct and in parity with the +reference, and refuses the rest by name. | backend | role | new features | |---|---|---| diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md index c63eeedf..b3d459a6 100644 --- a/docs/dev/kernel-contract.md +++ b/docs/dev/kernel-contract.md @@ -2,7 +2,7 @@ `solprop.translate.problem.KernelProblem` is the only object that crosses from the physics and translation layers into a backend. It is a frozen dataclass of plain NumPy arrays and scalars, -validated, versioned, and documented here field by field. A backend may assume everything +validated, and documented here field by field. A backend may assume everything stated here and nothing else; the translation layer promises everything stated here and nothing else. Changing this file and the dataclass together is the only way to extend the contract, and a test checks that the fields listed here and the fields of the dataclass agree. From 4c35178cbbafde7ce968d0a5b0e628069c537cc6 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:03:18 +0000 Subject: [PATCH 035/122] Digest an array not held in its order block by block A broadcast input no longer becomes a whole volume to be digested, and a byte-swapped one no longer takes a copy. Every digest is as it was, which the record tests hold. Closes #493. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/output/schema.py | 34 +++++++++++++++++++------ tests/output/test_field_retention.py | 37 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py index c25420a1..01cea9f1 100644 --- a/src/solprop/output/schema.py +++ b/src/solprop/output/schema.py @@ -458,6 +458,10 @@ def serialise(value: object) -> JsonValue: return _serialise_object(value) +_DIGEST_BLOCK = 2**16 +"""Elements a digest of an array not held in its order reads at a time.""" + + @dataclass(frozen=True) class ArrayDigest: """An array recorded by the digest of its contents rather than inline. @@ -497,8 +501,9 @@ def of(cls, values: np.ndarray) -> "ArrayDigest": # noqa: UP037 - bound after t Parameters ---------- values : numpy.ndarray - The array; read once, in little-endian byte order, without a copy when it - is already contiguous and little-endian. + The array; read once, in C order and little-endian byte order, a block at + a time unless it is held that way already, so that a broadcast input never + becomes a whole volume. Returns ------- @@ -506,12 +511,27 @@ def of(cls, values: np.ndarray) -> "ArrayDigest": # noqa: UP037 - bound after t The digest, the dtype and the shape. """ array = np.asarray(values) - # One byte order, so that the same numbers digest alike on every platform; a - # copy only where the array is not contiguous in it already. - ordered = np.ascontiguousarray(array, dtype=array.dtype.newbyteorder("<")) + # One byte order, so that the same numbers digest alike on every platform. + little = array.dtype.newbyteorder("<") + digest = hashlib.sha256() + if array.size == 0: + pass + elif array.flags.c_contiguous and array.dtype == little: + digest.update(memoryview(array).cast("B")) + else: + for block in np.nditer( + array, + flags=["external_loop", "buffered"], + op_flags=[["readonly", "contig"]], + op_dtypes=[little], + casting="equiv", + order="C", + buffersize=_DIGEST_BLOCK, + ): + digest.update(np.asarray(block).tobytes()) return cls( - sha256=hashlib.sha256(memoryview(ordered).cast("B")).hexdigest(), - dtype=str(ordered.dtype), + sha256=digest.hexdigest(), + dtype=str(little), shape=tuple(int(size) for size in array.shape), ) diff --git a/tests/output/test_field_retention.py b/tests/output/test_field_retention.py index 1bb669d4..be6cd678 100644 --- a/tests/output/test_field_retention.py +++ b/tests/output/test_field_retention.py @@ -10,6 +10,7 @@ import dataclasses import json +import tracemalloc from typing import cast import h5py @@ -327,6 +328,42 @@ def test_a_field_free_file_resumes_only_from_its_checkpoint(tmp_path, metadata): # ----------------------------------------------------------- record helpers +@pytest.mark.parametrize( + "view", + [ + lambda a: a.astype(">c16"), + np.asfortranarray, + lambda a: np.repeat(a, 2, axis=0)[::2], + lambda a: a[::-1, ::-1, ::-1][::-1, ::-1, ::-1], + ], + ids=["byte_swapped", "fortran", "strided", "reversed_twice"], +) +def test_the_same_numbers_digest_alike_however_they_are_held(view): + numbers = np.arange(5 * 3 * 7, dtype=np.complex128).reshape(5, 3, 7) + assert ArrayDigest.of(view(numbers)) == ArrayDigest.of(numbers) + + +@pytest.mark.parametrize("shape", [(0,), (3, 0, 2), ()], ids=["empty", "hollow", "0d"]) +def test_an_array_without_a_contiguous_copy_digests_at_any_shape(shape): + numbers = np.ones(shape, dtype=np.float64) + assert ArrayDigest.of(numbers.astype(">f8")) == ArrayDigest.of(numbers) + + +def test_a_broadcast_input_digests_without_a_volume(): + # A simulation holds a broadcast placeholder as one repeated block: its digest is + # that of the whole volume, and its peak is a buffer's, not the volume's. + shape = (4096, 1, 1024) + view = np.broadcast_to(np.zeros((1, 1, 1), dtype=np.complex128), shape) + tracemalloc.start() + try: + digest = ArrayDigest.of(view) + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + assert digest == ArrayDigest.of(np.zeros(shape, dtype=np.complex128)) + assert peak < 16 * 2**20 < 16 * int(np.prod(shape)) + + def test_an_array_above_the_inline_limit_is_recorded_by_its_digest(): small = np.zeros(4) assert recorded_input(small) is small From e20e28228ced790bc3ac278cc4a839ea4e56e19a Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:03:18 +0000 Subject: [PATCH 036/122] Form the transverse residual from exact squares and math.fsum The same bits as the rational sum, which a test holds, without a Fraction per node. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/spatial.py | 106 ++++++++++++++++++++++++++---- tests/test_cartesian_precision.py | 41 +++++++++++- 2 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/solprop/spatial.py b/src/solprop/spatial.py index 84e67fd6..b849c12c 100644 --- a/src/solprop/spatial.py +++ b/src/solprop/spatial.py @@ -25,6 +25,7 @@ from numpy.typing import ArrayLike from solprop import fourier +from solprop._process import parallel_rows from solprop.constants import EPS0, ROOM_TEMPERATURE, C from solprop.exceptions import GridError, MaterialError, OutputError from solprop.grid import FreeGrid, LineGrid, RadialGrid, TimeGrid @@ -519,19 +520,7 @@ def cartesian_squares( Inactive material bins are zero. No spatial-frequency table is built. """ square, low = self.material_squares(z) - x_squared = [Fraction(float(value)) ** 2 for value in space.k_x] - y_squared = [Fraction(float(value)) ** 2 for value in space.k_y] - transverse_low = np.array( - [ - float(x + y - Fraction(float(high))) - for (x, y), high in zip( - ((x, y) for y in y_squared for x in x_squared), - space.kperp_sq, - strict=True, - ) - ] - ) - return square, low, transverse_low + return square, low, _transverse_residual(space) def material_squares( self, z: float = 0.0 @@ -918,6 +907,26 @@ def admittance( columns, ) + def whole_admittance(self) -> Complex[np.ndarray, "n_modes n_omega"]: + """Return every column's admittance, its chunks formed in threads. + + Returns + ------- + numpy.ndarray + :meth:`admittance` of every column, shape ``(n_modes, n_omega)``. + """ + chunks = self.chunks() + admittance = np.empty( + (self.space.spectral_weights.size, self.grid.n_omega), dtype=np.complex128 + ) + + def work(rows: slice) -> None: + for columns in chunks[rows]: + admittance[columns] = self.admittance(columns) + + parallel_rows(len(chunks), admittance.nbytes, work) + return admittance + def chunks(self) -> list[slice]: """Return column slices bounding an integrated sum's working memory. @@ -934,6 +943,77 @@ def chunks(self) -> list[slice]: ] +def _exact_squares( + values: Float[np.ndarray, " n"], +) -> tuple[Float[np.ndarray, " n"], Float[np.ndarray, " n"]]: + """Return each value's square as the sum of two floats, exactly (Dekker, Veltkamp). + + Parameters + ---------- + values : numpy.ndarray + Each zero or of magnitude within ``[2**-450, 2**500]``, where the split can't + overflow and the low part can't underflow. + + Returns + ------- + tuple of numpy.ndarray + The rounded squares and their exact residuals. + """ + split = 134217729.0 * values # 2**27 + 1 + upper = split - (split - values) + lower = values - upper + high = values * values + return high, ((upper * upper - high) + 2.0 * upper * lower) + lower * lower + + +def _transverse_residual(space: FreeGrid) -> Float[np.ndarray, " n_modes"]: + """Return ``k_x**2 + k_y**2 - kperp_sq`` per column, rounded from its exact value. + + ``math.fsum`` rounds the exact sum of its floats, as a rational sum would. + + Parameters + ---------- + space : solprop.grid.FreeGrid + Stored unsquared transverse axes in rad/m, with x fastest. + + Returns + ------- + numpy.ndarray + The real residuals in m^-2. + """ + k_x, k_y = np.asarray(space.k_x), np.asarray(space.k_y) + moduli = np.abs(np.concatenate((k_x, k_y))) + moduli = moduli[moduli != 0.0] + if moduli.size and not (moduli.min() >= 2.0**-450 and moduli.max() <= 2.0**500): + x_squared = [Fraction(float(value)) ** 2 for value in k_x] + y_squared = [Fraction(float(value)) ** 2 for value in k_y] + return np.array( + [ + float(x + y - Fraction(float(high))) + for (x, y), high in zip( + ((x, y) for y in y_squared for x in x_squared), + space.kperp_sq, + strict=True, + ) + ] + ) + x_parts = list(zip(*map(np.ndarray.tolist, _exact_squares(k_x)), strict=True)) + y_parts = list(zip(*map(np.ndarray.tolist, _exact_squares(k_y)), strict=True)) + fsum = math.fsum + return np.fromiter( + ( + fsum((y_high, y_low, x_high, x_low, -high)) + for ((y_high, y_low), (x_high, x_low)), high in zip( + ((y, x) for y in y_parts for x in x_parts), + np.asarray(space.kperp_sq).tolist(), + strict=True, + ) + ), + dtype=np.float64, + count=k_x.size * k_y.size, + ) + + def _check_flux( spectrum: np.ndarray, flux: SpatialFlux, *, planar: bool | None = None ) -> None: diff --git a/tests/test_cartesian_precision.py b/tests/test_cartesian_precision.py index 855150f8..98887f0c 100644 --- a/tests/test_cartesian_precision.py +++ b/tests/test_cartesian_precision.py @@ -4,6 +4,8 @@ """Cartesian cutoff certificates from an independent decimal oracle.""" +from fractions import Fraction + import numpy as np import pytest @@ -11,7 +13,12 @@ from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid from solprop.linear import Frame from solprop.pulses import GaussianPulse, SpatialPulse -from solprop.spatial import SpatialDispersion, SpatialMedium +from solprop.spatial import ( + SpatialDispersion, + SpatialMedium, + _exact_squares, + _transverse_residual, +) from solprop.translate.free import free_operators from solprop.translate.spatial import separable_values from spatial_records import record_metric @@ -135,3 +142,35 @@ def test_cartesian_launch_uses_the_corrected_exact_linear_operator(): rtol=0, atol=(8 * eps + phase_error + normalisation) * float(np.max(np.abs(start))), ) + + +@pytest.mark.parametrize( + ("half_x", "n_x", "half_y", "n_y"), + [(3e-7, 9, 4e-7, 12), (2.3e-4, 64, 7.1e-5, 48), (1.0, 2, 1e-3, 33)], +) +def test_the_transverse_residual_is_the_rational_ones_bit_for_bit( + half_x, n_x, half_y, n_y +): + # The rational sum is the definition; `math.fsum` of exact squares is its fast form. + space = FreeGrid(half_x, n_x, half_y, n_y, collar_fraction=0) + x_squared = [Fraction(float(value)) ** 2 for value in space.k_x] + y_squared = [Fraction(float(value)) ** 2 for value in space.k_y] + expected = np.array( + [ + float(x + y - Fraction(float(high))) + for (x, y), high in zip( + ((x, y) for y in y_squared for x in x_squared), + space.kperp_sq, + strict=True, + ) + ] + ) + assert _transverse_residual(space).tobytes() == expected.tobytes() + + +def test_the_exact_squares_are_exact(): + rng = np.random.default_rng(61) + values = rng.standard_normal(4096) * 10.0 ** rng.integers(-100, 100, 4096) + high, low = _exact_squares(values) + for value, a, b in zip(values.tolist(), high.tolist(), low.tolist(), strict=True): + assert Fraction(a) + Fraction(b) == Fraction(value) ** 2 From 98e450fa9b703c5cf9a221f578c0c47a9069fe4f Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:03:19 +0000 Subject: [PATCH 037/122] Build a large launch in threads, with its admittance formed once The passes over a beam's elements ran on one thread, and the admittance ran twice. The sum that scales the field stays one sum in one order, so every bit of the launch is as it was, which a test holds across thread counts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/boundaries.py | 25 +++++--- src/solprop/pulses.py | 127 ++++++++++++++++++++++++++++++-------- tests/test_pulses.py | 29 ++++++++- 3 files changed, 147 insertions(+), 34 deletions(-) diff --git a/src/solprop/boundaries.py b/src/solprop/boundaries.py index f542430b..b0211dbc 100644 --- a/src/solprop/boundaries.py +++ b/src/solprop/boundaries.py @@ -77,6 +77,7 @@ import numpy as np from jaxtyping import Bool, Complex, Float, Inexact, UInt8 +from solprop._process import parallel_rows from solprop.exceptions import GridError, SolpropWarning from solprop.grid import Collar, TimeGrid from solprop.spatial_absorber import SpatialAbsorber, SpatialAbsorberDesign @@ -1086,15 +1087,25 @@ def occupancy( Shape ``(SPLIT_RATE_BUCKETS,)``, in the state's units of energy. """ # In the state's own precision, without a state-sized temporary: the shares - # are what the limit reads. + # are what the limit reads, so the order of summing is free. spectrum = np.asarray(field_spectrum) re, im = np.real(spectrum), np.imag(spectrum) - energy = np.einsum("bmpw,bmpw->mw", re, re) + np.einsum("bmpw,bmpw->mw", im, im) - if columns is not None: - energy = energy * np.asarray(columns, dtype=np.float64)[:, None] - return np.bincount( - self.buckets.ravel(), weights=energy.ravel(), minlength=SPLIT_RATE_BUCKETS - ).astype(np.float64) + weights = None if columns is None else np.asarray(columns, dtype=np.float64) + + def block(rows: slice) -> Float[np.ndarray, " n_buckets"]: + """Return a block of modes' energy per bucket.""" + energy = np.einsum("bmpw,bmpw->mw", re[:, rows], re[:, rows]) + np.einsum( + "bmpw,bmpw->mw", im[:, rows], im[:, rows] + ) + if weights is not None: + energy = energy * weights[rows, None] + return np.bincount( + self.buckets[rows].ravel(), + weights=energy.ravel(), + minlength=SPLIT_RATE_BUCKETS, + ).astype(np.float64) + + return np.sum(parallel_rows(spectrum.shape[1], spectrum.nbytes, block), axis=0) def limit(self, occupancy: Float[np.ndarray, " n_buckets"]) -> float: """Return the crossing limit of an occupancy, m. diff --git a/src/solprop/pulses.py b/src/solprop/pulses.py index 85261e5a..33847785 100644 --- a/src/solprop/pulses.py +++ b/src/solprop/pulses.py @@ -43,6 +43,7 @@ from solprop import fourier from solprop._display import Displayed, engineering, line, phase_text, summarise +from solprop._process import parallel_rows from solprop.constants import EPS0, C from solprop.exceptions import PulseError, SolpropWarning from solprop.grid import FreeGrid, LineGrid, RadialGrid, TimeGrid @@ -350,9 +351,8 @@ def build( else np.fft.fft2(profile.reshape(space.n_y, space.n_x)).ravel() ) jones = _unit_jones(self.jones) - live = np.concatenate( - [flux.admittance(columns) != 0 for columns in flux.chunks()] - ) + admittance = flux.whole_admittance() + live = admittance != 0 exponent = None if self.propz != 0: working_frame = Frame.comoving(grid) if frame is None else frame @@ -368,10 +368,8 @@ def build( msg = "SpatialPulse requires a scalar single-column temporal pulse" raise PulseError(msg) # A unit-peak profile keeps intermediate values near the final scale. - field = ( - spectrum[:, None, None] - * fourier.to_internal(jones)[None, :, None] - * temporal + field = _separable_field( + spectrum, fourier.to_internal(jones), temporal, grid.n_omega ) if exponent is not None: if np.max(np.real(exponent)) > math.log(np.finfo(float).max) - math.log( @@ -383,7 +381,8 @@ def build( if aligner is not None: field = aligner.apply(field) field = _hard_masked(field, live, space.spectral_weights, grid) - total += _normalised(member, temporal, field, flux, grid) + field = _normalised(member, temporal, field, flux, admittance, grid) + _add_rows(total, field) # Each member is a finite field scaled by the finite factor of a finite, # positive measured flux (_normalising_factor refuses the rest), so the sum # is finite. @@ -1139,7 +1138,7 @@ def _hard_masked( Returns ------- numpy.ndarray - The masked beam. + The masked beam: `field`, masked in place. Raises ------ @@ -1147,9 +1146,17 @@ def _hard_masked( If the nonpropagating share of the field norm exceeds its rounding budget. """ band = grid.active_mask & (grid.omega > 0) - norm = weights[:, None] * np.sum(np.abs(field) ** 2, axis=1) - removed = float(np.sum(norm[band[None, :] & ~live])) - total = float(np.sum(norm[:, band])) + outside = band[None, :] & ~live + + def shares(rows: slice) -> tuple[float, float]: + """Return a block's norm in the columns that don't propagate, and in all.""" + norm = weights[rows, None] * np.sum(np.abs(field[rows]) ** 2, axis=1) + return float(np.sum(norm[outside[rows]])), float(np.sum(norm[:, band])) + + # Judged against a budget of rounding, so that the blocks' order of summing is free. + parts = parallel_rows(field.shape[0], field.nbytes, shares) + removed = math.fsum(part[0] for part in parts) + total = math.fsum(part[1] for part in parts) # The transforms that formed the beam carry a few rounding units of its norm # per bin they sum; content above that is physical. budget = 8 * field.shape[0] * grid.n_omega * np.finfo(float).eps @@ -1160,7 +1167,62 @@ def _hard_masked( "widen the waist or refine the transverse grid" ) raise PulseError(msg) - return field * live[:, None, :] + + def mask(rows: slice) -> None: + """Zero one block's dead bins.""" + field[rows] *= live[rows, None, :] + + parallel_rows(field.shape[0], field.nbytes, mask) + return field + + +def _separable_field( + spectrum: np.ndarray, jones: np.ndarray, temporal: np.ndarray, n_omega: int +) -> np.ndarray: + """Return ``spectrum * jones * temporal`` in a new array, its rows in threads. + + Parameters + ---------- + spectrum : numpy.ndarray + The beam's transverse spectrum, ``(n_modes,)``. + jones : numpy.ndarray + Its polarisation, ``(n_pol,)``. + temporal : numpy.ndarray + Its temporal spectrum, ``(1, 1, n_omega)``. + n_omega : int + The bins. + + Returns + ------- + numpy.ndarray + The field, ``(n_modes, n_pol, n_omega)``. + """ + field = np.empty((spectrum.size, jones.size, n_omega), dtype=np.complex128) + + def work(rows: slice) -> None: + """Form one block, whose first touch is then this thread's.""" + field[rows] = spectrum[rows, None, None] * jones[None, :, None] * temporal + + parallel_rows(spectrum.size, field.nbytes, work) + return field + + +def _add_rows(total: np.ndarray, part: np.ndarray) -> None: + """Add `part` to `total` in place, row by row, in threads. + + Parameters + ---------- + total : numpy.ndarray + The sum so far. + part : numpy.ndarray + What to add, of the same shape. + """ + + def work(rows: slice) -> None: + """Add one block.""" + total[rows] += part[rows] + + parallel_rows(total.shape[0], total.nbytes, work) def _members(pulse: Pulse) -> list[Pulse]: @@ -1243,15 +1305,20 @@ def _normalising_factor( quantity, target = _target(pulse, temporal, grid) if quantity == "energy": energy_weights = fourier.energy_weights(grid.n_omega, grid.dt, kind=grid.kind) - density = np.sum(np.abs(beam) ** 2, axis=1) - measured = plane.scale * float( - np.sum( - plane.weights[:, None] + terms = np.empty((beam.shape[0], beam.shape[2])) + + def work(rows: slice) -> None: + """Form one block of the flux's terms.""" + terms[rows] = ( + plane.weights[rows, None] * energy_weights - * np.real(plane.admittance) - * density + * np.real(plane.admittance[rows]) + * np.sum(np.abs(beam[rows]) ** 2, axis=1) ) - ) + + parallel_rows(beam.shape[0], beam.nbytes, work) + # One sum over the whole array, in one order: this factor scales the field. + measured = plane.scale * float(np.sum(terms)) else: measured = float( np.max( @@ -1275,9 +1342,10 @@ def _normalised( temporal: np.ndarray, field: np.ndarray, flux: SpatialFlux, + admittance: np.ndarray, grid: TimeGrid, ) -> np.ndarray: - """Scale a spatial beam to its requested physical energy or peak power. + """Scale a spatial beam in place to its requested physical energy or peak power. Parameters ---------- @@ -1286,9 +1354,11 @@ def _normalised( temporal : numpy.ndarray Its built spectrum, shape ``(1, 1, n_omega)``. field : numpy.ndarray - The unnormalised beam spectrum, ``(n_modes, n_pol, n_omega)``. + The unnormalised beam spectrum, ``(n_modes, n_pol, n_omega)``, scaled in place. flux : solprop.spatial.SpatialFlux The launch plane's measure. + admittance : numpy.ndarray + Its admittance, ``(n_modes, n_omega)``. grid : solprop.grid.TimeGrid Temporal grid. @@ -1299,12 +1369,17 @@ def _normalised( """ plane = _Plane( weights=flux.space.spectral_weights, - admittance=np.concatenate( - [flux.admittance(columns) for columns in flux.chunks()] - ), + admittance=admittance, scale=flux.energy_scale, ) - return field * _normalising_factor(pulse, temporal, field, plane, grid) + factor = _normalising_factor(pulse, temporal, field, plane, grid) + + def scale(rows: slice) -> None: + """Scale one block.""" + field[rows] *= factor + + parallel_rows(field.shape[0], field.nbytes, scale) + return field def _frozen_phase(phase: Sequence[float] | np.ndarray) -> Float[np.ndarray, " n_deg"]: diff --git a/tests/test_pulses.py b/tests/test_pulses.py index 02815215..e01805e2 100644 --- a/tests/test_pulses.py +++ b/tests/test_pulses.py @@ -15,7 +15,7 @@ from hypothesis import strategies as st from numpy.testing import assert_allclose -from solprop import fourier +from solprop import _process, fourier from solprop.constants import C from solprop.exceptions import PulseError from solprop.grid import EnvelopeGrid, FieldGrid, TimeGrid @@ -1292,3 +1292,30 @@ def test_energy_and_arrival_hold_for_any_pulse(fwhm, energy, gdd, t0, kind): # a chirped pulse is a ratio of two sums with cancellation, and the strategies allow # a stretch factor of about seven, which brings the tails towards the window edge. assert_allclose(centroid(spectrum, grid), t0, rtol=0.0, atol=0.1 * grid.dt) + + +@pytest.mark.parametrize("workers", [2, 7]) +@pytest.mark.parametrize("jones", [(1,), (1, 0.4j)], ids=["one", "two"]) +def test_a_large_launch_is_the_same_on_any_number_of_threads( + workers, jones, monkeypatch +): + # The passes over a large launch take threads, one block of rows each. The sum that + # scales the field stays one sum in one order, so no bit may depend on the blocks. + from solprop.grid import FreeGrid # noqa: PLC0415 + from solprop.pulses import SpatialPulse # noqa: PLC0415 + from solprop.spatial import SpatialMedium # noqa: PLC0415 + + grid = EnvelopeGrid.from_limits( + 1e-4, 800e-9, (500e-9, 1.6e-6), 300e-15, thg=False, fft_size="smooth" + ) + space = FreeGrid(2e-4, 32, 3e-4, 48, collar_fraction=0) + temporal = GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9) + medium = SpatialMedium("Ar", 1.0) + pulse = SpatialPulse(temporal, 60e-6, jones=jones) + + serial = pulse.build(grid, space, medium=medium) + assert serial.nbytes < _process.PARALLEL_BYTES + monkeypatch.setattr(_process, "PARALLEL_BYTES", 0) + budget = dataclasses.replace(_process.thread_budget(), threads=workers) + monkeypatch.setattr(_process, "thread_budget", lambda: budget) + assert pulse.build(grid, space, medium=medium).tobytes() == serial.tobytes() From a68158f93f0d175325df30b2af8504b8f9054d90 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:03:42 +0000 Subject: [PATCH 038/122] Give nditer its one operand's flags flat, as the stubs read them Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/output/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py index 01cea9f1..36319a7d 100644 --- a/src/solprop/output/schema.py +++ b/src/solprop/output/schema.py @@ -522,7 +522,7 @@ def of(cls, values: np.ndarray) -> "ArrayDigest": # noqa: UP037 - bound after t for block in np.nditer( array, flags=["external_loop", "buffered"], - op_flags=[["readonly", "contig"]], + op_flags=["readonly", "contig"], op_dtypes=[little], casting="equiv", order="C", From 3cbb921ab5285f2e27d4e7fa923d8e96172d480b Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:03:54 +0000 Subject: [PATCH 039/122] Say why nditer's flag is outside the stubs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/output/schema.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py index 36319a7d..8d194ac1 100644 --- a/src/solprop/output/schema.py +++ b/src/solprop/output/schema.py @@ -522,7 +522,8 @@ def of(cls, values: np.ndarray) -> "ArrayDigest": # noqa: UP037 - bound after t for block in np.nditer( array, flags=["external_loop", "buffered"], - op_flags=["readonly", "contig"], + # NumPy's stubs leave "contig" out of the flags they list. + op_flags=["readonly", "contig"], # pyright: ignore[reportArgumentType] op_dtypes=[little], casting="equiv", order="C", From c8725fff67bfff6cf45441929568e39b236368ad Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:04:12 +0000 Subject: [PATCH 040/122] Put the stub note on the call the checker reports Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/output/schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py index 8d194ac1..2c9357e8 100644 --- a/src/solprop/output/schema.py +++ b/src/solprop/output/schema.py @@ -519,10 +519,10 @@ def of(cls, values: np.ndarray) -> "ArrayDigest": # noqa: UP037 - bound after t elif array.flags.c_contiguous and array.dtype == little: digest.update(memoryview(array).cast("B")) else: - for block in np.nditer( + # NumPy's stubs leave "contig" out of the flags they list. + for block in np.nditer( # pyright: ignore[reportCallIssue, reportUnknownVariableType] array, flags=["external_loop", "buffered"], - # NumPy's stubs leave "contig" out of the flags they list. op_flags=["readonly", "contig"], # pyright: ignore[reportArgumentType] op_dtypes=[little], casting="equiv", From c26059dbfcb4c48a5cfa8fc75a028ed00afffd40 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:13:13 +0000 Subject: [PATCH 041/122] SEP 44: keep Part II empty for a draft, and name its tracking issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prototype's measurements are evidence for the proposal, so they sit with its costs in §6. Refs #514. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/seps/sep-0044-native-edge-read.md | 83 +++++++++------------- 1 file changed, 33 insertions(+), 50 deletions(-) diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index c094b885..52b0f262 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -178,7 +178,7 @@ count and the node count, so it has no route branch beyond those. Planar's node **Native, radial.** The same sums, with the node's value a dense product per plane of the synthesis's row and the state, from the first row a read's regions can touch. It needs no scratch, so neither `statistics_time_` nor `modal_complex_`, whose non-crop bins the retained path relies on staying zero. It is slower at one thread than -the host's BLAS (§ Part II); a GEMM remains open to it if that ever matters. +the host's BLAS (the cost below); a GEMM remains open to it if that ever matters. **Collar.** Python computes each line's strides, window, inward bin range and onset triple, captured by value, so native does no tie-breaking. The strip uses a persistent padded transform per axis. The current accumulates in @@ -193,10 +193,34 @@ the route this SEP is mainly for. It's left out unless a CPU measurement asks fo **Kernel contract.** New `StepDriver` members, so a schema bump under today's rule. `KernelProblem` doesn't change. -**Cost.** Estimated before the work: 30 to 50 ms a read at 768 x 768 x 600 on a data-centre GPU, against 4.3 s then -on an H200 and against a step of about 230 ms. On CPU a read now transforms every plane, where the host read -transformed a fifth to a third of them, but it loses the copy and the host's single-thread scan. Part II has what -was measured (Q2). +**Cost, estimated.** 30 to 50 ms a read at 768 x 768 x 600 on a data-centre GPU, against 4.3 s then on an H200 and +against a step of about 230 ms. On CPU a read now transforms every plane, where the host read transformed a fifth to +a third of them, but it loses the copy and the host's single-thread scan. + +**Cost, on a prototype.** Phases 1 and 2 were prototyped on a branch to test Option E and that estimate, before review. +On an NVIDIA A40 (CUDA 12.8.1) at 384 x 384 x 600, eight fixed steps and four planes, `python -m benchmarks.device_split`: +12 reads took 0.41 s together, and the point took 6.6 s against 5.8 s with no check and 9.8 s with #502's ring alone, with +no state crossing. A40 timings say little of a data-centre card, whose reads this hasn't measured. On CPU, against +`main`, with #510 and #502 in, `python -m benchmarks ab`, Intel Xeon Gold 6240, 18 cores of one socket, passive +threads, three rounds of one repeat, exploratory on a shared machine: + +| case | threads | wall, s | ratio (range) | field difference | +|---|---:|---|---:|---:| +| `kerr_beam_192` | 1 | 18.74 → 17.62 | 0.934 (0.934–0.955) | 1.6e-16 | +| `radial_filament_256` | 1 | 7.66 → 7.93 | 1.036 (1.024–1.059) | 4.2e-15 | +| `line` | 1 | 1.017 → 1.011 | 0.997 (0.986–1.008) | 1.4e-15 | +| `planar` | 1 | 0.571 → 0.571 | 1.004 (1.001–1.007) | 0 | +| `kerr_beam_192` | 16 | 3.08 → 2.08 | 0.675 (0.662–0.683) | 0 | +| `radial_filament_256` | 16 | 1.763 → 1.722 | 0.984 (0.936–0.986) | 0 | +| `line` | 16 | 0.296 → 0.286 | 0.934 (0.900–1.053) | 0 | +| `planar` | 16 | 0.172 → 0.163 | 0.945 (0.937–0.980) | 0 | + +The same work in every row. On a point of 256 x 256 x 48, sixteen fixed steps and two planes at sixteen threads, the +default check went from 2.4 s to 2.0 s against 1.8 s without it, and from 3.9 to 2.1 field volumes above the level before +the run against 1.5 without it. The prototype's read of one state agrees with `HostEdgeReduction`'s on all four routes, +at a launch and a read, within four times `tests/bounds.py`'s `fft_bound`; a read at every step under each march leaves the +final field bit for bit that of the same run read on the host; and a field-free default run finishes with +`_observed_field` refused, on the CPU and the A40. The wheel compiles for `AMD_GFX942` under ROCm 7.2.4. ### 7. Verification and validation @@ -262,8 +286,8 @@ physics decision, left to [#388](https://github.com/LupoLab/solprop/issues/388). - **Q1.** Whether `Simulation.estimate` should count a collar's strip buffers, as it does the held plane. Recommended: yes. - **Q2.** Whether a CPU read, with every plane now transformed, stays within SEP 40's 2 % on a long run. If not, the - read-as-first-pass reuse of §6 comes back, on routes where it's provable. Measured in phase 2: see Part II. It holds - except for the radial route at one thread, ×1.036. + read-as-first-pass reuse of §6 comes back, on routes where it's provable. On the prototype it holds except for the + radial route at one thread, ×1.036 on a window (§6); a GEMM could take that, if it's worth a second code path. - **Q3.** Whether an interrupted collar run's record should drain the native current on the way out, or may lag by a few steps. Recommended: drain it. @@ -274,49 +298,8 @@ physics decision, left to [#388](https://github.com/LupoLab/solprop/issues/388). ## Part II — Implementation record -Phases 1 and 2 are implemented on the branch `feature/sep-0044-native-edge`, ahead of this SEP's review. - -**Phase 1, the Python split.** As designed, with `EdgeTables` in place of `EdgeBinding`. `READ_RESOLUTION` and -`_occupied_planes` are gone. The launch's clean column powers are formed at the load. A resume restores a collar's -current, baselines and peaks ([#512](https://github.com/LupoLab/solprop/issues/512)), and the record gained -`strip_floor`. - -**Phase 2, the native read without a collar.** Deviations from the draft: native sums against weight tables (Option E) -rather than returning $u_n$ (Option C), for the reason in §6; the radial read is a dense product per plane rather than -a GEMM. A collar still reduces a host copy. - -*Verification, on CPU.* `tests/backends/test_native_edge_reading.py`, all passing: the reading of one state against -`HostEdgeReduction` on all four routes, at a launch and at a read, within four times `tests/bounds.py`'s `fft_bound`; -the state's bits unchanged by a reading; one lit node reaching only its own faces, with the right weight, which white -noise can't show; and a field-free default run with `_observed_field` refused, which has to finish having read the edge. -`tests/backends/test_transverse_backends.py`, `test_transverse_reads.py`, `test_transverse_monitor.py`, -`test_transverse_nonlinear.py`, `test_transverse_resolution.py`, `test_transverse_witnesses.py`, `test_split_crossing.py` -and `test_monitor.py` pass. - -*Not yet covered from §7:* a forced read at every commit against an unread run, the adversarial probes beyond the lit -node, `n_pol = 2`, and every device row. - -*Timing against `main`, Intel Xeon Gold 6240, 18 cores of one socket.* `python -m benchmarks ab`, `--threading -passive`, three rounds of one repeat. This also holds this work's steps 1 and 2, which measured neutral on CPU alone. -Exploratory, on a shared machine: - -| case | threads | wall, s | ratio (range) | field difference | -|---|---:|---|---:|---:| -| `kerr_beam_192` | 1 | 18.74 → 17.62 | 0.934 (0.934–0.955) | 1.6e-16 | -| `radial_filament_256` | 1 | 7.66 → 7.93 | 1.036 (1.024–1.059) | 4.2e-15 | -| `line` | 1 | 1.017 → 1.011 | 0.997 (0.986–1.008) | 1.4e-15 | -| `planar` | 1 | 0.571 → 0.571 | 1.004 (1.001–1.007) | 0 | -| `kerr_beam_192` | 16 | 3.08 → 2.08 | 0.675 (0.662–0.683) | 0 | -| `radial_filament_256` | 16 | 1.763 → 1.722 | 0.984 (0.936–0.986) | 0 | -| `line` | 16 | 0.296 → 0.286 | 0.934 (0.900–1.053) | 0 | -| `planar` | 16 | 0.172 → 0.163 | 0.945 (0.937–0.980) | 0 | - -The same work in every row. On a point of 256 x 256 x 48, sixteen fixed steps and two planes at sixteen threads, one -runner, the default check went from 2.4 s to 2.0 s against 1.8 s without it, and from 3.9 to 2.1 field volumes above -the level before the run against 1.5 without it. - -*HIP.* The wheel compiles offline for `AMD_GFX942` under ROCm 7.2.4. +No phase has been implemented. ## Part III — History -- 2026-11-04: Drafted. Tracking issue: to be opened with this draft. +- 2026-11-04: Drafted. Tracking issue: [#514](https://github.com/LupoLab/solprop/issues/514). From a121c73c66c4a25b0652a150305af7fb7e2cffc4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:13:41 +0000 Subject: [PATCH 042/122] SEP 44: count the reads, not the calls that may read Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/seps/sep-0044-native-edge-read.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index 52b0f262..bb7b1371 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -199,8 +199,8 @@ a third of them, but it loses the copy and the host's single-thread scan. **Cost, on a prototype.** Phases 1 and 2 were prototyped on a branch to test Option E and that estimate, before review. On an NVIDIA A40 (CUDA 12.8.1) at 384 x 384 x 600, eight fixed steps and four planes, `python -m benchmarks.device_split`: -12 reads took 0.41 s together, and the point took 6.6 s against 5.8 s with no check and 9.8 s with #502's ring alone, with -no state crossing. A40 timings say little of a data-centre card, whose reads this hasn't measured. On CPU, against +the 8 reads took 0.41 s together, about 50 ms each, and the point took 6.6 s against 5.8 s with no check and 9.8 s with +#502's ring alone, with no state crossing. A40 timings say little of a data-centre card, whose reads this hasn't measured. On CPU, against `main`, with #510 and #502 in, `python -m benchmarks ab`, Intel Xeon Gold 6240, 18 cores of one socket, passive threads, three rounds of one repeat, exploratory on a shared machine: From 49ddb18256ff163055e88e6953babb81f6f3aba6 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:28:30 +0000 Subject: [PATCH 043/122] docs: record AI review of SEP 44 --- docs/dev/seps/sep-0044-native-edge-read.md | 154 ++++++++++++++++++++- 1 file changed, 152 insertions(+), 2 deletions(-) diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index bb7b1371..b723b5be 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -7,8 +7,8 @@ | **Author** | John Travers | | **Drafting agent** | Claude Opus 5.5 | | **Status** | Draft | -| **Review status** | Not reviewed | -| **Reviewers** | — | +| **Review status** | In review | +| **Reviewers** | GPT-6 (AI) | | **Type** | Change | | **Created** | 2026-11-04 | | **Resolution** | — | @@ -303,3 +303,153 @@ No phase has been implemented. ## Part III — History - 2026-11-04: Drafted. Tracking issue: [#514](https://github.com/LupoLab/solprop/issues/514). +- 2026-11-04: GPT-6 (AI) reviewed the proposal at `a121c73c`; findings and questions below. + +### AI review — 2026-11-04 + +Reviewer: **GPT-6 (AI)**. Reviewed all of Part I against SEP 0, SEP 40, the current +monitor and native implementation, the numerical comparison rules, and the linked +issues. Part I is unchanged. These are requests for the drafting agent and +maintainer to address, not amendments or acceptance decisions. All findings remain +open under [#514](https://github.com/LupoLab/solprop/issues/514). + +The separation recommended in Option E is sound in principle: Python supplies the +geometry and judges the readings, while native performs numerical operations on +resident data. Keeping per-line, per-frequency rectification and testing scratch +interference are particularly important. The proposal still needs the following +contract and qualification details before acceptance. + +1. **R1 — P1: specify the actual weighted-reduction contract (§§1, 4–6, 11).** + The abstract promises node and mode energies, but Option E returns twelve + regional sums and column powers. `EdgeTables.totals` and `EdgeReading.totals` + have no defined row meanings, units or normalisation; the requested collar's + removal rate has no named result. The current consumers need distinct values: + total launch norm, face sums, clip sums, the launch region used by + `_launch_return`, and the rate-weighted sum used by `_collar_rate`. The last + must also be available at later reads, although §6 says the other two tables + run only at launch. See the + [existing consumers](https://github.com/LupoLab/solprop/blob/a121c73c/src/solprop/transverse_monitor.py). + These choices determine the measured quantity, rather than just its storage. + Please give each table row and returned component an equation, shape and + scaling, define unused rows and the `faces/current/full` flags, and say whether + `columns` already includes the column quadrature. Include how planar mode and + polarisation axes are summed and how the strip's winning face retains its own + companion `windowed` value. Which result carries the collar rate at an ordinary + read? Add independent weighted-sum tests; agreement between two reducers using + the same incorrectly constructed tables would not establish correctness. + +2. **R2 — P1: make the current accumulator's lifecycle part of the contract + (§§6, 7, 9, 11, Q3).** Moving accumulation into native memory introduces state + beyond the scalar `current`: its previous accepted sample, position or interval, + and the undrained integral. Draining only at an edge read leaves Python's record + stale after a native march returns between reads. The existing + [execution-record path](https://github.com/LupoLab/solprop/blob/a121c73c/src/solprop/driver/run.py) + obtains `backend.metadata()` for saved states, snapshots and failures, and + [loading a new input](https://github.com/LupoLab/solprop/blob/a121c73c/src/solprop/backends/driver.py#L1148) + promises to reset everything a reused backend reads. Fixing #512 requires more + than adding `strip_floor`: losing the preceding current sample drops the first + trapezoid across a resume, even if the cumulative integral is restored. + **Needs a maintainer decision:** resolve Q3 before phase 4; recommend a current + record through the last committed state. Specify an idempotent drain, retention + of the last sample across drains, reset on `set_initial`, restore on resume, + and the initial-sample rule. Update only after the final mask/split treatment, + never for a rejected trial or an interpolated observation. Test repeated metadata + reads, an interruption between edge reads, snapshot/resume, and two runner + inputs, including one after failure. How are partially completed low-storage + steps kept from changing the last committed monitor record? + +3. **R3 — P2: derive bounds for the complete readings (§§4, 6, 7).** The stated + current bound, proportional to `epsilon * sum(abs(v) * abs(s))`, addresses the + product cancellation at supplied values, but does not account for synthesis, + the finite-difference slope, the reduction's addition depth or accumulation over + accepted steps. A slope formed from nearly equal neighbours can have error set + by their magnitudes divided by their separation, rather than by the resulting + slope's magnitude. Likewise, the radial path is a dense dot product, not an FFT; + four times `fft_bound` alone does not justify its error at arbitrary `n_r` or + under cancellation. Please declare an absolute error budget through synthesis, + squaring/weighting, reduction, division by the launch norm, rectification and + the current's trapezoid, using the applicable helpers in `tests/bounds.py`. + Distinguish unit roundoff from machine epsilon. Include a nearly cancelling + radial synthesis and an almost-zero inward current. Whole adaptive runs need + accuracy envelopes; exact stop/read-count parity should be limited to controlled + witnesses with matched histories and margins from decision thresholds. This + does not request a strict floating-point block or bitwise parity. + +4. **R4 — P2: measure the CPU criterion against the right baseline (§§3, 6, 7, + Q2).** SEP 40 §7.7's 2 % is the overhead of enabling the ramp and transverse + monitor on the *same implementation*, compared with disabling them. The table + in §6 instead compares the prototype with `main`; a ratio below 1.02 does not + establish that requirement. The separate 2.0 s versus 1.8 s point has about + 11.1 % check-on overhead, although that point alone does not establish a miss of + SEP 40's more specific transverse-only criterion. Please separate improvement + over the old code, monitor overhead, and the whole-point GPU target. Measure + ramp only, transverse reads only and both, reporting construction separately + and actual steps/source calls as SEP 40 requires. The comparison with 0.1.0 is + useful historical evidence but cannot replace this control. Include `numpy_ref` + after removing plane skipping: it retains host synthesis and does not gain + native's avoided transfer. **Needs a maintainer decision:** retain the 2 % CPU + criterion or explicitly replace/defer it. Also reconcile “fixes #396” with the + excluded read-ceiling question; keep the remaining work in that issue or a + linked successor rather than treating faster reductions as its full closure. + +5. **R5 — P2: account for default tables as well as collar buffers (§§5, 6, + Q1).** Q1 asks only about the collar's strip buffers, but Option E also adds + tables to the default check. At 768 by 768, twelve binary64 rows occupy + 56,623,104 bytes, **54 MiB** (about 57 MB), per resident copy. Python's tables, + upload staging, column results, radial rows and transform workspaces can add to + that. The current + [`estimate`](https://github.com/LupoLab/solprop/blob/a121c73c/src/solprop/driver/estimate.py#L158) + reads the ledger before loading the input and never attaches a monitor, so a + ledger entry allocated at attachment alone would remain invisible to it. + Recommend answering Q1 yes for all persistent resources this SEP introduces, + and specify how estimation sees them without reading a field. State ownership, + allocation and release points, host versus device accounting, and which buffers + can actually be shared across runner inputs. Test the estimate against the bound + backend's ledger, and verify that a newly constructed check-off run pays no edge + allocation. How much temporary memory does a collar's padded transform need on + each route, at the widest supported strip? + +6. **R6 — P2: complete and scope the qualification matrix (§§6, 7, 11).** The + adversarial probes are useful, but the matrix should explicitly include field + and envelope grids, DC/Nyquist and inactive bins, input/modified/no noise, a + supplied unmasked realisation, and the supported binary32 host path. The clean + launch identity in §6 is correct for a fresh input-noise launch; a resume already + carries the noise and must follow a separately stated path. Include launch-only + interruption and resume with and without a monitor record. Scope the no-crossing + promise to monitor-induced transfers: requested fields, some statistics, + interpolation and terminal checkpoints have separate transfer requirements. + Failing `_observed_field` alone cannot detect a direct + `field_to_host()` call, which the native adapter also contains. Add a lower-level + transfer count or trace for the stated field-free workload, including launch, + ordinary reads, collar commits and shutdown. For the CPU exercise of device + logic, require tests that actually invoke the device-shaped implementation; + merely compiling both alternatives does not exercise the unused one. + +7. **R7 — P2: make the performance evidence and phase gates reproducible + (§§6–8, 11).** The draft correctly labels the prototype as unpublished and the + GPU target as unmeasured. However, `benchmarks.device_split` is absent from the + reviewed tree, and neither the prototype revision nor the exact revisions + carrying #510/#502 are identified for the prototype comparisons. Before using + these numbers to accept Option E, provide a public reproducible harness or + patch, pinned baseline and candidate revisions, full commands/settings and + enough per-repeat results to assess the shared-machine spread. Keep the + 51-step historical measurement as context; specify the short qualification + window, or obtain an explicit exception to the standing roughly-ten-step rule. + Assign the schema bump and protected-surface test update to phase 1, where the + new driver hook first appears. Put the host-path manual changes and release + note for removing `READ_RESOLUTION` there too, rather than deferring all + documentation to phase 6. The maintainer's decision to remove it is respected; + the compatibility text should retain the general omission bound + `1e-3 * budget**2` and allow a changed verdict for a state within that distance + of a threshold, even though the existing witnesses are not near one. + +**Evidence and limits.** Read the proposal at `a121c73c`, its base at `e49925d1`, +the existing host/native reduction and commit paths, SEP 40's definition and cost +criterion, `tests/bounds.py`, the existing transverse tests, #396, #510, #512, #514 +and PR #508. Ran small, non-propagating NumPy probes: a line-grid sample of amplitudes +2 and 3 in two polarisations at the last frequency gives node power 13 and matching +weighted node/column totals to rounding; a supplied unmasked-noise example gives +327 by both the existing mask-and-subtract expression and the proposed clean-power +identity. Checked the table-byte calculation and the check-on/off ratio above. +No prototype, native build, GPU execution or performance measurement was run for +this review. No new physical constants or literature coefficients were introduced. From 1c5205937c3794e58a1e0623d5f290d03189877c Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:34:48 +0000 Subject: [PATCH 044/122] Ledger: the default check on a device Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 3a5eaa73..fb3d3aba 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2098,6 +2098,40 @@ column's norm inside the request, because no light has reached a collar: the ent and compares no absorption. It is a window, and the temporal pass's share of a step moves with the response's: a heavier response gives a smaller saving. +### The default check on a device: reads, occupancy and sums + +A field-free point of a free-space Kerr beam at 768 x 768 x 600 (5.27 GiB), eight fixed low-storage +RK4IP steps and four planes, with a stated input identity and no held plane, took 12 to 7 times as long +with the default `boundary_check` as without it. `python -m benchmarks.device_split 768`, the second point +of one `SimulationRunner`, on an NVIDIA H200 (CUDA 13.1) and an AMD Instinct MI325X (ROCm 10). Before is +`7a224ea5`, measured the same way that morning with the probe that became this module, on another droplet +of the same type; after is this entry's commit, both cards the same day: + +| | H200, before | H200, after | MI325X, before | MI325X, after | +|---|---:|---:|---:|---:| +| Point, `boundary_check=None`, s | 5.9 | 5.7 | 4.2 | 3.8 | +| Point, default check, s | 70 | 51 | 29 | 14 | +| State crossings in a point | 13 | 9 | 13 | 9 | +| `monitor.occupancy`, one reading, ms | 184 | 10 | 1,240 | 13 | +| `energy` statistic, ms | 55 | 2.3 | 80 | 6.6 | +| `peak_intensity` statistic, ms | 134 | 77 | 164 | 86 | + +What changed: a saved plane that the step reaching it already read takes no second read, which was a whole +state's copy thrown away; a replay decides its reads by its next step, not by `dz_init`; the occupancy is +a reduction per column, without an atomic per element; `energy`, the peak and a low-storage step's norm are real +reductions, not sums on one thread of a device; a runner's points share the occupancy's table; an +Adams run past its snapshot limit takes no snapshot to learn its size. + +HIP's smallest block is 64 threads, and a reduction's value has to fit `(64 + 2) * sizeof(value)` of 64 KiB +with the kernel's own static memory beside it, which Kokkos's check doesn't count. So a reading there holds 64 +buckets a pass and takes two passes of 6.4 ms, not one of 128: 124 passed Kokkos's check and the dispatch then faulted +("Illegal opcode in command stream"). On a CPU, `python -m benchmarks ab` against `main` on an Intel Xeon Gold 6240 +at 1 and 16 threads, three rounds, on `kerr_beam_192`, `line` and `planar`: geometric mean 1.002, each case +within 4 %, fields to 1.5e-15, the same work. + +The check still reads a host copy of the state, on the host, which is what is left of its cost above: the +entries that follow take that. + ## Reference tables These tables back the indicative results above. From 578d098d1f6b02de4626dcb4358567a240f8e318 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:38:59 +0000 Subject: [PATCH 045/122] Drop two tests whose premise is gone; fit the new text to ruff test_the_phase_lambdas_of_the_core_are_strict read lambdas in core.cpp's compact state, which went with the State engine (#88), and had failed since. A backend can no longer lack the march's hooks, so test_a_backend_without_a_march_resumes_without_one has nothing to test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/architecture.md | 1 + docs/dev/backends.md | 2 +- docs/dev/figures/build.py | 8 +++++--- src/solprop/translate/responses.py | 4 ++-- .../test_native_floating_point_licence.py | 11 ----------- tests/driver/test_resume_history.py | 15 --------------- 6 files changed, 9 insertions(+), 32 deletions(-) diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 335bbc12..24a44e90 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -553,6 +553,7 @@ class Backend(Protocol): def available_device_memory(self) -> int | None: ... def memory_ledger(self) -> dict[str, int]: ... def close(self) -> None: ... + # And what the run layer asks beside them: the stop flag, the held plane, the boundary # monitor, the march's snapshot and an estimate's step (backends.md section 1). ``` diff --git a/docs/dev/backends.md b/docs/dev/backends.md index d0ac2206..a902b494 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -66,7 +66,7 @@ class Backend(Protocol): @property def in_flight(self) -> tuple[float, float] | None: ... # a step abandoned there def held_observation(self) -> Observation | None: ... - def march_snapshot(self) -> MarchSnapshot | None: ... # the Adams history, for a resume + def march_snapshot(self) -> MarchSnapshot | None: ... # the Adams history def march_snapshot_nbytes(self, z: float) -> int | None: ... # copying nothing def march_export_refusal(self) -> str | None: ... def restore_march(self, snapshot: MarchSnapshot) -> str | None: ... diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index b729aa7f..ed146ac6 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -2525,8 +2525,8 @@ def backends() -> drawsvg.Drawing: footer = ( "Three things every backend owes beyond the methods: deterministic " "reductions, the device form of the stage, and the driver's bookkeeping. The " - "error norm is control flow, so a sum whose association depends on the thread count " - "forks the trajectory." + "error norm is control flow, so a sum whose association depends on the thread " + "count forks the trajectory." ) top = header_height(subtitle) + 20 gap = 44.0 @@ -2543,7 +2543,9 @@ def backends() -> drawsvg.Drawing: ) drawing = _canvas(height) - _header(drawing, "One seam, a shared driver, and the backends behind them", subtitle) + _header( + drawing, "One seam, a shared driver, and the backends behind them", subtitle + ) y = top _table( diff --git a/src/solprop/translate/responses.py b/src/solprop/translate/responses.py index 8504e532..3c00f828 100644 --- a/src/solprop/translate/responses.py +++ b/src/solprop/translate/responses.py @@ -179,8 +179,8 @@ The form is a **physical model** choice and so lives in ``model_id``, which is already a representation tag inside the resume identity. It is not a new -leaf field: adding one would move a contract row, and there is nothing else the form needs -to carry. +leaf field: adding one would move a contract row, and there is nothing else the form +needs to carry. """ KERR_MODEL_FORMS: Final[Mapping[str, str]] = { diff --git a/tests/backends/test_native_floating_point_licence.py b/tests/backends/test_native_floating_point_licence.py index 983b5c56..ca47153d 100644 --- a/tests/backends/test_native_floating_point_licence.py +++ b/tests/backends/test_native_floating_point_licence.py @@ -115,17 +115,6 @@ def test_every_protected_helper_is_strict(path, name): assert body.lstrip().startswith(STRICT_BLOCK) -def test_the_phase_lambdas_of_the_core_are_strict(): - """The compact state's stage phases are lambdas, each carrying the attribute.""" - source = NATIVE / "src" / "core.cpp" - if not source.exists(): - pytest.skip("the native sources are not in this tree") - text = source.read_text(encoding="utf-8") - for name in ("whole_phase", "stage_phase"): - pattern = rf"{name} = \[&\]\(\) {STRICT_ATTRIBUTE} \{{\s*{STRICT_BLOCK}" - assert re.search(pattern, text), name - - def test_a_protected_marker_still_does_something(): """The macro names its two mechanisms: the list cannot see an empty one.""" source = NATIVE / "include" / "solprop_native" / "fp.hpp" diff --git a/tests/driver/test_resume_history.py b/tests/driver/test_resume_history.py index 84710e0a..6eb03fdd 100644 --- a/tests/driver/test_resume_history.py +++ b/tests/driver/test_resume_history.py @@ -18,10 +18,8 @@ """ import dataclasses -import inspect import logging import math -from types import SimpleNamespace from typing import Any, cast import h5py @@ -38,7 +36,6 @@ from solprop.backends.driver import StepDriver from solprop.backends.numpy_ref import NumpyRefBackend from solprop.backends.registry import select -from solprop.driver import Simulation from solprop.exceptions import OutputError, SolpropWarning from solprop.log import NullReporter from solprop.output import HDF5Output @@ -608,15 +605,3 @@ def test_the_general_path_refuses_a_snapshot_it_cannot_install(build_simulation) finally: for backend in backends: backend.close() - - -def test_a_backend_without_a_march_resumes_without_one(build_simulation, recwarn): - # The driver continues a history only on a backend that offers the hooks; one - # without them (any `Backend`, not only a step driver) simply loads the plane. - simulation = build_simulation( - length=LENGTH, n_save=N_SAVE, backend="numpy_ref", integrator=Multistep() - ) - # Unwrapped: the runtime type check would refuse a stand-in for either argument. - continue_march = inspect.unwrap(Simulation._continue_march) - continue_march(simulation, SimpleNamespace(), SimpleNamespace(march=march())) - assert not recwarn.list From e914124ecee4e864dab5e033c0da38426305c794 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:41:13 +0000 Subject: [PATCH 046/122] Kernel schema 47, on top of the held plane's removal The driver and the Adams engine protocol gained members here, so the seam's number moves. The probe stops passing a setting that's gone, and the header stops declaring the methods #508 removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- benchmarks/device_split.py | 3 +-- changelog.d/kernel-schema-47.changed.md | 1 + docs/dev/benchmarks.md | 2 +- docs/dev/kernel-contract.md | 2 +- native/include/solprop_native/core.hpp | 6 ------ native/src/bindings.cpp | 2 +- native/src/solprop_native/__init__.py | 3 ++- native/tests/test_api_contract.py | 4 ++-- src/solprop/translate/problem.py | 7 ++++++- tests/backends/test_driver.py | 7 +++++-- tests/backends/test_mixture_channels.py | 2 +- tests/translate/test_dispersive_kerr_terms.py | 2 +- tests/translate/test_problem.py | 2 +- 13 files changed, 23 insertions(+), 20 deletions(-) create mode 100644 changelog.d/kernel-schema-47.changed.md diff --git a/benchmarks/device_split.py b/benchmarks/device_split.py index 5f08446d..2cf4a4a4 100644 --- a/benchmarks/device_split.py +++ b/benchmarks/device_split.py @@ -6,7 +6,7 @@ One input of a delay scan through :class:`solprop.driver.SimulationRunner`, with no fields kept: a fused-silica Kerr beam at 260 nm on fixed low-storage RK4IP steps, with -a stated input identity and no held plane. It runs with ``boundary_check=None`` and +and a stated input identity. It runs with ``boundary_check=None`` and with the default check, budgets relaxed so that nothing stops, and profiles one point of each. Then it times what a plane can ask of a device: a state's copy to the host, an observation and the common statistics. @@ -112,7 +112,6 @@ def _simulation( backend="native", boundary_check=check, fields="none", - held_plane=False, stats=StatsSpec(names=("energy",)), ) return Simulation.from_free( diff --git a/changelog.d/kernel-schema-47.changed.md b/changelog.d/kernel-schema-47.changed.md new file mode 100644 index 00000000..c7faef93 --- /dev/null +++ b/changelog.d/kernel-schema-47.changed.md @@ -0,0 +1 @@ +Kernel schema 47: `StepDriver` gains `march_snapshot_nbytes` and `_next_step`, and the Adams engine protocol gains `snapshot_nbytes`, so that a run asks a snapshot's size before taking it. No `KernelProblem` field or stage bundle moves. diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 5dd3e798..7f42b847 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2101,7 +2101,7 @@ the response's: a heavier response gives a smaller saving. ### The default check on a device: reads, occupancy and sums A field-free point of a free-space Kerr beam at 768 x 768 x 600 (5.27 GiB), eight fixed low-storage -RK4IP steps and four planes, with a stated input identity and no held plane, took 12 to 7 times as long +RK4IP steps and four planes, with a stated input identity, took 12 to 7 times as long with the default `boundary_check` as without it. `python -m benchmarks.device_split 768`, the second point of one `SimulationRunner`, on an NVIDIA H200 (CUDA 13.1) and an AMD Instinct MI325X (ROCm 10). Before is `7a224ea5`, measured the same way that morning with the probe that became this module, on another droplet diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md index edef9c1c..b91611f9 100644 --- a/docs/dev/kernel-contract.md +++ b/docs/dev/kernel-contract.md @@ -94,7 +94,7 @@ expansion. A total-degree expansion is not part of the contract: it failed the joint memory and build-cost criterion on the short fixture. -The current version is kernel schema **45** +The current version is kernel schema **47** (`solprop.translate.problem.KERNEL_SCHEMA_VERSION`), and the tables of this document describe it. A version bump that adds an optional field leaves every problem that does not use it unchanged, `schema_version` itself being the diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index a3e790f8..eb407f4b 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -833,12 +833,6 @@ class GeneralState { */ [[nodiscard]] double low_storage_step(double dz, const TrialScalars& scalars, bool rebuild_table); - /** Hold the accepted state at a field-free plane, in the memory space (SEP 22). */ - void hold_plane(); - /** Release the held plane's volume, for a run that holds none. */ - void release_plane(); - /** The held plane's state on the host: the one copy a checkpoint takes. */ - [[nodiscard]] std::vector held_to_host() const; /** The resident Adams history (solprop SEP 16 phase 3b, solprop ADR-043). */ void adams_allocate(std::size_t slots, bool compensated, bool interpolate = true); void adams_compensation_start(std::size_t slot, const std::vector& rate); diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index cb7b8827..9c633591 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -1921,7 +1921,7 @@ nb::dict build_record() { nb::dict result; result["backend_commit"] = SOLPROP_NATIVE_BACKEND_COMMIT; result["solprop_built_against"] = SOLPROP_NATIVE_SOLPROP_BUILT_AGAINST; - result["schema_version"] = 46; + result["schema_version"] = 47; result["kokkos_version"] = SOLPROP_NATIVE_KOKKOS_VERSION; result["kokkos_fft_version"] = SOLPROP_NATIVE_KOKKOSFFT_VERSION; result["kokkos_kernels_version"] = SOLPROP_NATIVE_KOKKOSKERNELS_VERSION; diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py index 8fec5d60..5ea0c7ec 100644 --- a/native/src/solprop_native/__init__.py +++ b/native/src/solprop_native/__init__.py @@ -277,7 +277,8 @@ def _band_request() -> int: ``SeparableOperator.user_rate``, the user's share of the spectral rate, which only the statistic ``absorbed_power`` reads, in a fifth channel. Schema 46 removes the low-storage march's held plane: ``_hold``, ``_held_field``, ``_drop_held``, and the -native state's ``hold_plane``, ``release_plane`` and ``held_to_host``. +native state's ``hold_plane``, ``release_plane`` and ``held_to_host``. Schema 47 adds +``march_snapshot_nbytes``, which the adapter's engine answers with ``snapshot_nbytes``. solprop freezes this surface under its ``KERNEL_SCHEMA_VERSION`` (the test in its ``tests/backends/test_driver.py``); :func:`check_build` looks each member up so a diff --git a/native/tests/test_api_contract.py b/native/tests/test_api_contract.py index 491ecc1c..6f4f459f 100644 --- a/native/tests/test_api_contract.py +++ b/native/tests/test_api_contract.py @@ -88,7 +88,7 @@ def test_build_info_is_frozen_and_complete() -> None: assert dataclasses.is_dataclass(info) with pytest.raises(dataclasses.FrozenInstanceError): info.thread_count = 99 # type: ignore[misc] - assert info.schema_version == 46 + assert info.schema_version == 47 assert info.response_full_thread_columns > 0 assert ( info.response_full_thread_columns @@ -245,7 +245,7 @@ def test_module_entry_point_prints_one_json_record() -> None: assert completed.returncode == 0, completed.stderr lines = completed.stdout.splitlines() assert len(lines) == 1 - assert json.loads(lines[0])["schema_version"] == 46 + assert json.loads(lines[0])["schema_version"] == 47 @pytest.mark.parametrize( diff --git a/src/solprop/translate/problem.py b/src/solprop/translate/problem.py index c1f05b6c..82efe9c2 100644 --- a/src/solprop/translate/problem.py +++ b/src/solprop/translate/problem.py @@ -207,7 +207,7 @@ logger = logging.getLogger(__name__) -KERNEL_SCHEMA_VERSION = 46 +KERNEL_SCHEMA_VERSION = 47 """Version of the kernel contract this module implements. Incremented on every change to the field list of ``docs/dev/kernel-contract.md``. A @@ -519,6 +519,11 @@ class attribute ``helpers``, the backend's implementation of the per-stage Cheby ``held_observation``, ``_hold``, ``_held_field`` and ``_drop_held``, the native state's ``hold_plane``, ``release_plane`` and ``held_to_host``, and the ledger's ``held_plane`` row (ADR-067). No ``KernelProblem`` field or stage bundle moves. + +Version 47 adds ``StepDriver.march_snapshot_nbytes``, with which a run asks a snapshot's +size before taking it, the engine protocol's ``snapshot_nbytes`` behind it, and +``StepDriver._next_step``, the step a read is judged by. No ``KernelProblem`` field or +stage bundle moves. """ DEFAULT_COMPONENTS = ("x",) diff --git a/tests/backends/test_driver.py b/tests/backends/test_driver.py index 777a630f..65f2edb4 100644 --- a/tests/backends/test_driver.py +++ b/tests/backends/test_driver.py @@ -870,6 +870,8 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): "_finish": "(self, state: _StateT, dz: float) -> _StateT", "_bind_monitor": "(self, monitor: solprop.monitor.BoundaryMonitor | None) -> None", "_boundary_energies": "(self, state: _StateT) -> solprop.monitor.BoundaryEnergies", + "_next_step": "(self) -> float", + "march_snapshot_nbytes": "(self, z: float) -> int | None", "_observed_field": ( "(self, state: _StateT)" " -> jaxtyping.Complex[ndarray, 'n_batch n_modes n_pol n_omega']" @@ -1139,9 +1141,10 @@ def test_the_surface_a_backend_subclasses_is_frozen(driver): ``_drop_held``, which releases its buffer. Version 45 appends ``user_rate`` to the separable leaf, without changing these signatures. Version 46 removes ``held_observation``, ``_hold``, ``_held_field``, ``set_held_plane`` and - ``_drop_held``: a low-storage march holds no plane (ADR-067). + ``_drop_held``: a low-storage march holds no plane (ADR-067). Version 47 adds + ``march_snapshot_nbytes`` and ``_next_step``. """ - assert KERNEL_SCHEMA_VERSION == 46, ( + assert KERNEL_SCHEMA_VERSION == 47, ( "the seam changed: rewrite the literals of this test" ) assert StepDriver.reads_transverse_edge is False diff --git a/tests/backends/test_mixture_channels.py b/tests/backends/test_mixture_channels.py index 5032e549..cd670e98 100644 --- a/tests/backends/test_mixture_channels.py +++ b/tests/backends/test_mixture_channels.py @@ -29,7 +29,7 @@ def case(): def test_compiled_channels_share_labels_rates_and_linear_density(case): mixture, _, mode, _, problem = case - assert problem.schema_version == 46 + assert problem.schema_version == 47 assert problem.flags.n_channels == 2 assert problem.flags.n_raman == 0 assert problem.responses.labels == mixture.labels diff --git a/tests/translate/test_dispersive_kerr_terms.py b/tests/translate/test_dispersive_kerr_terms.py index 5b0c9527..8fdc6a73 100644 --- a/tests/translate/test_dispersive_kerr_terms.py +++ b/tests/translate/test_dispersive_kerr_terms.py @@ -226,7 +226,7 @@ def test_the_problem_carries_the_leaf_and_its_stage_row(field_grid, mode): problem = problem_for( field_grid, mode, [DispersiveKerrResponse(ARGON, "polynomial")] ) - assert problem.schema_version == KERNEL_SCHEMA_VERSION == 45 + assert problem.schema_version == KERNEL_SCHEMA_VERSION == 47 assert problem.grids.carrier_2w0 is None bundle = scalars(problem, 0.0, 0.05) expected = float(mode.density(np.array([0.05]))[0]) diff --git a/tests/translate/test_problem.py b/tests/translate/test_problem.py index b9b97bcd..ac60989f 100644 --- a/tests/translate/test_problem.py +++ b/tests/translate/test_problem.py @@ -210,7 +210,7 @@ def test_every_page_states_the_schemas_the_code_writes(): History, which names the version a change took, is not a statement of the current one; the SEP records are history throughout. """ - assert KERNEL_SCHEMA_VERSION == 45 + assert KERNEL_SCHEMA_VERSION == 47 assert OUTPUT_SCHEMA_VERSION == 2 assert soliton_schema_version_row().startswith("incremented on every change") written = {"kernel": KERNEL_SCHEMA_VERSION, "output": OUTPUT_SCHEMA_VERSION} From aad31aba4482327af97a3171e37a911057f19ecc Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:51:07 +0000 Subject: [PATCH 047/122] Ledger: the state's crossing through a ring of pinned chunks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 7f42b847..819e8242 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2132,6 +2132,44 @@ within 4 %, fields to 1.5e-15, the same work. The check still reads a host copy of the state, on the host, which is what is left of its cost above: the entries that follow take that. +### The state's crossing: a ring of pinned chunks + +A state crossing to the host was slow for one reason: a fresh pageable buffer takes its first touch inside the +driver's copy, 1.38 million page faults at 768 x 768 x 600. A bare copy of 5.27 GiB, in GiB/s: + +| | NVIDIA H200 | AMD Instinct MI325X | +|---|---:|---:| +| Fresh pageable buffer, as it was | 1.8 | 7.9 | +| Reused pageable buffer | 16.5 | 52 | +| Pinned, which is the link | 51.5 | 52.6 | +| 64 MiB pinned ring, a worker per host thread, fresh huge-page destination | 33 | 50 | +| The same ring, no huge pages | 25 | 33 | + +Every state-sized crossing now takes that ring, both ways. `python -m benchmarks.device_split`, against the previous +entry's commit, the same day, on the same cards and window: + +| | H200, before | H200, after | MI325X, before | MI325X, after | +|---|---:|---:|---:|---:| +| `field_to_host`, 5.27 GiB, ms | 2,929 | 175 | 506 | 107 | +| Its page faults | 1,382,401 | 2,758 | 1,382,401 | 2,755 | +| The point's crossings together, s | 26.5 | 1.6 | 4.3 | 0.96 | +| Point, default check, s | 51 | 25 | 14 | 9.7 | + +Against `main`, with the previous entry, `python -m benchmarks ab --device` on `kerr_beam_384`, three rounds of one repeat, +which saves its fields: H200 13.451 → 4.672 s, ratio 0.347 (0.347–0.349); MI325X 5.700 → 2.033 s, ratio 0.359 +(0.338–0.362). Fields identical and the same work on both. On a longer window, 384 x 384 x 600 with 51 steps and 17 planes, +the point with the check takes 14.2 s against 3.9 s without on the H200 (49.8 s before these two entries), and 7.1 s against 3.7 s on +the MI325X. The NVIDIA A40 sees less (PCIe Gen 4): 183 → 147 ms at 384 x 384. + +A kept plane's conversion and its finiteness check take threads too, above `PARALLEL_BYTES`, and a plane converts straight +into the store's block rather than through a copy. A run that keeps no field allocates no ring: `memory_ledger`'s +`host_crossing_ring` stays zero, which a device test holds. + +On a CPU a crossing is a plain copy and takes no ring. `python -m benchmarks ab` against `main`, with the previous entry, on an Intel +Xeon Gold 6240 (18 cores of one socket, passive threads, three rounds of one repeat, a shared machine): `kerr_beam_192` 0.976 +at one thread and 0.882 at sixteen, `radial_filament_256` 0.999 and 0.994, `line` 0.983 and 0.990, `planar` 0.997 and 0.980; geometric +mean 0.975, fields to 4.2e-15, the same work. + ## Reference tables These tables back the indicative results above. From 40a8de0f58881b0feb5f508dcab0b78e58bf25c5 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 15:53:29 +0000 Subject: [PATCH 048/122] Hold the native tests to kernel schema 47 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/tests/test_backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/native/tests/test_backend.py b/native/tests/test_backend.py index d3c5b63a..d4f24cdd 100644 --- a/native/tests/test_backend.py +++ b/native/tests/test_backend.py @@ -263,7 +263,7 @@ def test_schema_mismatch_names_both_versions( # The two independently qualified schema-18 branches are not the combined seam. problem, _ = phase_a_case spoiled = dataclasses.replace(problem, schema_version=declared) - with pytest.raises(BackendError, match=rf"version 45.*version {declared}"): + with pytest.raises(BackendError, match=rf"version 47.*version {declared}"): check_build(spoiled) @@ -781,7 +781,7 @@ def test_metadata_and_memory_ledger_are_self_consistent( n = problem.shape[-1] stages = problem.stepper.tableau.n_stages assert metadata["backend"] == "native" - assert metadata["schema_version"] == 45 + assert metadata["schema_version"] == 47 assert metadata["openmp_wait_policy"] == solprop_native.WAIT_POLICY assert metadata["openmp_proc_bind"] == solprop_native.PROC_BIND # NumPy's own BLAS pool is held while the backend lives (solprop_native._host_blas). From 710d943b84a7c6354f53a09001d2a3d839e0efb7 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 16:11:19 +0000 Subject: [PATCH 049/122] Ledger: say machine Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 7f42b847..4d22ec2d 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2104,7 +2104,7 @@ A field-free point of a free-space Kerr beam at 768 x 768 x 600 (5.27 GiB), eigh RK4IP steps and four planes, with a stated input identity, took 12 to 7 times as long with the default `boundary_check` as without it. `python -m benchmarks.device_split 768`, the second point of one `SimulationRunner`, on an NVIDIA H200 (CUDA 13.1) and an AMD Instinct MI325X (ROCm 10). Before is -`7a224ea5`, measured the same way that morning with the probe that became this module, on another droplet +`7a224ea5`, measured the same way that morning with the probe that became this module, on another machine of the same type; after is this entry's commit, both cards the same day: | | H200, before | H200, after | MI325X, before | MI325X, after | From b848425898f2694e3afc32d6e4c2570920aed07d Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 16:13:13 +0000 Subject: [PATCH 050/122] Add SEP 40's four-way overhead control, and independent reading tests benchmarks.edge_overhead measures the ramp and the edge against the same implementation. The tests hold each region to SEP 40's definition and a plain transform, the total to the columns by Parseval, a nearly cancelling radial read to an absolute budget, and a runner to reading its next input as a new one would after a stop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- benchmarks/edge_overhead.py | 232 +++++++++++++++++++++ tests/backends/test_native_edge_reading.py | 188 ++++++++++++++++- 2 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 benchmarks/edge_overhead.py diff --git a/benchmarks/edge_overhead.py b/benchmarks/edge_overhead.py new file mode 100644 index 00000000..21835869 --- /dev/null +++ b/benchmarks/edge_overhead.py @@ -0,0 +1,232 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""What the transverse edge and the safety ramp cost a run, on one implementation. + +SEP 40 section 7.7's control: each case is built by the harness's builder and run four +ways in one process, interleaved round by round, against itself rather than an older +build. Each run keeps the temporal monitor, so the comparison is the ramp's and the +edge's alone: + +- ``neither``: no ramp and no reads; +- ``ramp``: the ramp alone; +- ``edge``: the reads alone; +- ``both``: the default. + +Usage:: + + python -m benchmarks.edge_overhead --case line --case planar --rounds 9 \\ + --backend native --width 18 --out overhead.jsonl + +One JSON line per run, with its wall time and what it took: the march's calls, source +calls and reads. Building and translating a variant is timed apart, as construction. A +summary follows on stdout. Pin the process and set the threads yourself; the module +says what it found in its first line. +""" + +import argparse +import dataclasses +import json +import os +import random +import statistics +import time +import warnings +from pathlib import Path +from typing import Any + +from benchmarks.builders import build +from benchmarks.registry import CASES +from solprop.driver import Simulation +from solprop.exceptions import BoundaryWarning + +__all__ = ["main"] + +VARIANTS = { + "neither": (False, False), + "ramp": (True, False), + "edge": (False, True), + "both": (True, True), +} +"""Each variant's ramp and reads.""" + + +def _variant( + name: str, backend: str, width: int, fraction: float, ramp: bool, edge: bool +) -> tuple[Simulation, float]: + """Return a case's simulation with the ramp and the reads as asked, and its build time. + + Parameters + ---------- + name : str + The case. + backend : str + The backend. + width : int + The threads. + fraction : float + The share of its length to run. + ramp, edge : bool + Whether to keep the safety ramp, and the reads of the edge. + + Returns + ------- + tuple + The simulation, translated, and the seconds that took. + """ + started = time.perf_counter() + simulation = build(CASES[name].configuration("default", width, backend, fraction)) + if not ramp: + simulation.settings = dataclasses.replace( + simulation.settings, spatial_absorber=False + ) + if not edge: + # Keep the temporal monitor: the control is the edge's cost, not the whole check's. + simulation._transverse_edge = lambda *_: None # type: ignore[method-assign, assignment] # noqa: SLF001 + simulation.translate() + return simulation, time.perf_counter() - started + + +def _took(output: object) -> dict[str, Any]: + """Return what a run's record says it took. + + Parameters + ---------- + output : object + What the run returned. + + Returns + ------- + dict + The march's logical calls, the source calls, and the edge's reads and status + where it has them. + """ + outcome = getattr(output, "outcome", None) + execution = {} if outcome is None else dict(outcome.execution) + monitor = execution.get("boundary_monitor") or {} + edge = monitor.get("transverse") or {} + return { + "logical": dict(getattr(outcome, "logical_calls", {}) or {}), + "calls": execution.get("source_evaluations"), + "reads": edge.get("reads"), + "status": edge.get("transverse_status"), + } + + +def main(argv: list[str] | None = None) -> None: + """Measure the four variants of each case and print a summary. + + Parameters + ---------- + argv : list of str or None, optional + The arguments, by default the process's. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", action="append", required=True, choices=sorted(CASES)) + parser.add_argument("--rounds", type=int, default=6) + parser.add_argument("--backend", default="native") + parser.add_argument("--width", type=int, default=os.cpu_count() or 1) + parser.add_argument("--fraction", type=float, default=1.0) + parser.add_argument("--seed", type=int, default=44) + parser.add_argument("--out", type=Path) + arguments = parser.parse_args(argv) + shuffled = random.Random(arguments.seed) # noqa: S311 - the order, not a secret + lines: list[dict[str, Any]] = [ + { + "section": "record", + "backend": arguments.backend, + "width": arguments.width, + "fraction": arguments.fraction, + "rounds": arguments.rounds, + "cpus": sorted(os.sched_getaffinity(0)), + "load": os.getloadavg(), + "threads": { + key: os.environ.get(key) + for key in ("OMP_NUM_THREADS", "OMP_WAIT_POLICY", "SOLPROP_THREADS") + }, + } + ] + print(json.dumps(lines[0]), flush=True) # noqa: T201 + with warnings.catch_warnings(): + # A case whose light reaches its edge warns on purpose: its status is reported. + warnings.simplefilter("ignore", BoundaryWarning) + for case in arguments.case: + variants: dict[str, Simulation] = {} + for variant, (ramp, edge) in VARIANTS.items(): + variants[variant], seconds = _variant( + case, + arguments.backend, + arguments.width, + arguments.fraction, + ramp, + edge, + ) + line = { + "section": "construction", + "case": case, + "variant": variant, + "seconds": round(seconds, 4), + } + lines.append(line) + print(json.dumps(line), flush=True) # noqa: T201 + for index in range(arguments.rounds): + order = list(variants) + shuffled.shuffle(order) + for variant in order: + started = time.perf_counter() + output = variants[variant].run(progress="none") + wall = time.perf_counter() - started + line = { + "section": "run", + "case": case, + "variant": variant, + "round": index, + "wall_s": round(wall, 4), + **_took(output), + } + lines.append(line) + print(json.dumps(line), flush=True) # noqa: T201 + if arguments.out is not None: + arguments.out.write_text( + "".join(json.dumps(line) + "\n" for line in lines), encoding="utf-8" + ) + _summary(lines, arguments.case) + + +def _summary(lines: list[dict[str, Any]], cases: list[str]) -> None: + """Print each case's medians, with each variant against ``neither``. + + Parameters + ---------- + lines : list of dict + What was measured. + cases : list of str + The cases. + """ + for case in cases: + runs = [ + line + for line in lines + if line["section"] == "run" and line.get("case") == case + ] + walls = { + variant: [line["wall_s"] for line in runs if line["variant"] == variant] + for variant in VARIANTS + } + base = statistics.median(walls["neither"]) + parts = [] + for variant in VARIANTS: + median = statistics.median(walls[variant]) + spread = statistics.median(abs(wall - median) for wall in walls[variant]) + taken = next(line for line in runs if line["variant"] == variant) + parts.append( + f"{variant} {median:.3f}s ±{spread:.3f} " + f"({100 * (median / base - 1):+.1f}%) " + f"calls {taken['calls']} reads {taken['reads']}" + ) + print(f"{case}: " + "; ".join(parts), flush=True) # noqa: T201 + + +if __name__ == "__main__": + main() diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index 8d160817..e2cd922b 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -9,23 +9,27 @@ """ import dataclasses +import warnings from typing import Any, cast import numpy as np import pytest -from bounds import fft_bound +from bounds import fft_bound, higham_gamma from capability_routes import LENGTH, SPATIAL_GRID from capability_routes import ROUTES as CASES from solprop.backends.driver import StepDriver from solprop.backends.registry import available, select from solprop.driver import Simulation +from solprop.exceptions import BoundaryError, BoundaryWarning from solprop.grid import FreeGrid, LineGrid, RadialGrid +from solprop.modes import PlanarGrid from solprop.monitor import BoundaryCheck from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, LowStorageRungeKutta, Multistep, RungeKutta -from solprop.transverse_monitor import EdgeReading +from solprop.transverse_monitor import GUARD, EdgeReading +from test_transverse_reads import REREVIEW, packet_run pytestmark = pytest.mark.skipif( "native" not in available(), reason="native is not installed" @@ -279,6 +283,182 @@ def final() -> tuple[np.ndarray, int]: assert native.tobytes() == host.tobytes() +def _direct_node_energies(space: Any, state: np.ndarray) -> np.ndarray: + """Return each node's ``sum |E|^2`` by a plain transform, not the edge's code.""" + flat = state.reshape(state.shape[0], -1) + if isinstance(space, RadialGrid): + hankel = space.hankel + matrix = ( + hankel.inverse_scale + * (hankel.j1[:, None] / hankel.j1[None, :]) + * hankel.matrix + ) + return np.sum(np.abs(matrix @ flat) ** 2, axis=1) + if isinstance(space, PlanarGrid): + cube = flat.reshape(space.n_y, space.n_p, -1) + return np.sum(np.abs(np.fft.ifft(cube, axis=0)) ** 2, axis=(1, 2)) + cube = flat.reshape(space.n_y, space.n_x, -1) + return np.sum(np.abs(np.fft.ifftn(cube, axes=(0, 1))) ** 2, axis=2).ravel() + + +def _direct_faces(space: Any) -> tuple[np.ndarray, list[np.ndarray]]: + """Return the node weights and each face's mask, from the grid and SEP 40's rule.""" + if isinstance(space, RadialGrid): + r = np.asarray(space.r) + return np.asarray(space.radial_weights), [r >= (1 - GUARD) * space.outer_radius] + + def axis_faces(axis: np.ndarray, flat: np.ndarray) -> list[np.ndarray]: + outer = 0.5 * axis.size * float(axis[1] - axis[0]) + return [flat <= -(1 - GUARD) * outer, flat >= (1 - GUARD) * outer] + + if isinstance(space, PlanarGrid): + y = np.asarray(space.y) + return np.full(space.n_y, float(y[1] - y[0])), axis_faces(y, y) + x = np.asarray(space.x) + masks = axis_faces(x, np.tile(x, space.n_y)) + if not isinstance(space, LineGrid): + y = np.asarray(space.y) + masks += axis_faces(y, np.repeat(y, space.n_x)) + return np.asarray(space.spatial_weights), masks + + +@pytest.mark.parametrize("route", ROUTES) +def test_each_region_reads_what_its_definition_says(route: str) -> None: + # Against the definition of SEP 40 and a plain transform, not the edge's tables: two + # reducers agreeing on one wrongly built table wouldn't show the table is right. + simulation = _case(route) + problem = simulation.translate() + state = _noise_state(problem.shape, np.asarray(problem.grids.active_mask), 53) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading(backend._require_state(), edge, full=True) + finally: + backend.close() + assert native.faces is not None and native.totals is not None + weights, masks = _direct_faces(edge.space) + energies = _direct_node_energies(edge.space, state[0]) + budget = 4 * fft_bound(state.shape[1] * state.shape[3]) + expected = np.zeros(4) + for index, mask in enumerate(masks): + expected[index] = np.sum((weights * energies)[mask]) + np.testing.assert_allclose(native.faces, expected, rtol=budget) + np.testing.assert_allclose( + native.totals[0], np.sum(weights * energies), rtol=budget + ) + # A row no region uses is held at zero, so that nothing reads it by accident. + np.testing.assert_array_equal(native.totals[3], 0.0) + + +@pytest.mark.parametrize("route", ROUTES) +def test_the_grids_total_is_its_columns_by_parseval(route: str) -> None: + # Two independent paths to one number: the nodes' quadrature on the synthesis, and + # the columns' quadrature on the state, which doesn't go through a transform. + simulation = _case(route) + problem = simulation.translate() + state = _noise_state(problem.shape, np.asarray(problem.grids.active_mask), 59) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading(backend._require_state(), edge, full=True) + finally: + backend.close() + assert native.totals is not None and native.columns is not None + spectral = np.asarray(edge.space.spectral_weights) + budget = 4 * fft_bound(state.shape[1] * state.shape[3]) + if isinstance(edge.space, RadialGrid): + # An FFT is unitary to rounding and a discrete Hankel transform isn't: its + # columns are orthogonal only as far as its own matrix says, the largest + # eigenvalue of the Gram matrix's departure from the identity. + matrix = np.asarray(edge.reduction.synthesis()) + weights = np.asarray(edge.space.radial_weights) + scaled = np.sqrt(weights)[:, None] * matrix / np.sqrt(spectral)[None, :] + gram = scaled.T @ scaled - np.eye(spectral.size) + budget += float(np.max(np.abs(np.linalg.eigvalsh(gram)))) + np.testing.assert_allclose( + native.totals[0], np.sum(spectral * native.columns), rtol=budget + ) + + +def test_a_nearly_cancelling_radial_read_stays_within_its_absolute_budget() -> None: + # A narrow beam's synthesis at the edge is a dense sum that all but cancels, so its + # rounding is set by the terms' magnitudes, not by the small result: a bound + # relative to the result would fail here. The budget is Higham's for a dot product. + simulation = _case("radial") + problem = simulation.translate() + monitor = simulation._boundary_monitor() + assert monitor is not None + assert monitor.transverse is not None + space = monitor.transverse.space + assert isinstance(space, RadialGrid) + active = np.asarray(problem.grids.active_mask) + r = np.asarray(space.r) + narrow = np.exp(-((r / (0.1 * space.radius)) ** 2)).astype(complex) + beam = space.hankel.forward(narrow) + state = np.zeros(problem.shape, dtype=np.complex128) + state[0, :, 0, :] = beam[:, None] * active[None, :] + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading(backend._require_state(), edge, full=True) + finally: + backend.close() + assert native.faces is not None + host = edge.reduction.reading(state[0], full=True) + assert host.faces is not None + synthesis = np.asarray(edge.reduction.synthesis()) + flat = state[0].reshape(state.shape[1], -1) + weights, masks = _direct_faces(space) + terms = np.abs(synthesis) @ np.abs(flat) + values = np.abs(synthesis @ flat) + # Complex by real, then one sum of `n_r` terms, on each side of the comparison. + error = higham_gamma(2 * space.n_r + 4) + outer = masks[0] + per_node = np.sum(2 * error * values * terms + (error * terms) ** 2, axis=1) + bound = float(np.sum((weights * per_node)[outer])) + # The share at the edge is rounding: what makes this case the hard one. + total = float(np.sum(weights * np.sum(values**2, axis=1))) + assert host.faces[0] < 1e-20 * total + assert bound > 0.0 + assert abs(native.faces[0] - host.faces[0]) <= 2 * bound + + +def test_a_runner_reads_its_next_input_as_a_new_runner_would_after_a_stop() -> None: + # What a stopped point leaves in the backend mustn't reach the next one: the bound + # tables, the scratch and the clean columns are all reused. + def run(*inputs: np.ndarray) -> tuple[np.ndarray, dict[str, Any]]: + schedule = FixedSchedule.uniform( + 0.0, REREVIEW.length, REREVIEW.length / 16, save_planes=[REREVIEW.length] + ) + simulation = packet_run(REREVIEW, schedule=schedule, backend="native") + with simulation.runner() as runner, warnings.catch_warnings(): + # The leaving input warns on its way to the stop. + warnings.simplefilter("ignore", BoundaryWarning) + *earlier, last = inputs + for value in earlier: + with pytest.raises(BoundaryError): + runner.run(value, progress="none") + output = cast("Any", runner.run(last, progress="none")) + record = output.outcome.execution["boundary_monitor"]["transverse"] + return np.asarray(output["field"])[-1], cast("dict[str, Any]", record) + + schedule = FixedSchedule.uniform( + 0.0, REREVIEW.length, REREVIEW.length / 16, save_planes=[REREVIEW.length] + ) + base = packet_run(REREVIEW, schedule=schedule, backend="native") + leaving = np.asarray(base.translate().initial.field0)[0] + space = LineGrid(REREVIEW.half, REREVIEW.samples) + staying_physical = dataclasses.replace(REREVIEW, velocity=0.0).physical(space) + moving_physical = REREVIEW.physical(space) + # The same pulse without the tilt: it stays near the axis and nothing warns. + staying = ( + leaving + * (np.fft.fft(staying_physical) / np.fft.fft(moving_physical))[:, None, None] + ) + field_alone, alone = run(staying) + field_after, after = run(leaving, staying) + assert after["reads"] == alone["reads"] > 0 + assert after["edge_max"] == alone["edge_max"] + assert field_after.tobytes() == field_alone.tobytes() + + def test_a_field_free_default_run_crosses_no_state( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -290,9 +470,7 @@ def refused(self: StepDriver[Any], state: Any) -> np.ndarray: monkeypatch.setattr(NativeBackend, "_observed_field", refused) simulation = _case("cartesian") - simulation.settings = dataclasses.replace( - simulation.settings, fields="none", held_plane=False - ) + simulation.settings = dataclasses.replace(simulation.settings, fields="none") problem = simulation.translate() with simulation.runner() as runner: output = runner.run(np.asarray(problem.initial.field0)[0], progress="none") From 2024a2e5232cee13e516a79cbe416bd4832fd053 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 16:22:29 +0000 Subject: [PATCH 051/122] Count a state's crossings, and what reading the edge holds A byte count in native's crossing paths lets a test hold a field-free run to no transfer, whichever path would have made one. That test also now runs native: it had been running the route's default backend. The edge's memory row comes from the grid too, which an estimate adds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- benchmarks/edge_overhead.py | 16 ++-- native/include/solprop_native/core.hpp | 10 +++ native/src/bindings.cpp | 12 +++ native/src/general.cpp | 12 +++ native/src/solprop_native/__init__.py | 1 + native/src/solprop_native/_core.pyi | 1 + native/src/solprop_native/backend.py | 21 ++++- src/solprop/backends/driver.py | 18 +++++ src/solprop/driver/estimate.py | 17 +++- src/solprop/transverse_monitor.py | 24 ++++++ tests/backends/test_driver.py | 1 + tests/backends/test_native_edge_reading.py | 90 +++++++++++++++++++++- 12 files changed, 210 insertions(+), 13 deletions(-) diff --git a/benchmarks/edge_overhead.py b/benchmarks/edge_overhead.py index 21835869..df26d12d 100644 --- a/benchmarks/edge_overhead.py +++ b/benchmarks/edge_overhead.py @@ -55,7 +55,7 @@ def _variant( name: str, backend: str, width: int, fraction: float, ramp: bool, edge: bool ) -> tuple[Simulation, float]: - """Return a case's simulation with the ramp and the reads as asked, and its build time. + """Return a case's simulation with the ramp and the reads asked for, and its build. Parameters ---------- @@ -82,8 +82,8 @@ def _variant( simulation.settings, spatial_absorber=False ) if not edge: - # Keep the temporal monitor: the control is the edge's cost, not the whole check's. - simulation._transverse_edge = lambda *_: None # type: ignore[method-assign, assignment] # noqa: SLF001 + # Keep the temporal monitor: the control is the edge's cost, not the check's. + simulation._transverse_edge = lambda *_: None # type: ignore[method-assign, assignment] simulation.translate() return simulation, time.perf_counter() - started @@ -131,7 +131,7 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument("--seed", type=int, default=44) parser.add_argument("--out", type=Path) arguments = parser.parse_args(argv) - shuffled = random.Random(arguments.seed) # noqa: S311 - the order, not a secret + shuffled = random.Random(arguments.seed) lines: list[dict[str, Any]] = [ { "section": "record", @@ -147,7 +147,7 @@ def main(argv: list[str] | None = None) -> None: }, } ] - print(json.dumps(lines[0]), flush=True) # noqa: T201 + print(json.dumps(lines[0]), flush=True) with warnings.catch_warnings(): # A case whose light reaches its edge warns on purpose: its status is reported. warnings.simplefilter("ignore", BoundaryWarning) @@ -169,7 +169,7 @@ def main(argv: list[str] | None = None) -> None: "seconds": round(seconds, 4), } lines.append(line) - print(json.dumps(line), flush=True) # noqa: T201 + print(json.dumps(line), flush=True) for index in range(arguments.rounds): order = list(variants) shuffled.shuffle(order) @@ -186,7 +186,7 @@ def main(argv: list[str] | None = None) -> None: **_took(output), } lines.append(line) - print(json.dumps(line), flush=True) # noqa: T201 + print(json.dumps(line), flush=True) if arguments.out is not None: arguments.out.write_text( "".join(json.dumps(line) + "\n" for line in lines), encoding="utf-8" @@ -225,7 +225,7 @@ def _summary(lines: list[dict[str, Any]], cases: list[str]) -> None: f"({100 * (median / base - 1):+.1f}%) " f"calls {taken['calls']} reads {taken['reads']}" ) - print(f"{case}: " + "; ".join(parts), flush=True) # noqa: T201 + print(f"{case}: " + "; ".join(parts), flush=True) if __name__ == "__main__": diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index d33f6ee4..f2d5aaba 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -82,6 +82,14 @@ struct MonitorSums { } }; +/** A state's whole-state transfers, in bytes, each way (solprop SEP 44): what lets a + test hold a field-free run to no transfer at all, whichever path would have made one. + */ +struct CrossedBytes { + std::size_t to_host{}; + std::size_t from_host{}; +}; + /** What the transverse edge reads of a state (solprop SEP 44): four numbers per weight table, and each mode's energy. */ struct EdgeNumbers { @@ -960,6 +968,8 @@ class GeneralState { void bind_edge(const std::vector& faces, const std::vector& clip, const std::vector& totals, const std::vector& synthesis, std::size_t first_row); + /** The bytes through this state's crossings since it was built, each way. */ + [[nodiscard]] CrossedBytes crossed_bytes() const noexcept; /** The edge's region sums and each mode's energy, where the state lives. */ EdgeNumbers edge_reading(bool whole, bool totals); /** The threads a large crossing takes (`crossing.hpp`), from solprop's budget. */ diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index 6a0da2b5..c346f4b1 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -1385,6 +1385,12 @@ class BoundState { throw std::logic_error("the edge's reading requires the general native state"); general_state_->bind_edge(faces, clip, totals, synthesis, first_row); } + native::CrossedBytes crossed_bytes() { + require_open(); + if (!general_state_) + throw std::logic_error("the crossing count requires the general native state"); + return general_state_->crossed_bytes(); + } native::EdgeNumbers edge_reading(const bool whole, const bool totals) { require_open(); if (!general_state_) @@ -2938,6 +2944,12 @@ NB_MODULE(_core, module_) { } return real_output(std::move(values), 1); }) + // solprop SEP 44: the bytes through the state's crossings, each way. + .def("crossed_bytes", + [](BoundState& self) { + const auto crossed = self.crossed_bytes(); + return nb::make_tuple(crossed.to_host, crossed.from_host); + }) // solprop SEP 44: the transverse edge's reading, where the state lives. .def( "bind_edge", diff --git a/native/src/general.cpp b/native/src/general.cpp index 1986680a..6dc83c15 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -2184,6 +2184,9 @@ class GeneralWorkspace { const std::vector& totals, const std::vector& synthesis, std::size_t first_row); EdgeNumbers edge_reading(bool whole, bool totals); + // The bytes through this state's crossings since it was built, each way (solprop SEP + // 44). + [[nodiscard]] CrossedBytes crossed_bytes() const noexcept { return crossed_; } std::vector field_to_host() const; void field_to_host(std::span destination) const; void cross_to_host(const ComplexView2& source, std::span destination) const; @@ -2888,6 +2891,7 @@ class GeneralWorkspace { // #440); empty unbound. ByteView occupancy_buckets_; std::size_t occupancy_count_ = 0; + mutable CrossedBytes crossed_; // What the transverse edge's reading sums with, once a monitor is bound (SEP 44). RealView2 edge_faces_, edge_clip_, edge_totals_, edge_synthesis_; RealView edge_columns_; @@ -4680,6 +4684,7 @@ void GeneralWorkspace::set_initial(const std::span field, if (!noise.empty()) require_extent(noise.size(), field.size(), "noise"); upload_span(execution_space_, crossing_ring_, current_, field); + crossed_.from_host += field.size_bytes(); if (noise.empty()) { if (problem_.modified_noise) graph::deep_copy(execution_space_, noise_, Complex{}); @@ -4689,6 +4694,7 @@ void GeneralWorkspace::set_initial(const std::span field, throw std::invalid_argument( "a native noise field is valid only for modified noise"); upload_span(execution_space_, crossing_ring_, noise_, noise); + crossed_.from_host += noise.size_bytes(); has_noise_ = true; } mask(current_); @@ -6380,6 +6386,7 @@ void GeneralWorkspace::adams_rows_to_host(const std::string& name, throw std::invalid_argument("the Adams history row '" + name + "' has the wrong extent"); execution_space_.fence("solprop_native Adams rows_to_host"); + crossed_.to_host += size * sizeof(Complex); if (crossing::takes_ring(size * sizeof(Complex))) { crossing_ring_.to_host(destination, view.data(), size * sizeof(Complex)); return; @@ -15045,6 +15052,7 @@ std::vector GeneralWorkspace::field_to_host() const { // element loop, so a large state crosses once (a device's one transfer). void GeneralWorkspace::cross_to_host(const ComplexView2& source, const std::span destination) const { + crossed_.to_host += destination.size_bytes(); if (crossing::takes_ring(destination.size_bytes())) { crossing_ring_.to_host(destination.data(), source.data(), destination.size_bytes()); return; @@ -15085,6 +15093,7 @@ void GeneralWorkspace::stage_to_host(const std::size_t stage, "stage destination"); execution_space_.fence("solprop_native stage_to_host"); const auto selected = Kokkos::subview(stages_, stage, Kokkos::ALL, Kokkos::ALL); + crossed_.to_host += destination.size_bytes(); if (crossing::takes_ring(destination.size_bytes())) { crossing_ring_.to_host(destination.data(), selected.data(), destination.size_bytes()); @@ -15955,6 +15964,9 @@ void GeneralState::bind_edge(const std::vector& faces, const CpuTeamScope team(workspace_->cpu_team()); workspace_->bind_edge(faces, clip, totals, synthesis, first_row); } +CrossedBytes GeneralState::crossed_bytes() const noexcept { + return workspace_->crossed_bytes(); +} EdgeNumbers GeneralState::edge_reading(const bool whole, const bool totals) { const CpuTeamScope team(workspace_->cpu_team()); return workspace_->edge_reading(whole, totals); diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py index bae04e4a..9be92f52 100644 --- a/native/src/solprop_native/__init__.py +++ b/native/src/solprop_native/__init__.py @@ -183,6 +183,7 @@ def _band_request() -> int: "_boundary_energies", "_dense_stages", "_edge_reading", + "edge_memory", "_finish", "_hoist", "_low_storage_engine", diff --git a/native/src/solprop_native/_core.pyi b/native/src/solprop_native/_core.pyi index 61325abc..ec3714ab 100644 --- a/native/src/solprop_native/_core.pyi +++ b/native/src/solprop_native/_core.pyi @@ -290,6 +290,7 @@ class State: synthesis: npt.NDArray[np.float64], first_row: int, ) -> None: ... + def crossed_bytes(self) -> tuple[int, int]: ... def edge_reading( self, whole: bool, totals: bool ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: ... diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 4a812221..e2818b33 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -141,7 +141,11 @@ require_device_ready, ) from solprop.translate.tdse import TDSEPurpose -from solprop.transverse_monitor import EdgeReading, TransverseEdge +from solprop.transverse_monitor import ( + EdgeReading, + TransverseEdge, + transverse_edge_bytes, +) from . import ( PROC_BIND, @@ -1121,6 +1125,21 @@ def _bind_monitor(self, monitor: BoundaryMonitor | None) -> None: int(np.argmax(tables.rows)), ) + def edge_memory(self, space: object) -> int: + """Return what reading the edge holds in the state's memory space (SEP 44). + + Parameters + ---------- + space : object + The run's transverse grid. + + Returns + ------- + int + The bytes of a `transverse_edge` ledger row, from the grid alone. + """ + return transverse_edge_bytes(cast("Any", space)) + def _edge_reading( self, state: _core.State, diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index 34221f5d..e679a04d 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -1924,6 +1924,24 @@ def _read_transverse(self, monitor: BoundaryMonitor, *, plane: bool) -> None: if read: edge.read(reading, self._z) + def edge_memory(self, space: object) -> int: + """Return what reading the edge holds in the state's memory space. + + What :func:`solprop.driver.estimate.estimate` adds: it attaches no monitor. + + Parameters + ---------- + space : object + The run's transverse grid. + + Returns + ------- + int + Zero by default: a read on the host holds nothing beside the state there. + """ + del space + return 0 + def _edge_reading( self, state: _StateT, diff --git a/src/solprop/driver/estimate.py b/src/solprop/driver/estimate.py index 3ec286b2..8834e12d 100644 --- a/src/solprop/driver/estimate.py +++ b/src/solprop/driver/estimate.py @@ -17,11 +17,14 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from functools import partial -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Any, Final, cast +from solprop.backends.driver import StepDriver from solprop.backends.registry import select from solprop.driver.run import field_retention from solprop.exceptions import DriverError +from solprop.grid import FreeGrid, RadialGrid +from solprop.modes.slab import PlanarGrid from solprop.stats import CATALOGUE from solprop.stepper import LowStorageRungeKutta, Multistep from solprop.translate.problem import KernelProblem @@ -194,6 +197,18 @@ def estimate( ) try: memory = sum(backend.memory_ledger().values()) + # Bound only when a monitor attaches, which an estimate doesn't do. A requested + # collar still reads a host copy (SEP 44). + design = problem.transverse + if ( + settings.boundary_check is not None + and simulation.medium is not None + and isinstance(simulation.spatial_grid, RadialGrid | FreeGrid | PlanarGrid) + and not (design is not None and design.has_collar) + ): + memory += cast("StepDriver[Any]", backend).edge_memory( + simulation.spatial_grid + ) backend.set_initial(problem.initial.field0, None, z=problem.initial.z) step, evaluations = _timed_step(backend, problem) step() diff --git a/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py index 540a64c5..467d7049 100644 --- a/src/solprop/transverse_monitor.py +++ b/src/solprop/transverse_monitor.py @@ -52,6 +52,7 @@ "HostEdgeReduction", "TransverseEdge", "TransverseStatus", + "transverse_edge_bytes", ] logger = logging.getLogger(__name__) @@ -266,6 +267,29 @@ def _guard_width(space: RadialGrid | FreeGrid | PlanarGrid) -> float: return GUARD * min(widths) +def transverse_edge_bytes(space: RadialGrid | FreeGrid | PlanarGrid) -> int: + """Return what a native read of the edge holds resident, from the grid alone. + + Three tables of four binary64 rows on the nodes, the columns' energies, and radially + the synthesis. A run reading no edge holds none of it. + + Parameters + ---------- + space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid + The run's grid. + + Returns + ------- + int + The bytes. + """ + columns = int(np.asarray(space.spectral_weights).size) + if isinstance(space, RadialGrid): + return 8 * (12 * space.n_r + columns + space.n_r**2) + nodes = space.n_y if isinstance(space, PlanarGrid) else space.n_y * space.n_x + return 8 * (12 * nodes + columns) + + def _radial_inverse(space: RadialGrid) -> Float[np.ndarray, "n_r n_r"]: """Return the radial grid's synthesis from its spectral columns to its nodes. diff --git a/tests/backends/test_driver.py b/tests/backends/test_driver.py index 76a3e979..656130a0 100644 --- a/tests/backends/test_driver.py +++ b/tests/backends/test_driver.py @@ -870,6 +870,7 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): "_finish": "(self, state: _StateT, dz: float) -> _StateT", "_bind_monitor": "(self, monitor: solprop.monitor.BoundaryMonitor | None) -> None", "_boundary_energies": "(self, state: _StateT) -> solprop.monitor.BoundaryEnergies", + "edge_memory": "(self, space: object) -> int", "_edge_reading": ( "(self, state: _StateT, edge: solprop.transverse_monitor.TransverseEdge, *, " "faces: bool = True, current: bool = False, full: bool = False" diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index e2cd922b..1030e444 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -28,7 +28,7 @@ from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, LowStorageRungeKutta, Multistep, RungeKutta -from solprop.transverse_monitor import GUARD, EdgeReading +from solprop.transverse_monitor import GUARD, EdgeReading, transverse_edge_bytes from test_transverse_reads import REREVIEW, packet_run pytestmark = pytest.mark.skipif( @@ -470,10 +470,94 @@ def refused(self: StepDriver[Any], state: Any) -> np.ndarray: monkeypatch.setattr(NativeBackend, "_observed_field", refused) simulation = _case("cartesian") - simulation.settings = dataclasses.replace(simulation.settings, fields="none") + simulation.settings = dataclasses.replace( + simulation.settings, fields="none", backend="native" + ) problem = simulation.translate() + field = np.asarray(problem.initial.field0)[0] with simulation.runner() as runner: - output = runner.run(np.asarray(problem.initial.field0)[0], progress="none") + output = runner.run(field, progress="none") + assert isinstance(runner.backend, NativeBackend) + state = cast("Any", runner.backend)._native_state + to_host, from_host = state.crossed_bytes() outcome = cast("Any", output).outcome edge = outcome.execution["boundary_monitor"]["transverse"] assert edge["reads"] > 0 + # Refusing `_observed_field` can't see a direct `field_to_host`, so native's own count + # says it too: nothing to the host, and the one load from it. + assert to_host == 0 + assert from_host == field.nbytes + + +def test_the_crossing_count_sees_a_direct_copy() -> None: + simulation = _case("cartesian") + problem = simulation.translate() + state = _noise_state(problem.shape, np.asarray(problem.grids.active_mask), 61) + backend, _ = _attached(simulation, state) + try: + native = backend._native_state + assert native.crossed_bytes() == (0, state.nbytes) + native.field_to_host() + assert native.crossed_bytes() == (state.nbytes, state.nbytes) + finally: + backend.close() + + +@pytest.mark.parametrize("route", ROUTES) +def test_the_edge_holds_memory_only_once_a_monitor_binds_it(route: str) -> None: + # A check-off run pays nothing, and the formula an estimate adds is the row itself. + simulation = _case(route) + problem = simulation.translate() + backend = cast( + "Any", + select( + "native", + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ), + ) + try: + assert backend.memory_ledger()["transverse_edge"] == 0 + backend.set_initial(problem.initial.field0, None) + assert backend.memory_ledger()["transverse_edge"] == 0 + monitor = simulation._boundary_monitor() + assert monitor is not None + assert monitor.transverse is not None + backend._bind_monitor(monitor) + expected = transverse_edge_bytes(monitor.transverse.space) + assert backend.memory_ledger()["transverse_edge"] == expected + assert backend.edge_memory(monitor.transverse.space) == expected + finally: + backend.close() + + +def test_an_estimate_counts_the_edge_only_for_a_run_that_reads_it() -> None: + # An estimate attaches no monitor, so it adds the row from the grid: against the same + # problem's own ledger before anything binds, it is exactly that row or nothing. + def difference(check: BoundaryCheck | None) -> tuple[int, Any]: + simulation = _case("cartesian") + simulation.settings = dataclasses.replace( + simulation.settings, boundary_check=check, backend="native" + ) + problem = simulation.translate() + backend = select( + "native", + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ) + try: + held = sum(backend.memory_ledger().values()) + finally: + backend.close() + return simulation.estimate().memory - held, simulation + + added, simulation = difference(BoundaryCheck(budget=1.0, stop_factor=1.0)) + monitor = simulation._boundary_monitor() + assert monitor is not None + assert monitor.transverse is not None + assert added == transverse_edge_bytes(monitor.transverse.space) + assert difference(None)[0] == 0 From d297e5031ce98a9bf9b3f74c72caad1e40c016f2 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 16:24:52 +0000 Subject: [PATCH 052/122] Test the estimate's edge row by a sentinel Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/backends/test_native_edge_reading.py | 39 ++++++++-------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index 1030e444..021d2f45 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -483,8 +483,8 @@ def refused(self: StepDriver[Any], state: Any) -> np.ndarray: outcome = cast("Any", output).outcome edge = outcome.execution["boundary_monitor"]["transverse"] assert edge["reads"] > 0 - # Refusing `_observed_field` can't see a direct `field_to_host`, so native's own count - # says it too: nothing to the host, and the one load from it. + # Refusing `_observed_field` can't see a direct `field_to_host`, so native's own + # count says it too: nothing to the host, and the one load from it. assert to_host == 0 assert from_host == field.nbytes @@ -533,31 +533,20 @@ def test_the_edge_holds_memory_only_once_a_monitor_binds_it(route: str) -> None: backend.close() -def test_an_estimate_counts_the_edge_only_for_a_run_that_reads_it() -> None: - # An estimate attaches no monitor, so it adds the row from the grid: against the same - # problem's own ledger before anything binds, it is exactly that row or nothing. - def difference(check: BoundaryCheck | None) -> tuple[int, Any]: +def test_an_estimate_counts_the_edge_only_for_a_run_that_reads_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # An estimate attaches no monitor, so it adds the row from the grid. A sentinel, + # since a ledger's `fft_plan_heap_growth` is measured and differs between backends. + sentinel = 2**50 + + def estimated(check: BoundaryCheck | None) -> int: simulation = _case("cartesian") simulation.settings = dataclasses.replace( simulation.settings, boundary_check=check, backend="native" ) - problem = simulation.translate() - backend = select( - "native", - problem, - grid=simulation.grid, - modes=simulation.modes, - profiles=simulation.profiles, - ) - try: - held = sum(backend.memory_ledger().values()) - finally: - backend.close() - return simulation.estimate().memory - held, simulation + return simulation.estimate().memory - added, simulation = difference(BoundaryCheck(budget=1.0, stop_factor=1.0)) - monitor = simulation._boundary_monitor() - assert monitor is not None - assert monitor.transverse is not None - assert added == transverse_edge_bytes(monitor.transverse.space) - assert difference(None)[0] == 0 + monkeypatch.setattr(NativeBackend, "edge_memory", lambda _self, _space: sentinel) + assert estimated(BoundaryCheck(budget=1.0, stop_factor=1.0)) >= sentinel + assert estimated(None) < sentinel From e7f2b667dcb595b253b02b71ab9ce78c869ebc2d Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 16:27:17 +0000 Subject: [PATCH 053/122] SEP 44: revise in response to the AI review, with the maintainer's decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native collar accumulator and phase 4 go; SEP 40's 2 % limit is retired for as small as possible, reported; no public prototype. §5 specifies the reduction's contract, §4 its rounding budget, §6 its memory and each launch's path, §7 the matrix, and the cost is measured against the same implementation. Corrects two overclaims on #396 and #505, and a test the draft cited that hadn't exercised native. Refs #514. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/roadmap.md | 4 +- docs/dev/seps/sep-0044-native-edge-read.md | 444 ++++++++++++++++----- 2 files changed, 338 insertions(+), 110 deletions(-) diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 1e803b0c..de6981d9 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -92,5 +92,5 @@ The [SEP index](seps/index.md) lists every proposal and its current status. - [SEP 44 — Reading the transverse edge where the state lives](seps/sep-0044-native-edge-read.md): the boundary check's transverse reductions move into native memory on the radial, Cartesian, - line and planar routes, on CPU and device builds, so that a field-free native run crosses no - state-sized data. Judging stays in Python, and a requested collar's reductions move too. \ No newline at end of file + line and planar routes, on CPU and device builds, so that in a field-free native run the monitor brings no + state-sized data to the host. Judging stays in Python, and a requested collar's reductions move too. \ No newline at end of file diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index b723b5be..0ecafefd 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -25,12 +25,12 @@ The boundary check's transverse edge (SEP 40) is reduced in Python on a host copy of the state, on every backend. On a device that copy and those reductions take far longer than the march they watch. This SEP moves the edge's reductions into the native memory space on the -radial, Cartesian, line and planar routes, on CPU and device builds, so that a field-free -native run crosses no state-sized data. Native returns energies per node and per mode, and -judging stays in Python and shared with `numpy_ref`. A requested collar's strip and inward -current move too, unchanged in meaning. Success is judged by parity with `numpy_ref` to -rounding, no state crossing in a field-free run, and a check that costs about a fifth of the -march rather than eleven times it. +radial, Cartesian, line and planar routes, on CPU and device builds, so that in a field-free +native run the monitor brings no state-sized data to the host. Python gives native weight tables; +native returns a sum per region and each mode's energy; and judging stays in Python, shared with +`numpy_ref`. A requested collar's strip and inward current move too, unchanged in meaning. Success +is judged by agreement with `numpy_ref` within a stated rounding budget (§4), no monitor-induced +transfer of the state in a field-free run, and the gates of §7. ### 2. Motivation and scope @@ -46,15 +46,17 @@ A field-free point of a Kerr scan at 768 x 768 x 600 (5.27 GiB), through | Single-thread host reductions | 20.8 s | 5.1 s | | Temporal monitor's occupancy | 1.7 s | 11.2 s | -A longer window with 51 steps and 17 planes on the H200, at 384 x 384 x 600, goes from 3.95 s to -49.8 s. So the default check multiplies a scan's time by about twelve on the card a large scan -is rented for, and the guide tells users to turn it off, which gives up the check of the time -window as well. +About 5.0 s and 3.3 s of the first row is the configuration hash, which +[#508](https://github.com/LupoLab/solprop/pull/508) takes out, leaving a march of about 1.9 s and 1.8 s. As context, +not as a gate, a longer window with 51 steps and 17 planes on the H200, at 384 x 384 x 600, goes from 3.95 s to 49.8 s. +So the default check multiplies a scan's time by about twelve on the card a large scan is rented for, and the guide +tells users to turn it off, which gives up the check of the time window as well. [#510](https://github.com/LupoLab/solprop/issues/510) and [#502](https://github.com/LupoLab/solprop/issues/502) remove the wasted reads, the occupancy's -atomics and most of the crossing's cost without touching the seam. This SEP removes what is -left, which is structural: the state has to reach the host for each read at all. +atomics and most of the crossing's cost. With both, the default check takes 25 s on the H200 and +9.6 s on the MI325X on the table's window. This SEP removes what is left, which is structural: the +state has to reach the host for each read at all. **In scope.** The default read and the launch on all four routes; a requested collar's strip, inward current and rate; the seam they need; the removal of `READ_RESOLUTION`; and the bug in @@ -82,59 +84,114 @@ native. A read with no collar: 4. judges. The design page says this is left to a measurement (`docs/dev/backends.md`, "The transverse edge is -read on the host"), and SEP 40 §7.7 sets a 2 % limit that the measurements above exceed by two orders -of magnitude. [ADR-057](../decisions.md) and `_boundary_energies` are the pattern for a reduction that +read on the host"). SEP 40 §7.7 sets a 2 % limit on what the ramp and the edge cost a clean CPU run against the same +implementation without them, which [#396](https://github.com/LupoLab/solprop/issues/396) records as missed +on some cases, with the read ceiling and the read's host cost as the two causes. That limit is +not the table above: a device's cost is far larger, and it is measured against the same build's check-off point. By the maintainer's decision +(R4) this SEP retires that limit: the cost is to be as small as possible, and reported. +[ADR-057](../decisions.md) and `_boundary_energies` are the pattern for a reduction that runs where the state lives, with `numpy_ref`'s host form as the reference. -**Issues.** This fixes [#385](https://github.com/LupoLab/solprop/issues/385) for CUDA and HIP, -[#396](https://github.com/LupoLab/solprop/issues/396), -[#512](https://github.com/LupoLab/solprop/issues/512) and the time-window item of -[#505](https://github.com/LupoLab/solprop/issues/505). It needs +**Issues.** This fixes [#385](https://github.com/LupoLab/solprop/issues/385) for CUDA and HIP, and +[#512](https://github.com/LupoLab/solprop/issues/512). It takes the read's host cost out of +[#396](https://github.com/LupoLab/solprop/issues/396), whose read ceiling stays open there. It needs [#510](https://github.com/LupoLab/solprop/issues/510) first, and bears on [#388](https://github.com/LupoLab/solprop/issues/388), -[#386](https://github.com/LupoLab/solprop/issues/386) and -[#225](https://github.com/LupoLab/solprop/issues/225). +[#386](https://github.com/LupoLab/solprop/issues/386), +[#225](https://github.com/LupoLab/solprop/issues/225) and the time-window item of +[#505](https://github.com/LupoLab/solprop/issues/505), which wants a statistic this SEP doesn't add. ### 4. Theory -Nothing here changes what is measured. With $\tilde E_j(\omega_p)$ the state's column $j$ at -frequency plane $p$ and $E_n(\omega_p)$ its synthesis at node $n$, a read forms +Nothing here changes what is measured. With $\tilde E_m(\omega_p)$ the state's mode $m$ at frequency plane $p$ and +$E_n(\omega_p)$ its synthesis at node $n$, a read forms $$ -u_n = \sum_p |E_n(\omega_p)|^2, \qquad +u_n = \sum_{\text{pol}} \sum_p |E_n(\omega_p)|^2, \qquad c_m = \sum_{\text{pol}} \sum_p |\tilde E_{m,\text{pol}}(\omega_p)|^2, $$ -unweighted, summed over polarisation, with no Nyquist weighting. A face's share is -$\sum_{n \in F} w_n u_n / U$, with $w_n$ the node quadrature and $U = \sum_n w_n u_n$ at the launch, -and the spacing and the ramp's ledger read $\gamma_m c_m$, with $\gamma_m$ the column quadrature. All -of the masks and weights are the edge's, and neither $u_n$ nor $c_m$ depends on them. - -**Removing `READ_RESOLUTION`.** A read leaves out the planes holding together at most $10^{-3}$ of the -warning, so by Parseval a face reads low by at most $10^{-9}$ of the launch energy at the default budget. -With no planes left out, the reference and native both form the sums above exactly, and a face's reading -can only rise, by at most that. No test or verdict at the default budget is near it, and the maintainer has -decided this removal. - -**The inward current.** $J = \mathrm{Im}(E^* \partial_s E)/k$, rectified per frequency and per line -before it is summed, as SEP 40 defines it. Rectifying after summing would be a different quantity. Its -trapezoid in $z$ is unchanged. - -**Contraction.** $\mathrm{Re}\,v\,\mathrm{Im}\,s - \mathrm{Im}\,v\,\mathrm{Re}\,s$ can't cancel -exactly on any build, and no hard-mask contract rests on it, so it takes no strict block (ADR-049). Its -parity bound is $\varepsilon \sum |v||s|$ times the line's measure over $k\,U$. +unweighted, with no Nyquist weighting. A region's sum is $\sum_n W_n u_n$, for weights $W_n$ the edge chooses (§5). A +face's share is that sum over the launch's $U = \sum_n w_n u_n$, with $w_n$ the node quadrature, and the spacing and the +ramp's ledger read $\gamma_m c_m$, with $\gamma_m$ the column quadrature. Where the sums run over one node at a time, +$n$ on the planar route is a $y$ row, its slab channels summed with the polarisations. + +The two totals $\sum_n w_n u_n$ and $\sum_m \gamma_m c_m$ are one number by Parseval only as far as the transform is +unitary. An FFT is, to rounding. A discrete Hankel transform isn't: its columns are orthogonal only to its matrix's +own defect, the largest eigenvalue of $\Gamma^{-1/2} S^T W S\,\Gamma^{-1/2} - I$, which is 2.6e-9 at 32 nodes. Nothing +judged depends on their agreement, but a test of it has to allow that. + +**Removing `READ_RESOLUTION`.** A read leaves out the planes holding together at most $10^{-3}$ of the warning, so by +Parseval a face reads low by at most $10^{-3}\,b^2$ of the launch energy at a budget $b$, which is $10^{-9}$ at the default +budget. With no planes left out, the reference and native both form the sums above exactly, and a face's reading can +only rise, by at most that. So a state within that distance of a threshold can change its verdict. No existing witness +is that near one, and the maintainer has decided this removal. + +**The inward current.** $J = \mathrm{Im}(E^* \partial_s E)/k$, rectified per frequency and per line before it is summed, as +SEP 40 defines it. Rectifying after summing would be a different quantity. Its trapezoid in $z$ is unchanged. + +**Rounding budget.** Unit roundoff $u = 2^{-53}$, $\varepsilon = 2u$ and $\gamma_k = ku/(1 - ku)$ (`higham_gamma` in +`tests/bounds.py`). Each bound is absolute, on one reading; a comparison of two readings allows both. + +- *Synthesis.* On an FFT route, per plane, $\|\delta E\|_2 \le B \|E\|_2$ with $B$ = `fft_bound(samples)`. Radially it is a + dense dot product, so per node $|\delta E_n| \le \gamma_{2 n_r + 4} T_n$ with $T_n = \sum_m |S_{nm}||\tilde E_m|$, which + doesn't shrink when the sum cancels. +- *A region's sum.* Node by node, $|\delta |E_n|^2| \le 2|E_n||\delta E_n| + |\delta E_n|^2 + 2\varepsilon|E_n|^2$. On an FFT + route Cauchy–Schwarz over the plane turns the first two terms into $2B\sqrt{R_p P_p} + B^2 P_p$, with $R_p$ the region's + energy in the plane and $P_p$ the plane's: so a dark face's error is set by the plane, not by the face. The terms are + non-negative, so the reduction adds $\gamma_d \sum_n W_n u_n$, for an addition depth $d$ at most the number of terms. +- *A share.* Dividing by $U$ adds $2u$ and $U$'s own relative error. The judged share, less baseline and allowance, adds + their absolute errors. +- *The columns.* Non-negative sums of the stored values: $\gamma_{n_\text{pol} n_\omega + 2}$, relative, with no synthesis. +- *The inward current.* With $v$ and $\delta v$ as above and $s = (v_a - v_b)/\Delta$, the slope's error is set by the + neighbours and not by itself: $|\delta s| \le (|\delta v_a| + |\delta v_b|)/\Delta + u(|v_a| + |v_b|)/\Delta + u|s|$, as + `stencil_bound` says. Then $|\delta J| \le (|\delta v||s| + |v||\delta s| + 2\varepsilon|v||s|)/k$. Rectifying is + 1-Lipschitz, so a term's error stays below that bound, and an almost-zero current's error is that bound, not a share of the + current. The non-negative sum over lines adds $\gamma_d$ of it, and its trapezoid accumulates by `prefix_bound`. No + hard-mask contract rests on the cancellation in $\mathrm{Im}(v^* s)$, so it takes no strict block (ADR-049). ### 5. Proposed interface Nothing public changes, except that `solprop.transverse_monitor.READ_RESOLUTION` goes. A read's -numbers can rise by at most the bound above. `memory_ledger` gains a `transverse_edge` row. +numbers can rise by at most the bound in §4. `memory_ledger` gains a `transverse_edge` row. ```text -StepDriver._edge_reading(state, edge, *, faces, current, full) -> EdgeReading # host by default -EdgeReading(faces, clip, totals, columns, strip, current) # numbers only -EdgeTables(faces, clip, totals, rows) # four regions' weights on the nodes, from the edge +StepDriver._edge_reading(state, edge, *, faces=True, current=False, full=False) -> EdgeReading # host by default +EdgeReading(faces, clip, totals, columns, strip, current) # numbers only +EdgeTables(faces, clip, totals, rows) # from the edge +StepDriver.edge_memory(space) -> int # what reading holds resident +_core.State.crossed_bytes() -> (to_host, from_host) # a state's whole transfers ``` +**The tables and what comes back.** $n$ runs over the nodes: $n_r$ radially, $n_y n_x$ with $x$ fastest, $n_x$ on a line and +$n_y$ on the planar kind. $f$ runs over `TransverseEdge`'s faces in their order (`r`; `x-`, `x+`, `y-`, `y+`; `x-`, `x+`; +`y-`, `y+`), and a table's rows past the route's faces are zero. $E_f$ is a face's edge, the outer `GUARD` of the outer +size; $C_f$ is its clip, the outer `GUARD` of the requested size; $O$ is what lies outside the request; and $\alpha_n$ is a +requested collar's power rate, in 1/m. Each table has shape `(4, n_nodes)`. Each returned sum is $\sum_n (\text{weight})\,u_n$, +in the state's units squared times the node measure, not yet divided by the launch. + +| Returned | Weight on node $n$ | What reads it | Formed | +|---|---|---|---| +| `faces[f]` | $w_n$ on $E_f$ | the face's share, against its baseline | every reading with `faces` | +| `clip[f]` | $w_n$ on $C_f$ | the launch's clipping warning | `full` | +| `totals[0]` | $w_n$ | $U$, the launch energy | `full` | +| `totals[1]` | $\alpha_n w_n$ | `_collar_rate`, over $U$, in 1/m | `full`, and every read of a collar | +| `totals[2]` | $w_n$ on $O$ | the current's launch baseline | `full`, with a collar | +| `totals[3]` | zero | nothing: reserved, and tested zero | | + +Beside the sums, in a reading with `faces`: + +| Returned | Shape | Value | +|---|---|---| +| `columns[m]` | `(n_modes,)` | $c_m$, unweighted: the edge multiplies by $\gamma_m$. On the planar kind $m$ is $(y, p)$ | +| `strip[l]` | `(n_lines, 2)` | Each line's `(inward, windowed)` over the launch's measure, not divided by $U$; `None` without a collar. The edge takes the line with the largest inward share, and that same line's windowed value, not the largest windowed value | +| `current` | scalar | $\sum_l M_l \sum_{\text{rows}} \max(J, 0)$, with $M_l$ the line's measure, not divided by $U$; `None` unless asked | + +**The flags.** `faces=False` asks for the current alone, and the other fields are `None`. `current=True` asks for it beside +whatever else. `full=True` is a launch: every node is formed, and every row above is meaningful. Otherwise a read may form only +`EdgeTables.rows`, the rows the faces and a collar's rate touch, and leave the others out, so `totals[0]` and `totals[2]` +mean nothing at a read and aren't read. `totals[1]`'s rate is zero outside those rows, so it is exact there. + ### 6. Implementation strategies **Option A: only a faster crossing.** [#502](https://github.com/LupoLab/solprop/issues/502)'s ring takes @@ -154,8 +211,9 @@ temporal monitor's occupancy. each four regions' weights on the nodes, and native runs the temporal monitor's existing kernel (`monitor_sums`, in the scratch's own order, one reduction of five numbers) against them, returning twelve numbers and $c_m$. Native holds no face, mask or judgement, only rows of weights; there is no reducer per route, so a parity failure can lie -only in the synthesis. Corners count on two faces because two tables hold them. The price is the tables: 96 bytes a -node, 57 MiB at 768 x 768 against a 5.3 GiB state, and an upload per point until a runner shares them. +only in the synthesis. Corners count on two faces because two rows hold them. The price is the tables: 96 bytes a +node, 54 MiB at 768 x 768 against a 5.3 GiB state, and an upload per point until a runner shares them. **Memory**, below, +has the rest. **Option D: native also judges.** Returning to Python per read costs microseconds against a read's tens of milliseconds, and a stop needs Python anyway. Not worth moving. @@ -165,10 +223,13 @@ milliseconds, and a stop needs Python anyway. Not worth moving. `HostEdgeReduction`, deep-copyable because a lockstep batch copies its monitors, which `numpy_ref` and `mlx` use through the default `_edge_reading`. -**Launch.** It needs no state crossing. `_input_floor` is already a host array, so the noise's reading stays -the host reducer's. `_judge_ramp_band` needs `state - noise`: `set_initial` computes those clean column -powers before it adds the draw, as $\sum_\text{active} |f|^2 + \sum_\text{inactive} |n|^2$, because a supplied -realisation isn't masked. One corner keeps a single copy: a resume with no monitor record. +**Launch.** A fresh launch needs no state crossing. `_input_floor` is already a host array, so the noise's reading stays the +host reducer's. `_judge_ramp_band` needs the launch without its noise: for a fresh input-noise launch `set_initial` computes +those clean column powers before it adds the draw, as $\sum_\text{active} |f|^2 + \sum_\text{inactive} |n|^2$, because a supplied +realisation isn't masked. Other launches take their own path. With no noise, or modified noise, which never enters the +state, the columns are read directly. A resume with a record isn't judged, its state already carries the noise, and it +reads with `full=False`. A resume with no monitor record is judged as a launch, and because its state already carries the +noise and nothing recorded the parts, it keeps a single copy to subtract it: the one crossing left in this path. **Native, Cartesian, line and planar.** `separable_load_current`, whose scale makes the scratch equal the host's synthesis, on a scratch that is dead at a committed position on all three marches; then `monitor_sums` on the @@ -180,29 +241,88 @@ state, from the first row a read's regions can touch. It needs no scratch, so ne `modal_complex_`, whose non-crop bins the retained path relies on staying zero. It is slower at one thread than the host's BLAS (the cost below); a GEMM remains open to it if that ever matters. -**Collar.** Python computes each line's strides, window, inward bin range and onset triple, captured by value, -so native does no tie-breaking. The strip uses a persistent padded transform per axis. The current accumulates in -the commit path that `finish` and `adams_exponential_accept` share, into a device view, as the split collar's -removal does, and the next reading drains it. Judging happens only at reads, so `steps.append(1)` in the native -march's bounds can go, and a collar run stops returning to Python at every step. A Cartesian collar still pays one +**Collar.** Python computes each line's strides, window, inward bin range and onset triple, captured by value, so native +does no tie-breaking. The strip uses a padded transform per axis, in batches of planes (**Memory**, below). The current is read +at every commit, with `_edge_reading(state, edge, faces=False, current=True)`, and accumulates in Python as it does today. So +the host step stays one and a collar's march returns to Python at every commit, also as it does today: a return takes tens of +microseconds against a step of hundreds of milliseconds on a grid where any of this matters. A Cartesian collar still pays one transverse inverse per commit, as it does now on the host. +**The edge's record through a run.** Everything accumulated lives in `TransverseEdge`, native holding no integral or sample, so +the record is always through the last committed state, and reading it twice can't change it. + +- *When it's updated.* Only from `_take_step_stats`, after the mask and any split treatment and after the position advances, at + the same point as the periodic statistics. A rejected trial or a low-storage step abandoned in flight never reaches it, and + an interpolated observation doesn't call it. +- *A fresh launch.* Takes no sample of the current, as today: the first commit takes the first, and the launch's share is the + baseline. +- *A resume with a record.* `resume` restores `current`, `current_baseline`, `strip_baseline`, `strip_floor`, `strip_max` and + `returned_max`, and the launch takes a sample from the resumed state, so the next trapezoid continues from it without one being + dropped ([#512](https://github.com/LupoLab/solprop/issues/512)). A record from before this change has no `strip_floor`, which + reads as zero. +- *A resume with no record.* Judged as a new launch, as today. +- *A new input to a runner.* A new `TransverseEdge`, whose tables are bound again. Native's allocations are kept, and + nothing else carries over, including after a stop. + **Reusing the read's transform as the next step's first pass.** Provable on the chunked route and with input noise, but not on the Kerr volume route, whose first pass is a rank-3 inverse on spectral scratch, and that is the route this SEP is mainly for. It's left out unless a CPU measurement asks for it (Q2). -**Kernel contract.** New `StepDriver` members, so a schema bump under today's rule. `KernelProblem` doesn't change. +**Memory.** Every persistent resource here counts in `memory_ledger`'s `transverse_edge` row and in `Simulation.estimate`. + +- *Default read.* Three tables of 32 bytes a node, 96 in all, and 8 bytes a mode for the columns: 56,623,104 bytes (54 MiB) and + 4.5 MiB at 768 x 768. Radially, $n_r^2$ doubles more for the synthesis. They're allocated at the first `bind_edge`, in the + memory space, kept across a runner's inputs, and released at `close`. A run with no edge binds nothing and pays nothing. +- *On the host.* The edge's own tables are another 54 MiB, the upload's staging a transient 54 MiB, and each read's column result 4.5 + MiB. These aren't resident on a device and aren't in the ledger, which lists the state's memory space. A runner's points can share the + tables, since they depend only on the grid and the design. +- *`estimate`.* It reads the ledger before loading an input and attaches no monitor, so it asks the backend for these by formula from the grid + (`StepDriver.edge_memory`, `transverse_edge_bytes`), with no field read and no monitor built, and a requested collar's run adds none until phase 3. A test holds the formula to a bound backend's ledger row. +- *A collar's strip.* Its transform is padded twice over $m$ nodes along the normal. At the widest strip for a collar fraction + $f$, $m \approx n(1/4 + f)/(2(1 + f))$ across an axis of $n$ nodes, and twice that radially, where $n_r$ nodes span only $r$. + Transformed all at once, one Cartesian axis would take $2m \times n_\perp \times n_\text{pol} n_\omega \times 16$ bytes: at 768 x 768 x + 600 and $f = 1$, $m = 240$, that is 3.3 GiB, 62 % of the state. So it runs in batches of planes, capped at 256 MiB (45 planes + there), allocated at a collar's first read. The radial route needs 160 MiB at 1024 x 8192, and the planar 35 MiB at 768 rows + and 4800 channels and bins, both below the cap. These are formulas, to be measured in phase 3. + +**Kernel contract.** New `StepDriver` members, so phase 1 rewrites the frozen table in `tests/backends/test_driver.py`, and takes +the next kernel schema if the number still exists by then ([#508](https://github.com/LupoLab/solprop/pull/508) takes 46 and +[#516](https://github.com/LupoLab/solprop/pull/516) 47). +`KernelProblem` doesn't change. **Cost, estimated.** 30 to 50 ms a read at 768 x 768 x 600 on a data-centre GPU, against 4.3 s then on an H200 and against a step of about 230 ms. On CPU a read now transforms every plane, where the host read transformed a fifth to a third of them, but it loses the copy and the host's single-thread scan. -**Cost, on a prototype.** Phases 1 and 2 were prototyped on a branch to test Option E and that estimate, before review. -On an NVIDIA A40 (CUDA 12.8.1) at 384 x 384 x 600, eight fixed steps and four planes, `python -m benchmarks.device_split`: -the 8 reads took 0.41 s together, about 50 ms each, and the point took 6.6 s against 5.8 s with no check and 9.8 s with -#502's ring alone, with no state crossing. A40 timings say little of a data-centre card, whose reads this hasn't measured. On CPU, against -`main`, with #510 and #502 in, `python -m benchmarks ab`, Intel Xeon Gold 6240, 18 cores of one socket, passive -threads, three rounds of one repeat, exploratory on a shared machine: +**Cost, on a prototype.** Phases 1 and 2 were prototyped, before review, to test Option E and that estimate. By the maintainer's +decision (R7) the prototype isn't published, so these numbers are evidence for this proposal and don't stand in for §7: phase 2's pull +request carries the harness and repeats them. Baseline `main` at `e49925d1`; the prototype holds [#508](https://github.com/LupoLab/solprop/pull/508), +[#510](https://github.com/LupoLab/solprop/issues/510) and [#502](https://github.com/LupoLab/solprop/issues/502). Intel Xeon Gold 6240, 18 cores of one socket, +`OMP_WAIT_POLICY=passive`, on a shared machine. + +*The monitor's overhead on one implementation.* SEP 40 §7.7's control, `python -m benchmarks.edge_overhead --width 18`: each case run four ways in one process, +interleaved, against itself with neither the ramp nor the reads, keeping the temporal monitor. Medians, of 12 rounds on `line` and `planar`, 6 on `radial_filament_256` +and `kerr_beam_192`, and 3 on half of `cartesian_filament_128`, whose light reaches its edge. Spread is the median absolute deviation of the check-off wall. + +| native | check off, s | ramp | reads | both | added, s | on `main`: both, added | source calls or steps | reads | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| `line` | 0.225 ± 0.001 | −0.1 % | +13.2 % | +13.6 % | 0.031 | +19.7 %, 0.046 | 196 → 224 calls | 23 | +| `planar` | 0.124 ± 0.001 | 0.0 % | +5.8 % | +8.4 % | 0.010 | +13.3 %, 0.019 | 41 → 41 steps | 23 | +| `radial_filament_256` | 1.319 ± 0.006 | −0.7 % | +1.9 % | +2.2 % | 0.029 | +1.3 %, 0.022 | 128 → 128 steps | 21 | +| `kerr_beam_192` (a read at each of 4 steps) | 1.835 ± 0.006 | −0.1 % | +5.9 % | +6.3 % | 0.115 | +38.2 %, 1.186 | 16 → 16 calls | 4 | +| `cartesian_filament_128`, half | 54.48 ± 0.49 | −0.2 % | +2.2 % | +3.0 % | 1.61 | not run; +10.5 % in #396 | 301 → 308 calls | 24 | + +The percentages aren't comparable across the two builds, whose check-off times differ: `main`'s includes the hash. The added seconds are. Two costs come apart here. + +- *The read ceiling's extra steps* are unchanged, and on `line` they are all of it: 28 more source calls at the check-off rate is 0.032 s, against 0.031 s added. They stay in [#396](https://github.com/LupoLab/solprop/issues/396). +- *The read itself* is what this SEP changes. On `kerr_beam_192` it falls from about 296 ms to 29 ms, though `main`'s rounds there spread by 0.25 s on a shared machine. On `planar` it falls from about 0.8 ms to 0.4 ms. On the Cartesian filament, once its 7 more source calls are taken out, it falls from the 180 ms #396 records to about 14 ms by medians, which three rounds' spread of 0.5 s leaves anywhere up to about 35 ms. +- *Radial* may rise, from about 1.0 ms to 1.4 ms a read, where a BLAS product gave way to a plain loop. At 18 threads that's within this table's spread of 0.01 s, but at one thread below it is clear. + +Building and translating each variant is reported apart by the harness and isn't in these rows. The ramp costs nothing measurable anywhere. + +On `numpy_ref`, 6 rounds on the short cases, after removing plane skipping: `line` +13.8 % (+14.3 % on `main`), the same ceiling; `planar` −4.0 % (−2.5 %), within a spread of 0.1 s; `radial_filament_256` +0.9 %, 3 rounds. So removing plane skipping costs the host path nothing measurable on these cases. + +*Improvement over the old code.* `python -m benchmarks ab` against `main`, three rounds of one repeat, passive threads: | case | threads | wall, s | ratio (range) | field difference | |---|---:|---|---:|---:| @@ -215,51 +335,74 @@ threads, three rounds of one repeat, exploratory on a shared machine: | `line` | 16 | 0.296 → 0.286 | 0.934 (0.900–1.053) | 0 | | `planar` | 16 | 0.172 → 0.163 | 0.945 (0.937–0.980) | 0 | -The same work in every row. On a point of 256 x 256 x 48, sixteen fixed steps and two planes at sixteen threads, the -default check went from 2.4 s to 2.0 s against 1.8 s without it, and from 3.9 to 2.1 field volumes above the level before -the run against 1.5 without it. The prototype's read of one state agrees with `HostEdgeReduction`'s on all four routes, -at a launch and a read, within four times `tests/bounds.py`'s `fft_bound`; a read at every step under each march leaves the -final field bit for bit that of the same run read on the host; and a field-free default run finishes with -`_observed_field` refused, on the CPU and the A40. The wheel compiles for `AMD_GFX942` under ROCm 7.2.4. +The same work in every row. With [#510](https://github.com/LupoLab/solprop/issues/510) and [#502](https://github.com/LupoLab/solprop/issues/502) alone, `radial_filament_256` at one thread is 0.999, so its 1.036 is the native read's. + +*The whole point.* On an NVIDIA A40 (CUDA 12.8.1) at 384 x 384 x 600, 8 fixed steps and 4 planes, `python -m benchmarks.device_split 384`: the 8 reads took 0.41 s together, about 50 ms each, and the point took 6.6 s against 5.8 s with no +check and 9.8 s with [#502](https://github.com/LupoLab/solprop/issues/502)'s ring alone. A40 timings say little of a data-centre card, which hasn't run this. So §7's gates there are unmeasured. + +*Parity.* The prototype's tests pass on CPU, and those it had at the time on the A40; the wheel compiles for `AMD_GFX942` under ROCm 7.2.4. The transfer count hasn't run on a device. There, the evidence so far is the A40's profile of the point above, which shows no `_observed_field` call. ### 7. Verification and validation -- **Same-state parity.** New `tests/backends/test_native_edge_reading.py`: native's reading of a state against - `HostEdgeReduction` on that state's own host copy, which removes trajectory rounding and leaves the budgets of - `tests/bounds.py`. Over route, scheme and march, with adversarial probes: energy in one (polarisation, last - frequency) bin, an anisotropic box with light in one corner, `n_pol = 2`, `n_to != n_t` radially, a planar - `p != 0`, a small node chunk, and mixed inward and outward frequencies that tell per-row rectification from - rectifying a sum. +- **Same-state parity.** `tests/backends/test_native_edge_reading.py`: native's reading of a state against + `HostEdgeReduction` on that state's own host copy, which removes trajectory rounding and leaves the budget of §4, stated + through the helpers in `tests/bounds.py`. Over route, scheme and march. +- **Independent of the tables.** Two reducers agreeing on one wrong table would show nothing, so each region is also held to SEP 40's + definition and a plain transform, written in the test from the grid alone; the total to the columns by Parseval, a separate path, + allowing a radial grid's orthogonality defect (§4); one lit node to exactly its own faces and its weight; and a collar's rate to + its design at a lit node. +- **Adversarial probes.** A nearly cancelling radial synthesis against the absolute budget, whose relative error is large; energy + in one (polarisation, last frequency) bin; an anisotropic box with light in one corner; DC, Nyquist and inactive bins; `n_pol = 2`; + `n_to != n_t` radially; a planar `p != 0`; a small node chunk; an almost-zero inward current; and mixed inward and outward + frequencies, which tell per-row rectification from rectifying a sum. +- **The qualification matrix.** Field and envelope grids; input, modified and no noise, and a supplied unmasked realisation; + `numpy_ref`'s binary32 host path, which native refuses by name; fresh launches, launch-only interruptions, and resumes with and without a + monitor record. - **No interference.** A read forced at every commit, on a matched schedule, across the low-storage march with and without its stage register, Runge-Kutta with FSAL, the split and in-stage collars and both Adams formulations. - Final fields agree with the unread run within the rounding budget. -- **Whole runs.** `tests/backends/test_transverse_backends.py` extended to every route and to collars: the same stop, - read count and record as `numpy_ref`, including `current`, `strip_max` and `returned_max`. -- **No crossing.** A test that fails on `_observed_field` during a field-free watched run: none allowed, and one for a - resume with no record. A collar run's native calls are far fewer than its commits. -- **Device logic on CPU.** Both the team and host kernels compile everywhere, with `#if` only at the point of use, - so that CPU, ASan and UBSan run the device-shaped logic. A GTest beside them. + The final field is bit for bit that of the same run read on the host, which touches no scratch. +- **The record.** An interruption between edge reads, a snapshot and its resume, and two runner inputs, one of them after a stop, which must + read as a new runner would. +- **Whole runs.** `tests/backends/test_transverse_backends.py` extended to every route and to collars. Exact stops, read counts + and records against `numpy_ref` hold only on controlled witnesses, with matched histories and margins from the decision thresholds. + Other adaptive runs are held to an accuracy envelope ([testing.md](../testing.md) §3). +- **No monitor-induced transfer.** The promise is about what the monitor costs: requested fields, some statistics, interpolation and + a terminal checkpoint keep their own transfers. Refusing `_observed_field` can't see a direct `field_to_host()`, so native counts the bytes + through its crossing paths, each way, and a field-free default run through launch, reads and shutdown is held to none to the host, + and one state from it, the load. Collar commits join in phase 3. On a device, a trace of the same workload confirms it. +- **Memory.** `transverse_edge_bytes` against a bound backend's ledger row; a check-off run's row at zero. +- **Device logic.** Phases 1 and 2 add no device-shaped kernel: the sums are `monitor_sums`' existing branch, and the column pass + is one code on every build. So their device logic is exercised by running on a device, below. A device-shaped kernel in phase 3 has to be invoked + by a CPU test, not merely compiled beside the one that runs there. A GTest beside them. - **By hand on CUDA and HIP.** `pytest -m gpu` with the above mirrored, a probe above the device GEMM's shape, a volume-route probe and a collar. No CI lane runs these ([#354](https://github.com/LupoLab/solprop/issues/354)). -- **Benchmarks.** `python -m benchmarks.device_split` on an NVIDIA H200 and an AMD Instinct MI325X, against the table - in §2, and `python -m benchmarks ab` on `kerr_beam_192`, `kerr_beam_384`, `line`, `planar` and - `radial_filament_256` against 0.1.0 on an AMD EPYC 7763. Targets: under 1.25 times the check-off point on the longer - window above, and no state crossing. +- **Benchmarks.** Three questions, each with its own control. + - *The monitor's overhead on one implementation:* `python -m benchmarks.edge_overhead`, SEP 40 §7.7's four-way control, on `line`, + `planar`, `radial_filament_256`, `kerr_beam_192` and half of `cartesian_filament_128`, on native and on `numpy_ref`, reporting construction + apart, source calls and reads. It carries no percentage limit: by the maintainer's decision (R4) the cost is to be as small as possible, and + reported, with the read ceiling's steps kept apart from the read's own cost. + - *Improvement over the old code:* `python -m benchmarks ab` against `main`, and against 0.1.0 on an AMD EPYC 7763 as history. + - *The whole point on a device:* `python -m benchmarks.device_split 768`, on an NVIDIA H200 and an AMD Instinct MI325X: 8 fixed steps + and 4 planes, which is the ten-step rule's window and makes one read a step, the worst case. Gates: no state transfer; a read under + half a step; and the point under twice its check-off time, with the hash gone. The 51-step window is reported as context. ### 8. Documentation -`docs/dev/backends.md`, "The transverse edge is read on the host"; `docs/guide/setup/running.md`, "The boundary check on a -free-space scan", whose advice to pass `boundary_check=None` goes; `docs/guide/results/output.md` and -`docs/guide/free-space/model.md`, which say the check reads on the host; `docs/dev/kernel-contract.md`; and a ledger -entry in `docs/dev/benchmarks.md`. No gallery. +With the phase that changes what they say. Phase 1: `docs/guide/free-space/model.md`'s account of what a read leaves out, and the release +note for removing `READ_RESOLUTION`. Phase 2: `docs/dev/backends.md`, "The transverse edge is read on the host"; +`docs/guide/setup/running.md`, "The boundary check on a free-space scan", whose advice to pass `boundary_check=None` +goes; `docs/guide/results/output.md`; `docs/dev/kernel-contract.md`; and a ledger entry in `docs/dev/benchmarks.md`. Phase 3: a +collar's costs. No gallery. ### 9. Compatibility and impact -`READ_RESOLUTION`, public since 0.2's development, goes. Native's readings rise by at most $10^{-9}$ of the launch at the -default budget against the current behaviour, and match `numpy_ref` to rounding. A resumed collar run changes, because -[#512](https://github.com/LupoLab/solprop/issues/512) is fixed. The record gains `strip_floor`. The output schema's own -compatibility is whatever [#508](https://github.com/LupoLab/solprop/pull/508) settles. +`READ_RESOLUTION`, public since 0.2's development, goes. A read's shares rise by at most $10^{-3}\,b^2$ of the launch at a budget $b$ +($10^{-9}$ at the default budget) against the current behaviour, and match `numpy_ref` within §4's budget. So a state within that +distance of a threshold can change its verdict, though no existing witness is that near one. A resumed collar run changes, because +[#512](https://github.com/LupoLab/solprop/issues/512) is fixed. The record gains `strip_floor`, which is in the execution +record and outside the configuration hash, so under [#508](https://github.com/LupoLab/solprop/pull/508) it needs no other step; an +older file resumes with it at zero. SEP 40 §7.7's 2 % limit on the default's cost is retired by the maintainer's decision (R4). ### 10. Alternatives considered @@ -272,24 +415,21 @@ physics decision, left to [#388](https://github.com/LupoLab/solprop/issues/388). ### 11. Phases 1. **Python split.** `EdgeReading`, `EdgeTables`, `HostEdgeReduction`; `READ_RESOLUTION` goes; clean column powers; - [#512](https://github.com/LupoLab/solprop/issues/512). Native inherits the host path. Verify: the edge's tests, adapted + [#512](https://github.com/LupoLab/solprop/issues/512). Native inherits the host path. The new driver hook's frozen table, the kernel + schema if it still exists, the host path's manual changes and the release note land here. Verify: the edge's tests, adapted to pass readings. -2. **Native read, no collar.** All four routes on CPU, with same-state parity and the no-crossing test. Native falls back - to the host path when a collar is requested. -3. **Collar, called per step.** Lines, windows, plans, and `_edge_current`, with the host step still one, which pins parity - on every route. -4. **Collar, accumulated.** The commit hook; the host step goes. Verify the march's traces are unchanged. -5. **Device qualification.** CUDA and HIP compile, `pytest -m gpu`, and the timings of §7 on two data-centre GPUs. -6. **Documents and ledger.** §8, then `Final`. +2. **Native read, no collar.** All four routes on CPU, with same-state parity, the independent tests, the transfer count and the + memory row. Native falls back to the host path when a collar is requested. +3. **Collar, called per step.** Lines, windows, batched plans, and the current at each commit, with the host step still one, which + pins parity on every route. +4. **Device qualification.** CUDA and HIP compile, `pytest -m gpu`, and §7's gates on two data-centre GPUs. +5. **Documents and ledger.** What §8 hasn't already moved, then `Final`. ### 12. Open questions -- **Q1.** Whether `Simulation.estimate` should count a collar's strip buffers, as it does the held plane. Recommended: yes. -- **Q2.** Whether a CPU read, with every plane now transformed, stays within SEP 40's 2 % on a long run. If not, the - read-as-first-pass reuse of §6 comes back, on routes where it's provable. On the prototype it holds except for the - radial route at one thread, ×1.036 on a window (§6); a GEMM could take that, if it's worth a second code path. -- **Q3.** Whether an interrupted collar run's record should drain the native current on the way out, or may lag by a few - steps. Recommended: drain it. +- **Q1.** Whether `Simulation.estimate` should count what this SEP adds. Answered: yes, for every persistent resource (§6). +- **Q2.** Whether a CPU read, with every plane now transformed, stays within SEP 40's 2 %. Answered: the limit is retired (R4), and §6 reports the cost. +- **Q3.** Whether an interrupted collar run's record should drain a native current on the way out. Answered by not having one (R2, §6). ### 13. References @@ -304,6 +444,7 @@ No phase has been implemented. - 2026-11-04: Drafted. Tracking issue: [#514](https://github.com/LupoLab/solprop/issues/514). - 2026-11-04: GPT-6 (AI) reviewed the proposal at `a121c73c`; findings and questions below. +- 2026-11-04: Revised in response, with the maintainer's decisions on R2, R4 and R7. Phase 4 goes, and SEP 40 §7.7's 2 % limit is retired. ### AI review — 2026-11-04 @@ -358,6 +499,8 @@ contract and qualification details before acceptance. inputs, including one after failure. How are partially completed low-storage steps kept from changing the last committed monitor record? + **Maintainer's decision, 2026-11-04:** the current stays in Python, read natively per commit. There's no native accumulator, and phase 4 goes. + 3. **R3 — P2: derive bounds for the complete readings (§§4, 6, 7).** The stated current bound, proportional to `epsilon * sum(abs(v) * abs(s))`, addresses the product cancellation at supplied values, but does not account for synthesis, @@ -392,6 +535,8 @@ contract and qualification details before acceptance. excluded read-ceiling question; keep the remaining work in that issue or a linked successor rather than treating faster reductions as its full closure. + **Maintainer's decision, 2026-11-04:** the 2 % limit is arbitrary, and goes. The cost is to be as small as possible, and reported. The read ceiling's steps stay in #396. + 5. **R5 — P2: account for default tables as well as collar buffers (§§5, 6, Q1).** Q1 asks only about the collar's strip buffers, but Option E also adds tables to the default check. At 768 by 768, twelve binary64 rows occupy @@ -443,6 +588,8 @@ contract and qualification details before acceptance. `1e-3 * budget**2` and allow a changed verdict for a state within that distance of a threshold, even though the existing witnesses are not near one. + **Maintainer's decision, 2026-11-04:** no public prototype is needed. Only the maintainer's review remains, and it won't read that branch. + **Evidence and limits.** Read the proposal at `a121c73c`, its base at `e49925d1`, the existing host/native reduction and commit paths, SEP 40's definition and cost criterion, `tests/bounds.py`, the existing transverse tests, #396, #510, #512, #514 @@ -453,3 +600,84 @@ weighted node/column totals to rounding; a supplied unmasked-noise example gives identity. Checked the table-byte calculation and the check-on/off ratio above. No prototype, native build, GPU execution or performance measurement was run for this review. No new physical constants or literature coefficients were introduced. + +### Response to review — 2026-11-04 + +By the drafting agent, Claude Opus 5.5, to the AI review above. Part I is revised as stated. Where a point's premise +concerned resuming, [#508](https://github.com/LupoLab/solprop/pull/508) has since changed part of it: see **What #508 changes** +at the end. + +1. **R1: taken.** §1 no longer promises node energies. §5 now gives every table row and returned component a weight, a shape, a + scale and a reader, says what's unused, and defines `faces`, `current` and `full`. The answers to the questions asked: `columns` + is unweighted, and the edge applies $\gamma_m$. The planar kind's node is a $y$ row, with its channels summed like polarisations, and its + column is $(y, p)$. The strip returns each line's pair and the edge keeps the winner's own `windowed`. The collar's rate is `totals[1]`, + formed at a launch and at every read of a collar, which corrects §6's earlier wording. + + Independent tests are added to the prototype: `test_each_region_reads_what_its_definition_says` writes each region from SEP 40's rule and a + plain transform, without the edge's tables or reducer, and `test_the_grids_total_is_its_columns_by_parseval` reaches the total + by a path through no transform. The second found something the draft had wrong: **a radial grid's node total and column total agree only to + the discrete Hankel transform's orthogonality defect, 2.6e-9 at 32 nodes,** a property of the transform and not of a backend. Nothing judged depends on it. §4 + says so, and the test allows the defect computed from the grid's own matrix rather than a loosened tolerance. + +2. **R2: taken, by removing what it concerned, as the maintainer decided.** The lifecycle R2 sets out is what a native accumulator + would need. The reasons for one don't survive measurement: the saving was the return to Python at each commit, and Python's march control + costs 10–50 µs a call against a step of hundreds of milliseconds at the sizes where this matters. So the current stays in + Python, read natively per commit (§6), and old phase 4 goes (§11). The requests then read: + + - *Idempotent drain, retention across drains:* nothing to drain. `metadata()` reads `summary()`, and repeated reads are the same. + - *Reset on `set_initial`:* a run has a new `TransverseEdge`. Prototype: `test_a_runner_reads_its_next_input_as_a_new_runner_would_after_a_stop`, + bit for bit, after a stopped input. + - *Restore on resume, and the first trapezoid:* the point about the lost sample is correct, and the draft's `strip_floor` alone wouldn't + fix it. On a resume the launch takes a sample at the resumed state, so the next trapezoid starts there + (`test_a_resume_restores_a_collars_totals_and_continues_its_current`). + - *The initial-sample rule:* a fresh launch takes none, as today; stated in §6, since "unchanged" had left it implicit. + - *After the final mask and split treatment, never a rejected trial or an interpolated observation:* the read's one call site is + `_take_step_stats`, after `_finish`. + - *Partially completed low-storage steps:* they can't change the record. A step in flight hasn't reached that call site. + + Interruption between edge reads and snapshot/resume are added to §7. I haven't run those two yet. + +3. **R3: taken.** §4's new **Rounding budget** carries the error through synthesis, squaring and weighting, reduction, division by the launch norm, + the slope, rectification and the trapezoid, in $u$ and $\varepsilon$ as named, and it replaces the single product bound R3 criticised. The + concrete failure is right: four times `fft_bound` had no business bounding a radial dot product under cancellation. The prototype's + `test_a_nearly_cancelling_radial_read_stays_within_its_absolute_budget` now uses Higham's bound on $|S||\tilde E|$, on a share below 1e-20 of the + total. The almost-zero current is in §7's probes, for phase 3. Whole adaptive runs move to an accuracy envelope; exact parity is kept + for controlled witnesses. + +4. **R4: taken, with the limit retired, as the maintainer decided.** The criticism is correct: comparing the prototype with `main` says nothing about SEP 40's 2 %. + §6 now separates the three questions, and `python -m benchmarks.edge_overhead` is SEP 40's four-way control, on native and `numpy_ref`, with construction reported apart. Its table shows the + two causes #396 named coming apart. The read itself falls, from about 296 ms to 29 ms on `kerr_beam_192`, and on the Cartesian filament from 180 ms to about 14 ms. The ceiling's steps don't, and on `line` + they're all of the cost. The ramp costs nothing. `numpy_ref` loses nothing measurable from losing plane skipping. Radial at one thread is the one row that rises, ×1.036 against `main`, and §6 says + it's the native read's; a GEMM would be a second code path for it, which I haven't proposed. Several of these figures are close to their spread on a shared machine, and §6 gives it. + + On #396: "fixes" was wrong. The read's host cost goes, and the read ceiling stays open there, so §3 now says that and the SEP won't close + it. In re-reading §3 I found a second overclaim the review didn't raise: the SEP doesn't add the statistic that #505's time-window item asks for, so + that moves to "bears on". I also withdrew the sentence saying the device's cost exceeded SEP 40's limit, since that limit is a CPU control. + +5. **R5: taken.** The arithmetic was right and the draft's "57 MiB" wrong: it's 54 MiB. Q1 is answered yes for everything, and §6's new **Memory** states what is + resident, on the host and transient, who owns it and when it's released, and how `estimate` sees it without a monitor or a field: by + formula, held to the ledger by a test. A check-off run binds nothing, and the prototype's `test_the_edge_holds_memory_only_once_a_monitor_binds_it` holds that. The temporary memory asked about was larger than I'd assumed: a whole + axis at 768 x 768 x 600 would take 62 % of a state, so the strip now runs in batches of planes under a 256 MiB cap. Those are + formulas until phase 3 measures them. + +6. **R6: taken,** with one change of form. §7 now has the full matrix, and §6 states each launch path separately, including the resume that keeps a copy. The + no-crossing promise is scoped to monitor-induced transfers, and §7 replaces the `_observed_field` test's sole authority with a byte count in native's + crossing paths, each way, now in the prototype. + + **A correction this led to.** The version you reviewed said a field-free default run finishes with `_observed_field` refused "on the CPU and the A40". Adding the count showed that test + wasn't exercising native at all: it ran the route's default backend, `numpy_ref`. So that statement had no support when I wrote it. With the test fixed, it passes on CPU: nothing to the host, and one state from it. On a device the + evidence so far is only the A40's profile, which §6 now says, and the corrected test still has to run there. + + On device logic, I didn't take "both kernels compile everywhere" further, because Option E leaves nothing to take: phases 1 and 2 add + no device-shaped kernel, and §7 says so. The requirement stands for phase 3. + +7. **R7: taken, except a public prototype, which the maintainer decided isn't needed.** `benchmarks.device_split` is public with [#516](https://github.com/LupoLab/solprop/pull/516), and `benchmarks.edge_overhead` will be with phase 2's pull request, which repeats §6's numbers. + So those numbers are evidence for this proposal and not a verdict, and §6 says so. It now pins the baseline and states the commands and each measurement's spread. The qualification window is 8 steps and 4 planes, within the rule, and the 51-step run stays as + context, so no exception is requested. §7's gates moved from wall times to ratios and a read's share of a step, because #508's hash fix had + made the old absolute targets too easy. The kernel-schema change, the frozen table, the host manual and the release note move to phase 1 (§8, §11). + §4 and §9 now state the omission bound as $10^{-3}\,b^2$ and allow a changed verdict within it. + +**What #508 changes.** [#508](https://github.com/LupoLab/solprop/pull/508) keeps same-version resume and doesn't touch `TransverseEdge.resume`, so the +resume points above still stand. What differs: a resume now needs an exact configuration hash, and `strip_floor`, in the execution record, is outside +it, so a recorded field needs no other step, as §9 now says; an older file can still resume and reads it as zero. The held plane is gone, which removes +one reader of a low-storage step's state. The hash's cost leaves the check-off row of §2's table. It takes kernel schema 46. From a35774b774ea79d0a183e69ce3934222da6510e0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 16:29:01 +0000 Subject: [PATCH 054/122] Keep huge_page with the only code that uses it Clang's -Werror rejected it as unused on Windows, and would on macOS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/crossing.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/native/src/crossing.cpp b/native/src/crossing.cpp index 73baea40..e84f9b5c 100644 --- a/native/src/crossing.cpp +++ b/native/src/crossing.cpp @@ -27,9 +27,6 @@ constexpr bool device_build = true; constexpr bool device_build = false; #endif -/** The size huge pages come in, which a large buffer is aligned to. */ -constexpr std::size_t huge_page = std::size_t{2} << 20; - // Kokkos holds its copies to one thread at a time per instance, so the workers call the // vendor's synchronous copy, which is safe from many threads: it is what was measured. void device_read(void* destination, const void* source, const std::size_t bytes) { @@ -103,6 +100,8 @@ HostBuffer host_buffer(const std::size_t count) { const std::size_t bytes = count * sizeof(double); #if defined(__linux__) if (takes_ring(bytes)) { + // The size huge pages come in, which a large buffer is aligned to. + constexpr std::size_t huge_page = std::size_t{2} << 20; const std::size_t rounded = (bytes + huge_page - 1) / huge_page * huge_page; auto* pointer = static_cast(std::aligned_alloc(huge_page, rounded)); if (pointer == nullptr) From 28dd4187b2049cc0770e28022a0364df8d567b83 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 16:37:56 +0000 Subject: [PATCH 055/122] Drop what the merge left in the driver's frozen table Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/backends/test_driver.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/backends/test_driver.py b/tests/backends/test_driver.py index 86229ea7..3ac3e9da 100644 --- a/tests/backends/test_driver.py +++ b/tests/backends/test_driver.py @@ -870,7 +870,6 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): "_bind_monitor": "(self, monitor: solprop.monitor.BoundaryMonitor | None) -> None", "_boundary_energies": "(self, state: _StateT) -> solprop.monitor.BoundaryEnergies", "_next_step": "(self) -> float", - "march_snapshot_nbytes": "(self, z: float) -> int | None", "_observed_field": ( "(self, state: _StateT)" " -> jaxtyping.Complex[ndarray, 'n_batch n_modes n_pol n_omega']" @@ -1005,7 +1004,6 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): "_accepted_steps", "_clock", "_collar_quiet", - "_held_z", "_history", "_monitor", "_natural_count", From c1c4c6523df714477f50485e96d1c186321456a6 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 16:38:28 +0000 Subject: [PATCH 056/122] Let the probe type-check without native installed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- benchmarks/device_split.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/device_split.py b/benchmarks/device_split.py index 2cf4a4a4..bb6eef8a 100644 --- a/benchmarks/device_split.py +++ b/benchmarks/device_split.py @@ -205,7 +205,7 @@ def emit(section: str, **values: Any) -> None: sink.write(line + "\n") sink.flush() - import solprop_native # noqa: PLC0415 - loaded first by nothing else here + import solprop_native # noqa: PLC0415 # pyright: ignore[reportMissingImports] emit( "record", From e64ff4e02119edb88a34588b922ec44951825a34 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 16:56:04 +0000 Subject: [PATCH 057/122] Accept SEP 44 Refs #514. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/roadmap.md | 10 +++++----- docs/dev/seps/index.md | 2 +- docs/dev/seps/sep-0044-native-edge-read.md | 9 +++++---- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index de6981d9..a42f0507 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -35,6 +35,11 @@ being implemented. routes, bound response and other geometries remain follow-up work; the first route is a single-mode capillary with node quadrature. +- [SEP 44 — Reading the transverse edge where the state lives](seps/sep-0044-native-edge-read.md): + the boundary check's transverse reductions move into native memory on the radial, Cartesian, + line and planar routes, on CPU and device builds, so that in a field-free native run the monitor brings no + state-sized data to the host. Judging stays in Python, and a requested collar's reductions move too. + ## Draft enhancement proposals SEPs under discussion; their scope may change and implementation has not been accepted. @@ -89,8 +94,3 @@ The [SEP index](seps/index.md) lists every proposal and its current status. subtraction, a weak-response limit and local temporal integration. Its ensemble, data and integration questions must be settled before acceptance; shared-level ladders, vector saturation and repeated-window histories remain future work. - -- [SEP 44 — Reading the transverse edge where the state lives](seps/sep-0044-native-edge-read.md): - the boundary check's transverse reductions move into native memory on the radial, Cartesian, - line and planar routes, on CPU and device builds, so that in a field-free native run the monitor brings no - state-sized data to the host. Judging stays in Python, and a requested collar's reductions move too. \ No newline at end of file diff --git a/docs/dev/seps/index.md b/docs/dev/seps/index.md index 6c101a9d..e1ead761 100644 --- a/docs/dev/seps/index.md +++ b/docs/dev/seps/index.md @@ -35,7 +35,7 @@ Proposals that are active, in draft, accepted, in progress or deferred. | [17](sep-0017-gas-two-photon-ionisation.md) | Broadband two-photon continuum response beside strong-field ionisation | Accepted | Feature | | [21](archive/sep-0021-absorbing-boundaries.md) | Temporal boundaries: wider windows, absorption and the escaped field | Final | Change | | [32](sep-0032-raman-maxwell-bloch.md) | Raman Maxwell–Bloch dynamics | Draft | Feature | -| [44](sep-0044-native-edge-read.md) | Reading the transverse edge where the state lives | Draft | Change | +| [44](sep-0044-native-edge-read.md) | Reading the transverse edge where the state lives | Accepted | Change | SEP 13, the standalone numerical prerequisite for SEP 12, SEP 14, which carried it to the compiled backends, and SEP 16, which unified the multistep family and made it the diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index 0ecafefd..aed8ec06 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -6,12 +6,12 @@ | **Title** | Reading the transverse edge where the state lives | | **Author** | John Travers | | **Drafting agent** | Claude Opus 5.5 | -| **Status** | Draft | -| **Review status** | In review | -| **Reviewers** | GPT-6 (AI) | +| **Status** | Accepted | +| **Review status** | Reviewed | +| **Reviewers** | John Travers (human); GPT-6 (AI) | | **Type** | Change | | **Created** | 2026-11-04 | -| **Resolution** | — | +| **Resolution** | Accepted 2026-11-04, in [#515](https://github.com/LupoLab/solprop/pull/515) | | **Requires** | [SEP 40](archive/sep-0040-transverse-absorbing-boundaries.md) | | **Supersedes** | — | | **Superseded-by** | — | @@ -445,6 +445,7 @@ No phase has been implemented. - 2026-11-04: Drafted. Tracking issue: [#514](https://github.com/LupoLab/solprop/issues/514). - 2026-11-04: GPT-6 (AI) reviewed the proposal at `a121c73c`; findings and questions below. - 2026-11-04: Revised in response, with the maintainer's decisions on R2, R4 and R7. Phase 4 goes, and SEP 40 §7.7's 2 % limit is retired. +- 2026-11-04: John Travers (human) reviewed the revised proposal, and the maintainer accepted it. ### AI review — 2026-11-04 From 15ff3932251bae84858643aebdfab8c65e680e31 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 17:23:15 +0000 Subject: [PATCH 058/122] SEP 44 in progress: report phases 1 and 2 Refs #514. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/seps/index.md | 2 +- docs/dev/seps/sep-0044-native-edge-read.md | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/dev/seps/index.md b/docs/dev/seps/index.md index 213ba623..ab02210f 100644 --- a/docs/dev/seps/index.md +++ b/docs/dev/seps/index.md @@ -35,7 +35,7 @@ Proposals that are active, in draft, accepted, in progress or deferred. | [17](sep-0017-gas-two-photon-ionisation.md) | Broadband two-photon continuum response beside strong-field ionisation | Accepted | Feature | | [21](archive/sep-0021-absorbing-boundaries.md) | Temporal boundaries: wider windows, absorption and the escaped field | Final | Change | | [32](sep-0032-raman-maxwell-bloch.md) | Raman Maxwell–Bloch dynamics | Draft | Feature | -| [44](sep-0044-native-edge-read.md) | Reading the transverse edge where the state lives | Accepted | Change | +| [44](sep-0044-native-edge-read.md) | Reading the transverse edge where the state lives | In progress | Change | SEP 13, the standalone numerical prerequisite for SEP 12, SEP 14, which carried it to the compiled backends, and SEP 16, which unified the multistep family and made it the diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index aed8ec06..e40d07b6 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -6,7 +6,7 @@ | **Title** | Reading the transverse edge where the state lives | | **Author** | John Travers | | **Drafting agent** | Claude Opus 5.5 | -| **Status** | Accepted | +| **Status** | In progress | | **Review status** | Reviewed | | **Reviewers** | John Travers (human); GPT-6 (AI) | | **Type** | Change | @@ -15,7 +15,7 @@ | **Requires** | [SEP 40](archive/sep-0040-transverse-absorbing-boundaries.md) | | **Supersedes** | — | | **Superseded-by** | — | -| **Implementation** | — | +| **Implementation** | Claude Opus 5.5, on `feature/sep-0044-native-edge`: phases 1 and 2 done; phase 3 next | | **Manual pages** | — | ## Part I — Proposal @@ -438,7 +438,20 @@ physics decision, left to [#388](https://github.com/LupoLab/solprop/issues/388). ## Part II — Implementation record -No phase has been implemented. +### Phases 1 and 2 — 2026-11-04 + +The prototype §6 measured, carried onto this branch. It stacks on [#518](https://github.com/LupoLab/solprop/pull/518), [#517](https://github.com/LupoLab/solprop/pull/517), +[#516](https://github.com/LupoLab/solprop/pull/516) and [#508](https://github.com/LupoLab/solprop/pull/508), so it needs no kernel schema number. + +**What landed.** Phase 1: `EdgeReading`, `EdgeTables`, `HostEdgeReduction` and `StepDriver._edge_reading`; `READ_RESOLUTION` and `_occupied_planes` gone; clean column powers formed at the load; a resume restores a collar's current, baselines and peaks ([#512](https://github.com/LupoLab/solprop/issues/512)), and the record gains `strip_floor`. Phase 2: `bind_edge` and `edge_reading` in native, on all four routes; `crossed_bytes`, the `transverse_edge` ledger row, `edge_memory` and the estimate's use of it; `benchmarks.edge_overhead`. A requested collar still reduces a host copy, until phase 3. + +**Deviations from Part I.** None of design. §7's matrix isn't complete, as below. + +**Verification, on CPU.** With #508, #516, #517 and #518 merged in: `tests/backends/test_native_edge_reading.py`, `test_transverse_backends.py`, `test_driver.py`, `test_backend_protocol.py`, the driver's run and estimate modules and the protocol's doctests, 189 passed, 3 skipped; `test_transverse_monitor.py`, `test_transverse_reads.py`, `test_transverse_nonlinear.py`, `test_transverse_resolution.py`, `test_transverse_witnesses.py`, `test_split_crossing.py`, `test_monitor.py`, `test_seps.py` and `tests/translate/test_problem.py`, 613 passed, 3 skipped; `ruff`, `ruff format` and `basedpyright` clean. Native's `tests/`: 555 passed, 7 skipped. + +**Not yet done from §7.** Probes: DC, Nyquist and inactive bins, `n_to != n_t` radially, a planar `p != 0`, a small node chunk, and a collar's rate at a lit node. Matrix: field grids at the reading level, modified noise and a supplied unmasked realisation, `numpy_ref`'s binary32 path, and launch-only interruptions. The record: an interruption between edge reads, and a snapshot and its resume. The no-interference matrix's collars and stage-register variants. The radial row of `test_a_nearly_cancelling_radial_read_stays_within_its_absolute_budget` covers one narrow beam. These go with phase 3, where the collar joins them. No device has run this branch since the transfer count was added. + +**Open.** None for the maintainer. ## Part III — History @@ -446,6 +459,7 @@ No phase has been implemented. - 2026-11-04: GPT-6 (AI) reviewed the proposal at `a121c73c`; findings and questions below. - 2026-11-04: Revised in response, with the maintainer's decisions on R2, R4 and R7. Phase 4 goes, and SEP 40 §7.7's 2 % limit is retired. - 2026-11-04: John Travers (human) reviewed the revised proposal, and the maintainer accepted it. +- 2026-11-04: Implementation started on `feature/sep-0044-native-edge`; phases 1 and 2 reported in Part II. ### AI review — 2026-11-04 From 5782bb92d3f8777c40b6bba639fc37c1d9ca8cb0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:07:37 +0000 Subject: [PATCH 059/122] Read a requested collar's strip and current natively SEP 44 phase 3. One description of each line, EdgeLine, serves the host's reduction and native's, so they differ only in their arithmetic. A padded transform runs in batches of planes under 256 MiB, and the current reads the onset's three nodes. The strip, the current and the region sums share one transform at a commit. A collar's current is still accumulated in Python, at every commit, so a collar run brings no state to the host. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/include/solprop_native/core.hpp | 23 +- native/src/bindings.cpp | 47 ++- native/src/general.cpp | 310 +++++++++++++-- native/src/solprop_native/_core.pyi | 21 +- native/src/solprop_native/backend.py | 75 +++- src/solprop/backends/driver.py | 2 +- src/solprop/driver/estimate.py | 5 +- src/solprop/transverse_monitor.py | 416 ++++++++++++++------- tests/backends/test_native_edge_collar.py | 382 +++++++++++++++++++ tests/backends/test_native_edge_reading.py | 5 +- tests/test_transverse_monitor.py | 6 +- 11 files changed, 1110 insertions(+), 182 deletions(-) create mode 100644 tests/backends/test_native_edge_collar.py diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index d772b426..2d5eca37 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -94,6 +94,24 @@ struct CrossedBytes { struct EdgeNumbers { std::vector sums; std::vector columns; + /** A requested collar's `(inward, windowed)` per line. */ + std::vector strip; + /** A requested collar's rectified inward current. */ + double current{}; +}; + +/** A requested collar's lines, as Python describes them (solprop SEP 44): for each, its + axis (0 `r`, 1 `x`, 2 `y`), its count of strip nodes and those nodes, its window, + its inward bins `[first, last)`, the factor on its strip's sums and the measure on + its current, and its onset's node, before and after, with the distance between the + last two. An onset whose node is `no_onset` has no slope to read. */ +struct EdgeLines { + static constexpr std::size_t no_onset = static_cast(-1); + std::vector axes, counts, positions, inward, onsets; + std::vector windows, factors, measures, steps, scale; + double k_min{}; + /** The most one batch of a padded transform holds. */ + std::size_t batch_bytes{}; }; /** What a split-step collar's finish reads of the power it finds (solprop SEP 21, @@ -915,7 +933,10 @@ class GeneralState { /** The bytes through this state's crossings since it was built, each way. */ [[nodiscard]] CrossedBytes crossed_bytes() const noexcept; /** The edge's region sums and each mode's energy, where the state lives. */ - EdgeNumbers edge_reading(bool whole, bool totals); + /** A requested collar's lines, whose strip and current a reading then forms. */ + void bind_edge_lines(const EdgeLines& lines); + EdgeNumbers edge_reading(bool faces, bool whole, bool totals, bool strip, + bool current); /** The threads a large crossing takes (`crossing.hpp`), from solprop's budget. */ void set_crossing_workers(std::size_t workers) noexcept; [[nodiscard]] std::vector stage_to_host(std::size_t stage) const; diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index bc7ecdf1..88d97147 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -1358,8 +1358,13 @@ class BoundState { open_state().bind_edge(faces, clip, totals, synthesis, first_row); } native::CrossedBytes crossed_bytes() { return open_state().crossed_bytes(); } - native::EdgeNumbers edge_reading(const bool whole, const bool totals) { - return open_state().edge_reading(whole, totals); + void bind_edge_lines(const native::EdgeLines& lines) { + open_state().bind_edge_lines(lines); + } + native::EdgeNumbers edge_reading(const bool faces, const bool whole, + const bool totals, const bool strip, + const bool current) { + return open_state().edge_reading(faces, whole, totals, strip, current); } // Not `require_open`: this stays a no-op on a closed state, whose pointer is null. void set_crossing_workers(const std::size_t workers) noexcept { @@ -2866,16 +2871,46 @@ NB_MODULE(_core, module_) { "synthesis"_a.noconvert(), "first_row"_a) .def( "edge_reading", - [](BoundState& self, const bool whole, const bool totals) { + [](BoundState& self, const bool faces, const bool whole, const bool totals, + const bool strip, const bool current) { native::EdgeNumbers numbers; { const nb::gil_scoped_release release; - numbers = self.edge_reading(whole, totals); + numbers = self.edge_reading(faces, whole, totals, strip, current); } return nb::make_tuple(real_output(std::move(numbers.sums), 1), - real_output(std::move(numbers.columns), 1)); + real_output(std::move(numbers.columns), 1), + real_output(std::move(numbers.strip), 1), + numbers.current); + }, + "faces"_a, "whole"_a, "totals"_a, "strip"_a, "current"_a) + .def( + "bind_edge_lines", + [](BoundState& self, const IndexInput axes, const IndexInput counts, + const IndexInput positions, const RealInput windows, + const IndexInput inward, const RealInput factors, const RealInput measures, + const IndexInput onsets, const RealInput steps, const double k_min, + const RealInput scale, const std::size_t batch_bytes) { + native::EdgeLines lines; + lines.axes = index_vector(axes); + lines.counts = index_vector(counts); + lines.positions = index_vector(positions); + lines.windows = real_vector(windows); + lines.inward = index_vector(inward); + lines.factors = real_vector(factors); + lines.measures = real_vector(measures); + lines.onsets = index_vector(onsets); + lines.steps = real_vector(steps); + lines.k_min = k_min; + lines.scale = real_vector(scale); + lines.batch_bytes = batch_bytes; + const nb::gil_scoped_release release; + self.bind_edge_lines(lines); }, - "whole"_a, "totals"_a) + "axes"_a.noconvert(), "counts"_a.noconvert(), "positions"_a.noconvert(), + "windows"_a.noconvert(), "inward"_a.noconvert(), "factors"_a.noconvert(), + "measures"_a.noconvert(), "onsets"_a.noconvert(), "steps"_a.noconvert(), + "k_min"_a, "scale"_a.noconvert(), "batch_bytes"_a) .def( "set_crossing_workers", [](BoundState& self, const std::size_t workers) { diff --git a/native/src/general.cpp b/native/src/general.cpp index 5dd657cb..ce726815 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -245,6 +245,9 @@ using TransversePlan = KokkosFFT::Plan; // The hybrid planar kind transforms y alone, the trailing axis of (bins, n_p, n_y). using SlabPlan = KokkosFFT::Plan; +// A requested collar's padded transform, along the last axis of a batch (solprop SEP +// 44). +using StripPlan = KokkosFFT::Plan; // Each current keeps its own chunk accumulator, including stacked embedded // quadrature. Sharing the latter would mix currents between partial chunks. @@ -2183,7 +2186,22 @@ class GeneralWorkspace { void bind_edge(const std::vector& faces, const std::vector& clip, const std::vector& totals, const std::vector& synthesis, std::size_t first_row); - EdgeNumbers edge_reading(bool whole, bool totals); + void bind_edge_lines(const EdgeLines& lines); + EdgeNumbers edge_reading(bool faces, bool whole, bool totals, bool strip, + bool want_current); + // What reading the edge holds in the state's memory space (solprop SEP 44). + [[nodiscard]] std::size_t edge_bytes() const noexcept { + std::size_t total = + (edge_faces_.size() + edge_clip_.size() + edge_totals_.size() + + edge_synthesis_.size() + edge_columns_.size() + edge_scale_.size()) * + sizeof(double); + for (const auto& line : edge_lines_) + total += line.positions.size() * sizeof(std::size_t) + + line.window.size() * sizeof(double); + for (const auto& [shape, batch] : edge_batches_) + total += batch.buffer.size() * sizeof(Complex); + return total; + } // The bytes through this state's crossings since it was built, each way (solprop SEP // 44). [[nodiscard]] CrossedBytes crossed_bytes() const noexcept { return crossed_; } @@ -2896,6 +2914,23 @@ class GeneralWorkspace { RealView2 edge_faces_, edge_clip_, edge_totals_, edge_synthesis_; RealView edge_columns_; std::size_t edge_first_row_ = 0; + // A requested collar's lines, and one padded batch per distinct shape, kept across a + // runner's points. Native chooses none of what's in them. + struct EdgeLine { + std::size_t axis{}, nodes{}, rows{}, batch{}, first{}, last{}; + double factor{}, measure{}, step{}; + std::size_t onset{EdgeLines::no_onset}, before{}, after{}; + IndexView positions; + RealView window; + }; + struct EdgeBatch { + ComplexView2 buffer; + std::unique_ptr plan; + }; + std::vector edge_lines_; + std::map, EdgeBatch> edge_batches_; + RealView edge_scale_; + double edge_k_min_{}; // A large crossing's pinned chunks, once one has been made (`crossing.hpp`). mutable crossing::Ring crossing_ring_; [[nodiscard]] bool monitoring_split() const noexcept { @@ -14632,6 +14667,100 @@ MonitorSums monitor_sums(const char* label, const ExecutionSpace& execution, } // namespace namespace { +// The field on a line's node, in a row of the transverse scratch (solprop SEP 44): a +// row is a plane and a position across the line, and no route has a branch here. +struct EdgeScratchValue { + ComplexView3 scratch; + std::size_t units{}, across{}, row_stride{}, node_stride{}; + KOKKOS_INLINE_FUNCTION Complex operator()(const std::size_t row, + const std::size_t node) const { + return scratch.data()[(row / across) * units + (row % across) * row_stride + + node * node_stride]; + } +}; + +// The same radially: `sqrt(r) E`, a dense product per value, in a row of (pol, omega). +struct EdgeRadialValue { + RealView2 synthesis; + ComplexView2 current; + RealView scale; + std::size_t n_pol{}, n{}, modes{}; + KOKKOS_INLINE_FUNCTION Complex operator()(const std::size_t row, + const std::size_t node) const { + const auto pol = row / n; + const auto omega = row % n; + Complex value{}; + for (std::size_t mode = 0; mode < modes; ++mode) + value += synthesis(node, mode) * current(mode * n_pol + pol, omega); + return scale(node) * value; + } +}; + +// One line's `(inward, windowed)`, unscaled: a Kaiser-windowed transform along its +// normal, padded twice, batch by batch so that the buffer stays bounded. +template +MonitorSums edge_strip_line(const ExecutionSpace& execution, const Value& value, + const std::size_t rows, const std::size_t batch, + const std::size_t nodes, const std::size_t first, + const std::size_t last, const IndexView& positions, + const RealView& window, const ComplexView2& buffer, + StripPlan& plan) { + const std::size_t count = 2 * nodes; + MonitorSums sums; + for (std::size_t start = 0; start < rows; start += batch) { + launch::parallel_for( + "solprop_native.edge.strip_fill", + Kokkos::RangePolicy(execution, 0, batch), + KOKKOS_LAMBDA(const std::size_t row) { + // A short last batch leaves its other rows zero, whose energy is none. + const bool live = start + row < rows; + for (std::size_t index = 0; index < nodes; ++index) + buffer(row, index) = + live ? window(index) * value(start + row, positions(index)) : Complex{}; + for (std::size_t index = nodes; index < count; ++index) + buffer(row, index) = Complex{}; + }); + graph::execute(plan, buffer, buffer, KokkosFFT::Normalization::none); + MonitorSums part; + launch::parallel_reduce( + "solprop_native.edge.strip_sums", + Kokkos::RangePolicy(execution, 0, batch), + KOKKOS_LAMBDA(const std::size_t row, MonitorSums& update) { + for (std::size_t index = 0; index < count; ++index) { + const double power = squared_abs(buffer(row, index)); + update.values[1] += power; + if (index >= first && index < last) + update.values[0] += power; + } + }, + Kokkos::Sum(part)); + sums += part; + } + return sums; +} + +// One line's rectified inward current: `Im(E* dE/ds) / k`, rectified per row before +// it's summed, as a collar's current is defined (solprop SEP 40 section 5.3). +template +double edge_current_line(const ExecutionSpace& execution, const Value& value, + const std::size_t rows, const std::size_t node, + const std::size_t before, const std::size_t after, + const double step, const double k_min) { + double total = 0.0; + launch::parallel_reduce( + "solprop_native.edge.current", + Kokkos::RangePolicy(execution, 0, rows), + KOKKOS_LAMBDA(const std::size_t row, double& update) { + const Complex here = value(row, node); + const Complex slope = (value(row, after) - value(row, before)) / step; + const double flux = + (here.real() * slope.imag() - here.imag() * slope.real()) / k_min; + update += flux > 0.0 ? flux : 0.0; + }, + Kokkos::Sum(total)); + return total; +} + // The transverse edge's region weights (solprop SEP 44), from the rows given on: a // radial read synthesises only the outer rows its regions can touch. struct EdgeWeights { @@ -14684,12 +14813,91 @@ void GeneralWorkspace::bind_edge(const std::vector& faces, execution_space_.fence("solprop_native edge bind"); } +// Bind a requested collar's lines (solprop SEP 44), which Python describes: their +// nodes, windows, inward bins and onsets. A batch of one shape serves every line that +// has it. +void GeneralWorkspace::bind_edge_lines(const EdgeLines& lines) { + require_open(); + const auto n_lines = lines.axes.size(); + require_extent(lines.counts.size(), n_lines, "edge line counts"); + require_extent(lines.inward.size(), 2 * n_lines, "edge line bins"); + require_extent(lines.factors.size(), n_lines, "edge line factors"); + require_extent(lines.measures.size(), n_lines, "edge line measures"); + require_extent(lines.onsets.size(), 3 * n_lines, "edge line onsets"); + require_extent(lines.steps.size(), n_lines, "edge line steps"); + require_extent(lines.windows.size(), lines.positions.size(), "edge line windows"); + const auto planes = problem_.n_pol_state * problem_.n_omega; + edge_lines_.clear(); + edge_k_min_ = lines.k_min; + std::size_t offset = 0; + for (std::size_t index = 0; index < n_lines; ++index) { + EdgeLine line; + line.axis = lines.axes[index]; + line.nodes = lines.counts[index]; + line.rows = planes * (problem_.slab_geometry() ? problem_.n_p + : !problem_.separable_geometry() ? std::size_t{1} + : line.axis == 1 ? problem_.n_y + : problem_.n_x); + line.first = lines.inward[2 * index]; + line.last = lines.inward[2 * index + 1]; + line.factor = lines.factors[index]; + line.measure = lines.measures[index]; + line.step = lines.steps[index]; + line.onset = lines.onsets[3 * index]; + line.before = lines.onsets[3 * index + 1]; + line.after = lines.onsets[3 * index + 2]; + if (line.nodes != 0) { + const auto begin = lines.positions.begin() + static_cast(offset); + const auto end = begin + static_cast(line.nodes); + if (offset + line.nodes > lines.positions.size()) + throw std::invalid_argument("the edge's lines overrun their nodes"); + line.positions = IndexView("solprop_native.edge.line_nodes", line.nodes); + line.window = RealView("solprop_native.edge.line_window", line.nodes); + copy_vector(execution_space_, line.positions, + std::vector(begin, end)); + copy_vector(execution_space_, line.window, + std::vector( + lines.windows.begin() + static_cast(offset), + lines.windows.begin() + + static_cast(offset + line.nodes))); + offset += line.nodes; + const std::size_t count = 2 * line.nodes; + line.batch = + std::min(line.rows, std::max(1, lines.batch_bytes / + (sizeof(Complex) * count))); + auto& batch = edge_batches_[{line.batch, count}]; + if (batch.buffer.extent(0) == 0) { + batch.buffer = + ComplexView2(Kokkos::view_alloc(Kokkos::WithoutInitializing, + std::string("solprop_native.edge.strip")), + line.batch, count); + constexpr int along = -1; + batch.plan = std::make_unique(fft_space_for(batch.buffer.size()), + batch.buffer, batch.buffer, + KokkosFFT::Direction::forward, along); + } + } + edge_lines_.push_back(std::move(line)); + } + if (offset != lines.positions.size()) + throw std::invalid_argument("the edge's lines don't account for their nodes"); + if (lines.scale.empty()) { + edge_scale_ = {}; + } else { + edge_scale_ = RealView("solprop_native.edge.line_scale", lines.scale.size()); + copy_vector(execution_space_, edge_scale_, lines.scale); + } + execution_space_.fence("solprop_native edge lines"); +} + // The state's reading for the transverse edge: its region sums and each mode's energy, // with only those numbers crossing. With `whole` (a launch) every table on every node; -// otherwise the faces', and with `totals` a collar's, on the rows a read uses. The -// transverse scratch is free at a committed position: a step's first pass writes it -// again. -EdgeNumbers GeneralWorkspace::edge_reading(const bool whole, const bool totals) { +// otherwise the faces', and with `totals` a collar's, on the rows a read uses; and with +// a requested collar its strip and current, on the same transform. The transverse +// scratch is free at a committed position: a step's first pass writes it again. +EdgeNumbers GeneralWorkspace::edge_reading(const bool faces, const bool whole, + const bool totals, const bool strip, + const bool want_current) { require_open(); if (!initialized_) throw std::logic_error("no initial field has been loaded"); @@ -14699,12 +14907,16 @@ EdgeNumbers GeneralWorkspace::edge_reading(const bool whole, const bool totals) const auto n_pol = problem_.n_pol_state; const auto n = problem_.n_omega; EdgeNumbers reading; + const bool separable = problem_.separable_geometry(); + if (separable) + separable_load_current(); const auto take = [&reading](const MonitorSums& sums) { reading.sums.insert(reading.sums.end(), sums.values.data() + 1, sums.values.data() + 5); }; - if (problem_.separable_geometry()) { - separable_load_current(); + if (!faces) { + // Only what follows was asked for. + } else if (separable) { const auto scratch = transverse_; const auto units = transverse_units(); const auto planes = scratch.size() / units; @@ -14743,20 +14955,58 @@ EdgeNumbers GeneralWorkspace::edge_reading(const bool whole, const bool totals) if (whole || totals) take(sums("solprop_native.edge.totals", edge_totals_)); } - const auto energies = edge_columns_; - launch::parallel_for( - "solprop_native.edge.columns", - Kokkos::RangePolicy(execution_space_, 0, problem_.n_modes), - KOKKOS_LAMBDA(const std::size_t mode) { - double sum = 0.0; - for (std::size_t pol = 0; pol < n_pol; ++pol) - for (std::size_t index = 0; index < n; ++index) - sum += squared_abs(current(mode * n_pol + pol, index)); - energies(mode) = sum; - }); - execution_space_.fence("solprop_native edge columns"); - const auto host = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, energies); - reading.columns.assign(host.data(), host.data() + host.size()); + if (faces) { + const auto energies = edge_columns_; + launch::parallel_for( + "solprop_native.edge.columns", + Kokkos::RangePolicy(execution_space_, 0, problem_.n_modes), + KOKKOS_LAMBDA(const std::size_t mode) { + double sum = 0.0; + for (std::size_t pol = 0; pol < n_pol; ++pol) + for (std::size_t index = 0; index < n; ++index) + sum += squared_abs(current(mode * n_pol + pol, index)); + energies(mode) = sum; + }); + execution_space_.fence("solprop_native edge columns"); + const auto host = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, energies); + reading.columns.assign(host.data(), host.data() + host.size()); + } + // The same two kernels on any route: only where a row's value comes from differs. + const auto take_line = [&](const EdgeLine& line, const auto& value) { + if (strip) { + MonitorSums sums; + if (line.nodes != 0) { + auto& batch = edge_batches_.at({line.batch, 2 * line.nodes}); + sums = edge_strip_line(execution_space_, value, line.rows, line.batch, + line.nodes, line.first, line.last, line.positions, + line.window, batch.buffer, *batch.plan); + } + reading.strip.push_back(sums.values[0] * line.factor); + reading.strip.push_back(sums.values[1] * line.factor); + } + if (want_current && line.onset != EdgeLines::no_onset) + reading.current += + line.measure * edge_current_line(execution_space_, value, line.rows, + line.onset, line.before, line.after, + line.step, edge_k_min_); + }; + if (strip || want_current) { + for (const auto& line : edge_lines_) { + if (!separable) { + take_line(line, EdgeRadialValue{edge_synthesis_, current, edge_scale_, n_pol, n, + problem_.n_modes}); + } else if (problem_.slab_geometry()) { + take_line(line, EdgeScratchValue{transverse_, transverse_units(), 1, 0, 1}); + } else if (line.axis == 1) { + take_line(line, EdgeScratchValue{transverse_, transverse_units(), problem_.n_y, + problem_.n_x, 1}); + } else { + take_line(line, EdgeScratchValue{transverse_, transverse_units(), problem_.n_x, + 1, problem_.n_x}); + } + } + } return reading; } @@ -15413,10 +15663,7 @@ std::map GeneralWorkspace::base_memory_ledger() const (monitor_split_sum_.size() + monitor_found_.size()) * sizeof(SplitSums)}, {"statistics_flux_magnitude", statistics_magnitude_.size() * real_bytes}, {"host_crossing_ring", crossing_ring_.bytes()}, - {"transverse_edge", - (edge_faces_.size() + edge_clip_.size() + edge_totals_.size() + - edge_synthesis_.size() + edge_columns_.size()) * - real_bytes}, + {"transverse_edge", edge_bytes()}, {"statistics_outputs", (statistics_total_power_.size() + statistics_on_axis_.size() + statistics_packed_.size()) * real_bytes}, @@ -15657,6 +15904,9 @@ void GeneralWorkspace::close() noexcept { occupancy_buckets_ = {}; occupancy_count_ = 0; crossing_ring_.release(); + edge_batches_.clear(); + edge_lines_.clear(); + edge_scale_ = {}; edge_faces_ = {}; edge_clip_ = {}; edge_totals_ = {}; @@ -15967,9 +16217,15 @@ void GeneralState::bind_edge(const std::vector& faces, CrossedBytes GeneralState::crossed_bytes() const noexcept { return workspace_->crossed_bytes(); } -EdgeNumbers GeneralState::edge_reading(const bool whole, const bool totals) { +void GeneralState::bind_edge_lines(const EdgeLines& lines) { + const CpuTeamScope team(workspace_->cpu_team()); + workspace_->bind_edge_lines(lines); +} +EdgeNumbers GeneralState::edge_reading(const bool faces, const bool whole, + const bool totals, const bool strip, + const bool current) { const CpuTeamScope team(workspace_->cpu_team()); - return workspace_->edge_reading(whole, totals); + return workspace_->edge_reading(faces, whole, totals, strip, current); } void GeneralState::bind_occupancy(const std::vector& buckets, const std::size_t count) { diff --git a/native/src/solprop_native/_core.pyi b/native/src/solprop_native/_core.pyi index ec3714ab..04593341 100644 --- a/native/src/solprop_native/_core.pyi +++ b/native/src/solprop_native/_core.pyi @@ -291,9 +291,26 @@ class State: first_row: int, ) -> None: ... def crossed_bytes(self) -> tuple[int, int]: ... + def bind_edge_lines( + self, + axes: npt.NDArray[np.uint64], + counts: npt.NDArray[np.uint64], + positions: npt.NDArray[np.uint64], + windows: npt.NDArray[np.float64], + inward: npt.NDArray[np.uint64], + factors: npt.NDArray[np.float64], + measures: npt.NDArray[np.float64], + onsets: npt.NDArray[np.uint64], + steps: npt.NDArray[np.float64], + k_min: float, + scale: npt.NDArray[np.float64], + batch_bytes: int, + ) -> None: ... def edge_reading( - self, whole: bool, totals: bool - ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: ... + self, faces: bool, whole: bool, totals: bool, strip: bool, current: bool + ) -> tuple[ + npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64], float + ]: ... def set_crossing_workers(self, workers: int) -> None: ... def stage_to_host(self, stage: int) -> npt.NDArray[np.complex128]: ... def projected_to_host(self, coarse: bool = False) -> npt.NDArray[np.complex128]: ... diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index e3575c21..83e193aa 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -21,6 +21,7 @@ import numpy as np import numpy.typing as npt +from solprop import transverse_monitor from solprop._helpers import Helpers from solprop._interrupts import committing from solprop._process import acquire_host_blas, thread_budget @@ -75,7 +76,7 @@ ConvergenceError, HostMarchWarning, ) -from solprop.grid import TimeGrid +from solprop.grid import RadialGrid, TimeGrid from solprop.modes.protocol import Mode from solprop.monitor import BoundaryEnergies, BoundaryMonitor from solprop.noise import NoiseRealisation @@ -1107,7 +1108,7 @@ def _bind_monitor(self, monitor: BoundaryMonitor | None) -> None: ) self._bound_buckets = crossing.buckets edge = getattr(monitor, "transverse", None) - if edge is not None and not edge.every_step: + if edge is not None: # Weights and no faces: what it reads comes back to the edge's judging. tables = edge.tables synthesis = edge.reduction.synthesis() @@ -1120,6 +1121,49 @@ def _bind_monitor(self, monitor: BoundaryMonitor | None) -> None: else np.ascontiguousarray(synthesis, dtype=np.float64).reshape(-1), int(np.argmax(tables.rows)), ) + self._bind_edge_lines(edge) + + def _bind_edge_lines(self, edge: TransverseEdge) -> None: + """Hand native a requested collar's lines, as arrays (solprop SEP 44). + + Parameters + ---------- + edge : solprop.transverse_monitor.TransverseEdge + The run's edge, whose lines may be none. + """ + lines = edge.reduction.lines + axes = {"r": 0, "x": 1, "y": 2} + none = 2**64 - 1 + strip = [np.asarray(line.strip, dtype=np.uint64) for line in lines] + window = [np.asarray(line.window, dtype=np.float64) for line in lines] + onsets = [ + line.onset if line.onset is not None else (none, 0, 0) for line in lines + ] + space = edge.space + self._native_state.bind_edge_lines( + np.array([axes[line.axis] for line in lines], dtype=np.uint64), + np.array([array.size for array in strip], dtype=np.uint64), + np.concatenate(strip) if strip else np.empty(0, dtype=np.uint64), + np.concatenate(window) if window else np.empty(0, dtype=np.float64), + np.array([line.inward for line in lines], dtype=np.uint64).reshape(-1), + np.array( + [ + line.spacing / (2 * array.size) * line.measure + if array.size + else 0.0 + for line, array in zip(lines, strip, strict=True) + ], + dtype=np.float64, + ), + np.array([line.measure for line in lines], dtype=np.float64), + np.array(onsets, dtype=np.uint64).reshape(-1), + np.array([line.onset_step for line in lines], dtype=np.float64), + float(edge.k_min), + np.sqrt(np.asarray(space.r, dtype=np.float64)) + if lines and isinstance(space, RadialGrid) + else np.empty(0, dtype=np.float64), + transverse_monitor.STRIP_BATCH_BYTES, + ) def edge_memory(self, space: object) -> int: """Return what reading the edge holds in the state's memory space (SEP 44). @@ -1134,7 +1178,10 @@ def edge_memory(self, space: object) -> int: int The bytes of a `transverse_edge` ledger row, from the grid alone. """ - return transverse_edge_bytes(cast("Any", space)) + _, _, n_pol, n_omega = self._problem.shape + return transverse_edge_bytes( + cast("Any", space), self._problem.transverse, n_pol, n_omega + ) def _edge_reading( self, @@ -1166,19 +1213,27 @@ def _edge_reading( The reading, of which only numbers crossed. A requested collar still reduces a host copy of the state. """ - if edge.every_step: - return super()._edge_reading( - state, edge, faces=faces, current=current, full=full - ) if state is not self._native_state: raise BackendError("the read state does not belong to this backend") - sums, columns = state.edge_reading(full, False) + collar = edge.every_step + sums, columns, strip, rate = state.edge_reading( + faces, full, collar, faces and collar, current + ) + rate_value = float(rate) if current else None + if not faces: + return EdgeReading(current=rate_value) values = np.asarray(sums).reshape(-1) + if full: + clip, totals = values[4:8], values[8:12] + else: + clip, totals = None, values[4:8] if collar else None return EdgeReading( faces=values[:4], - clip=values[4:8] if full else None, - totals=values[8:12] if full else None, + clip=clip, + totals=totals, columns=np.asarray(columns).reshape(-1), + strip=np.asarray(strip).reshape(-1, 2) if collar else None, + current=rate_value, ) def _boundary_energies(self, state: _core.State) -> BoundaryEnergies: diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index da238eeb..c56ce737 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -2019,7 +2019,7 @@ def _edge_reading( return edge.reduction.reading( np.asarray(self._observed_field(state)[0]), faces=faces, - current=edge.current_scale if current else None, + current=current, full=full, ) diff --git a/src/solprop/driver/estimate.py b/src/solprop/driver/estimate.py index 82096d01..1f42dbb5 100644 --- a/src/solprop/driver/estimate.py +++ b/src/solprop/driver/estimate.py @@ -186,14 +186,11 @@ def estimate( ) try: memory = sum(backend.memory_ledger().values()) - # Bound only when a monitor attaches, which an estimate doesn't do. A requested - # collar still reads a host copy (SEP 44). - design = problem.transverse + # Bound only when a monitor attaches, which an estimate doesn't do (SEP 44). if ( settings.boundary_check is not None and simulation.medium is not None and isinstance(simulation.spatial_grid, RadialGrid | FreeGrid | PlanarGrid) - and not (design is not None and design.has_collar) ): memory += backend.edge_memory(simulation.spatial_grid) backend.set_initial(problem.initial.field0, None, z=problem.initial.z) diff --git a/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py index 467d7049..b616ae1f 100644 --- a/src/solprop/transverse_monitor.py +++ b/src/solprop/transverse_monitor.py @@ -267,16 +267,243 @@ def _guard_width(space: RadialGrid | FreeGrid | PlanarGrid) -> float: return GUARD * min(widths) -def transverse_edge_bytes(space: RadialGrid | FreeGrid | PlanarGrid) -> int: +@dataclass(frozen=True, eq=False) +class EdgeLine: + """A face's outward normal, as a requested collar's reductions walk it. + + One description for the host's reduction and native's, so that they differ only in + their arithmetic. Python chooses every node, window and bin here, and native does no + tie-breaking of its own. + + Attributes + ---------- + axis : {"r", "x", "y"} + The grid axis the line runs along. + measure : float + What a row's sum is multiplied by: the cross-section of one row. + strip : numpy.ndarray + The axis's nodes in the return strip, by increasing outward coordinate; empty + when there are too few of them to resolve a direction. + window : numpy.ndarray + The Kaiser(8) window on those nodes. + spacing : float + Their mean spacing, m. + inward : tuple of int + The padded transform's bins ``[first, last)``, inward at ``|q| >= 16 / s``. + onset : tuple of int or None + The axis nodes at the requested size and either side of it, in outward order; + ``None`` when the node has no neighbour on one side. + onset_step : float + The outward distance between those two neighbours, m. + """ + + axis: str + measure: float + strip: Int[np.ndarray, " m"] + window: Float[np.ndarray, " m"] + spacing: float + inward: tuple[int, int] + onset: tuple[int, int, int] | None + onset_step: float + + +def _requested_size(space: RadialGrid | FreeGrid | PlanarGrid) -> float: + """Return the requested half-size on the narrowest axis, m. + + Parameters + ---------- + space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid + The run's grid. + + Returns + ------- + float + The size, m. + """ + if isinstance(space, RadialGrid): + return space.radius + if isinstance(space, PlanarGrid): + return space.radius_y + if isinstance(space, LineGrid): + return space.radius_x + return min(space.radius_x, space.radius_y) + + +def _edge_line( + axis: str, + s: Float[np.ndarray, " n"], + measure: float, + bounds: tuple[float, float], + size: float, +) -> EdgeLine: + """Return one line's description, from its outward coordinate. + + Parameters + ---------- + axis : str + The grid axis. + s : numpy.ndarray + The outward coordinate of each of its nodes, m. + measure : float + What a row's sum is multiplied by. + bounds : tuple of float + The return strip, ``(a, b)`` in `s`, m. + size : float + The requested half-size, m. + + Returns + ------- + solprop.transverse_monitor.EdgeLine + The description. + """ + a, b = bounds + selected = np.nonzero((s >= a) & (s <= b))[0] + strip = np.empty(0, dtype=np.int64) + window = np.empty(0) + spacing = 0.0 + inward = (0, 0) + if selected.size >= 4: + strip = selected[np.argsort(s[selected])] + coordinate = s[strip] + spacing = float(np.mean(np.diff(coordinate))) + u = 2 * (coordinate - a) / (b - a) - 1 + window = np.i0(8.0 * np.sqrt(np.clip(1 - u * u, 0.0, 1.0))) / np.i0(8.0) + # solprop propagates as exp(-i k_z z) (the operator carries -i(k_z - k)), so + # a component exp(i q s) travels towards smaller s: inward is q > 0. + count = 2 * strip.size + q = 2 * np.pi * np.fft.fftfreq(count, d=spacing) + bins = np.nonzero(q >= 16.0 / (b - a))[0] + if bins.size: + inward = (int(bins[0]), int(bins[-1]) + 1) + node = int(np.argmin(np.abs(s - size))) + # A node without a neighbour on each side has no slope to read. + order = np.argsort(s) + position = int(np.nonzero(order == node)[0][0]) + onset = None + onset_step = 0.0 + if 0 < position < s.size - 1: + before, after = int(order[position - 1]), int(order[position + 1]) + onset = (node, before, after) + onset_step = float(s[after] - s[before]) + return EdgeLine(axis, measure, strip, window, spacing, inward, onset, onset_step) + + +def edge_lines( + space: RadialGrid | FreeGrid | PlanarGrid, design: SpatialAbsorberDesign | None +) -> tuple[EdgeLine, ...]: + """Return the lines a requested collar's strip and current run along. + + Parameters + ---------- + space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid + The run's grid. + design : solprop.spatial_absorber.SpatialAbsorberDesign or None + The run's transverse boundary. + + Returns + ------- + tuple of solprop.transverse_monitor.EdgeLine + One per direction along each axis; none without a collar. + """ + if design is None or not design.has_collar: + return () + size = _requested_size(space) + width = min(design.width) + bounds = (size - min(0.5 * width, 0.25 * size), size + width) + if isinstance(space, RadialGrid): + return (_edge_line("r", np.asarray(space.r), 2 * np.pi, bounds, size),) + if isinstance(space, PlanarGrid): + y = np.asarray(space.y) + return ( + _edge_line("y", y, 1.0, bounds, size), + _edge_line("y", -y, 1.0, bounds, size), + ) + x = np.asarray(space.x) + dy = 1.0 if isinstance(space, LineGrid) else float(space.y[1] - space.y[0]) + lines = [ + _edge_line("x", x, dy, bounds, size), + _edge_line("x", -x, dy, bounds, size), + ] + if not isinstance(space, LineGrid): + y = np.asarray(space.y) + dx = float(space.x[1] - space.x[0]) + lines += [ + _edge_line("y", y, dx, bounds, size), + _edge_line("y", -y, dx, bounds, size), + ] + return tuple(lines) + + +STRIP_BATCH_BYTES = 2**28 +"""The most a batch of a strip's padded transforms holds on the state's memory space.""" + + +def line_rows( + space: RadialGrid | FreeGrid | PlanarGrid, line: EdgeLine, n_pol: int, n_omega: int +) -> int: + """Return how many rows of values a line carries. + + Parameters + ---------- + space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid + The run's grid. + line : solprop.transverse_monitor.EdgeLine + The line. + n_pol, n_omega : int + The state's polarisations and bins. + + Returns + ------- + int + The rows: one per polarisation and bin, and per transverse position across the + line, or per slab channel on the planar kind. + """ + planes = n_pol * n_omega + if isinstance(space, RadialGrid): + return planes + if isinstance(space, PlanarGrid): + return planes * space.n_p + return planes * (space.n_y if line.axis == "x" else space.n_x) + + +def strip_batch(line: EdgeLine, rows: int) -> int: + """Return how many of a line's rows one batch of its padded transform takes. + + Parameters + ---------- + line : solprop.transverse_monitor.EdgeLine + The line, with nodes in its strip. + rows : int + Its rows in all. + + Returns + ------- + int + The rows in a batch, under :data:`STRIP_BATCH_BYTES`. + """ + return min(rows, max(1, STRIP_BATCH_BYTES // (16 * 2 * line.strip.size))) + + +def transverse_edge_bytes( + space: RadialGrid | FreeGrid | PlanarGrid, + design: SpatialAbsorberDesign | None, + n_pol: int, + n_omega: int, +) -> int: """Return what a native read of the edge holds resident, from the grid alone. - Three tables of four binary64 rows on the nodes, the columns' energies, and radially - the synthesis. A run reading no edge holds none of it. + Three tables of four binary64 rows on the nodes and the columns' energies; radially + the synthesis; and with a requested collar each line's nodes and window, and one + padded buffer per distinct batch. A run reading no edge holds none of it. Parameters ---------- space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid The run's grid. + design : solprop.spatial_absorber.SpatialAbsorberDesign or None + Its transverse boundary. + n_pol, n_omega : int + The state's polarisations and bins. Returns ------- @@ -285,9 +512,20 @@ def transverse_edge_bytes(space: RadialGrid | FreeGrid | PlanarGrid) -> int: """ columns = int(np.asarray(space.spectral_weights).size) if isinstance(space, RadialGrid): - return 8 * (12 * space.n_r + columns + space.n_r**2) - nodes = space.n_y if isinstance(space, PlanarGrid) else space.n_y * space.n_x - return 8 * (12 * nodes + columns) + total = 8 * (12 * space.n_r + columns + space.n_r**2) + else: + nodes = space.n_y if isinstance(space, PlanarGrid) else space.n_y * space.n_x + total = 8 * (12 * nodes + columns) + lines = edge_lines(space, design) + if isinstance(space, RadialGrid) and lines: + total += 8 * space.n_r # the scale of the radial line's values + buffers: set[tuple[int, int]] = set() + for line in lines: + total += 16 * line.strip.size + if line.strip.size: + rows = line_rows(space, line, n_pol, n_omega) + buffers.add((strip_batch(line, rows), 2 * line.strip.size)) + return total + sum(16 * rows * count for rows, count in buffers) def _radial_inverse(space: RadialGrid) -> Float[np.ndarray, "n_r n_r"]: @@ -322,13 +560,16 @@ class HostEdgeReduction: The run's grid. tables : solprop.transverse_monitor.EdgeTables What a state's node energies are summed with. - strip_bounds : tuple of float or None - A requested collar's return strip in the outward coordinate, m. + lines : tuple of solprop.transverse_monitor.EdgeLine + A requested collar's lines; none without one. + k_min : float + The smallest wavenumber over the requested band, rad/m. """ space: RadialGrid | FreeGrid | PlanarGrid tables: EdgeTables - strip_bounds: tuple[float, float] | None + lines: tuple[EdgeLine, ...] + k_min: float _inverse: np.ndarray | None = field(default=None, init=False) # The host's transverse transforms take the process's thread budget (ADR-052). _workers: int = field(default_factory=lambda: thread_budget().threads, init=False) @@ -338,7 +579,7 @@ def reading( state: Complex[np.ndarray, "n_modes n_pol n_omega"], *, faces: bool = True, - current: tuple[float, float] | None = None, + current: bool = False, full: bool = False, ) -> EdgeReading: """Return what the edge asked of a state. @@ -349,9 +590,8 @@ def reading( One batch member's spectral columns. faces : bool, optional Whether to read the nodes, the columns and any strip. - current : tuple of float or None, optional - ``(size, k_min)``: to read the current at the onset of that requested size, - m, on that wavenumber, rad/m. + current : bool, optional + Whether to read a collar's inward current. full : bool, optional Whether every node is wanted, as a launch wants, not only the read's rows. @@ -360,11 +600,11 @@ def reading( solprop.transverse_monitor.EdgeReading The reading. """ - rate = None if current is None else self.current(state, *current) + rate = self.current(state) if current else None if not faces: return EdgeReading(current=rate) nodes = self.node_power(state, full=full) - collar = self.strip_bounds is not None + collar = bool(self.lines) return EdgeReading( faces=self.tables.faces @ nodes, clip=self.tables.clip @ nodes if full else None, @@ -473,10 +713,10 @@ def column_power( """ return _row_energy(state.reshape(state.shape[0], -1)) - def lines( + def _axis_values( self, state: Complex[np.ndarray, "n_modes n_pol n_omega"] - ) -> list[tuple[np.ndarray, np.ndarray, float]]: - """Return the field along each face's outward normal, line by line. + ) -> dict[str, np.ndarray]: + """Return the field along each axis's nodes, line by line. Parameters ---------- @@ -485,35 +725,23 @@ def lines( Returns ------- - list of tuple - ``(s, values, measure)``: the outward coordinate of the line's nodes, m; - the field on them, ``(n_nodes, n_rows)`` with one row per frequency, - polarisation and transverse line (``sqrt(r) E`` radially); and the - measure each row's sum is multiplied by. + dict + By axis, the field on its nodes, ``(n_nodes, n_rows)`` with one row per + frequency, polarisation and transverse position (``sqrt(r) E`` radially). """ space = self.space flat = state.reshape(state.shape[0], -1) if isinstance(space, RadialGrid): - r = np.asarray(space.r) - return [ - (r, np.sqrt(r)[:, None] * (_radial_inverse(space) @ flat), 2 * np.pi) - ] + scale = np.sqrt(np.asarray(space.r))[:, None] + return {"r": scale * (_radial_inverse(space) @ flat)} if isinstance(space, PlanarGrid): cube = np.fft.ifft(flat.reshape(space.n_y, space.n_p, -1), axis=0) - rows = cube.reshape(space.n_y, -1) - y = np.asarray(space.y) - return [(y, rows, 1.0), (-y, rows, 1.0)] + return {"y": cube.reshape(space.n_y, -1)} cube = np.fft.ifftn(flat.reshape(space.n_y, space.n_x, -1), axes=(0, 1)) - x = np.asarray(space.x) - dy = 1.0 if isinstance(space, LineGrid) else float(space.y[1] - space.y[0]) - along_x = np.moveaxis(cube, 1, 0).reshape(space.n_x, -1) - lines = [(x, along_x, dy), (-x, along_x, dy)] + values = {"x": np.moveaxis(cube, 1, 0).reshape(space.n_x, -1)} if not isinstance(space, LineGrid): - y = np.asarray(space.y) - dx = float(space.x[1] - space.x[0]) - along_y = cube.reshape(space.n_y, -1) - lines += [(y, along_y, dx), (-y, along_y, dx)] - return lines + values["y"] = cube.reshape(space.n_y, -1) + return values def strip( self, state: Complex[np.ndarray, "n_modes n_pol n_omega"] @@ -535,40 +763,27 @@ def strip( ``(inward, windowed)`` per line, unweighted by the launch; zero on a line with too few nodes in the strip. """ - assert self.strip_bounds is not None - a, b = self.strip_bounds - resolved = 16.0 / (b - a) + values = self._axis_values(state) found = [] - for s, values, measure in self.lines(state): - select = (s >= a) & (s <= b) - if np.count_nonzero(select) < 4: + for line in self.lines: + if not line.strip.size: found.append((0.0, 0.0)) continue - order = np.argsort(s[select]) - coordinate = s[select][order] - spacing = float(np.mean(np.diff(coordinate))) - u = 2 * (coordinate - a) / (b - a) - 1 - window = np.i0(8.0 * np.sqrt(np.clip(1 - u * u, 0.0, 1.0))) / np.i0(8.0) - lines = window[:, None] * values[select][order] - count = 2 * coordinate.size - spectrum = np.abs(np.fft.fft(lines, count, axis=0)) ** 2 * spacing / count - q = 2 * np.pi * np.fft.fftfreq(count, d=spacing) - # solprop propagates as exp(-i k_z z) (the operator carries -i(k_z - k)), so - # a component exp(i q s) travels towards smaller s: inward is q > 0. + rows = line.window[:, None] * values[line.axis][line.strip] + count = 2 * line.strip.size + spectrum = ( + np.abs(np.fft.fft(rows, count, axis=0)) ** 2 * line.spacing / count + ) + first, last = line.inward found.append( ( - float(np.sum(spectrum[q >= resolved])) * measure, - float(np.sum(spectrum)) * measure, + float(np.sum(spectrum[first:last])) * line.measure, + float(np.sum(spectrum)) * line.measure, ) ) return np.array(found, dtype=np.float64).reshape(-1, 2) - def current( - self, - state: Complex[np.ndarray, "n_modes n_pol n_omega"], - size: float, - k_min: float, - ) -> float: + def current(self, state: Complex[np.ndarray, "n_modes n_pol n_omega"]) -> float: """Return the inward energy current across the collar's onset, rectified. ``J = Im(E* dE/dn) / k`` on the requested size's boundary, summed over its @@ -579,29 +794,23 @@ def current( ---------- state : numpy.ndarray One member's spectral columns. - size : float - The requested half-size on the narrowest axis, m. - k_min : float - The smallest wavenumber over the requested band, rad/m. Returns ------- float The current, unweighted by the launch. """ + values = self._axis_values(state) total = 0.0 - for s, values, measure in self.lines(state): - index = int(np.argmin(np.abs(s - size))) - # A node without a neighbour on each side has no slope to read. - order = np.argsort(s) - position = int(np.nonzero(order == index)[0][0]) - if position == 0 or position == s.size - 1: + for line in self.lines: + if line.onset is None: continue - before, after = order[position - 1], order[position + 1] - slope = (values[after] - values[before]) / float(s[after] - s[before]) + node, before, after = line.onset + along = values[line.axis] + slope = (along[after] - along[before]) / line.onset_step # Under exp(-i k_z z), Im(E* dE/ds) / k is the flux towards smaller s. - flux = np.imag(np.conj(values[index]) * slope) / k_min - total += float(np.sum(np.maximum(flux, 0.0))) * measure + flux = np.imag(np.conj(along[node]) * slope) / self.k_min + total += float(np.sum(np.maximum(flux, 0.0))) * line.measure return total @@ -694,12 +903,9 @@ def __post_init__(self) -> None: self._peak = [0.0] * len(self._faces) self._peak_raw = [0.0] * len(self._faces) self._baseline = [0.0] * len(self._faces) - collar = self.design is not None and self.design.has_collar self.tables = self._edge_tables() self.reduction = HostEdgeReduction( - space, - self.tables, - self._strip_bounds() if collar else None, + space, self.tables, edge_lines(space, self.design), self.k_min ) def _edge_tables(self) -> EdgeTables: @@ -725,7 +931,7 @@ def _edge_tables(self) -> EdgeTables: for face in self._faces: outside &= ~face.clip if isinstance(self.space, RadialGrid): - outside = np.asarray(self.space.r) > self._requested_size() + outside = np.asarray(self.space.r) > _requested_size(self.space) totals[2] = weights * outside return EdgeTables(faces, clip, totals, self._rows_read()) @@ -780,18 +986,6 @@ def _column_power( return self._columns * columns # ------------------------------------------------------------------ the record - @property - def current_scale(self) -> tuple[float, float]: - """Return what a reading of the inward current needs of the edge. - - Returns - ------- - tuple of float - The requested half-size on the narrowest axis, m, and the band's smallest - wavenumber, rad/m. - """ - return self._requested_size(), self.k_min - @property def resumed(self) -> bool: """Return whether this edge continues an earlier execution's record. @@ -1180,38 +1374,6 @@ def read(self, reading: EdgeReading, z: float) -> None: self._judge_ramp_removal() # ------------------------------------------------------------------ a collar - def _strip_bounds(self) -> tuple[float, float]: - """Return a collar's return strip, ``(a, b)`` in the outward coordinate, m. - - The collar plus ``min(w/2, R/4)`` of the request (section 5.3). - - Returns - ------- - tuple of float - The strip's inner and outer edges. - """ - assert self.design is not None - width = min(self.design.width) - size = self._requested_size() - return size - min(0.5 * width, 0.25 * size), size + width - - def _requested_size(self) -> float: - """Return the requested half-size on the narrowest axis, m. - - Returns - ------- - float - The size, m. - """ - space = self.space - if isinstance(space, RadialGrid): - return space.radius - if isinstance(space, PlanarGrid): - return space.radius_y - if isinstance(space, LineGrid): - return space.radius_x - return min(space.radius_x, space.radius_y) - def _strip_shares( self, strip: Float[np.ndarray, "n_lines 2"] ) -> tuple[float, float]: diff --git a/tests/backends/test_native_edge_collar.py b/tests/backends/test_native_edge_collar.py new file mode 100644 index 00000000..227f077e --- /dev/null +++ b/tests/backends/test_native_edge_collar.py @@ -0,0 +1,382 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Native's reading of a requested collar's strip and current (SEP 44, phase 3). + +A plane wave has an exact answer for the current on a uniform grid, which needs neither +reducer: two reducers agreeing on one wrong line would show nothing. The strip is held +to the host's on the same state, and each direction to where a packet actually goes. +""" + +import math +import warnings +from typing import Any, cast + +import numpy as np +import pytest + +from bounds import fft_bound +from solprop import transverse_monitor +from solprop.backends.driver import StepDriver +from solprop.backends.registry import available, select +from solprop.boundaries import SpatialAbsorber +from solprop.driver import Simulation, SimulationSettings +from solprop.exceptions import BoundaryWarning +from solprop.grid import EnvelopeGrid, FreeGrid, LineGrid, RadialGrid +from solprop.modes import PlanarGrid, SlabMode +from solprop.pulses import GaussianPulse, PlanarPulse, SpatialPulse +from solprop.spatial import SpatialMedium +from solprop.stepper import ( + FixedSchedule, + LowStorageRungeKutta, + Multistep, + PIController, + RungeKutta, +) + +pytestmark = pytest.mark.skipif( + "native" not in available(), reason="native is not installed" +) +NativeBackend = pytest.importorskip("solprop_native.backend").NativeBackend + +LENGTH = 2e-3 +"""The tiny cases' length, m.""" + +ROUTES = ("radial", "cartesian", "line", "planar") +"""Every route a requested collar runs on.""" + +STEPS = 8 +"""Fixed steps over the tiny cases: short enough for the collar's stability limit.""" + + +def _settings(*, planar: bool = False, **overrides: Any) -> SimulationSettings: + """Return settings for a linear run with a requested collar on native.""" + keywords: dict[str, Any] = { + "controller": PIController(rtol=1e-7, atol=0.0), + "save_planes": [LENGTH / 2, LENGTH], + "dz_max": LENGTH / 8, + "dz_init": LENGTH * 1e-3, + "backend": "native", + "spatial_absorber": SpatialAbsorber(kappa=2.5e5 if planar else 8e4), + } + return SimulationSettings(**(keywords | overrides)) + + +def _case(route: str, **overrides: Any) -> Simulation: + """Return a linear run with a requested collar outside its aperture.""" + grid = EnvelopeGrid.from_limits(LENGTH, 800e-9, (700e-9, 900e-9), 200e-15) + temporal = GaussianPulse(wavelength=800e-9, fwhm=50e-15, energy=1e-9) + settings = _settings(planar=route == "planar", **overrides) + if route == "planar": + modes = [SlabMode(63.5e-6, "Ar", 1.0, order=p) for p in (1, 3)] + return Simulation.from_planar( + EnvelopeGrid.from_limits(LENGTH, 800e-9, (650e-9, 1000e-9), 600e-15), + PlanarGrid(modes, 1e-4, 40, collar_fraction=1.0), + [], + PlanarPulse( + GaussianPulse(wavelength=800e-9, fwhm=100e-15, energy=1e-3), + 20e-6, + waist_x=0.735 * 63.5e-6, + ), + settings=settings, + ) + pulse = SpatialPulse(temporal, 40e-6) + if route == "radial": + return Simulation.from_radial( + grid, + RadialGrid(300e-6, 48, collar_fraction=1.0), + SpatialMedium(1.0), + [], + pulse, + settings=settings, + noise=None, + ) + space = ( + LineGrid(300e-6, 96, collar_fraction=1.0) + if route == "line" + else FreeGrid(300e-6, 64, 360e-6, 80, collar_fraction=1.0) + ) + return Simulation.from_free( + grid, space, SpatialMedium(1.0), [], pulse, settings=settings, noise=None + ) + + +def _attached(simulation: Simulation, state: np.ndarray) -> tuple[Any, Any]: + """Return a native backend loaded with `state`, its edge bound.""" + problem = simulation.translate() + backend = cast( + "Any", + select( + "native", + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ), + ) + backend.set_initial(state, None) + monitor = simulation._boundary_monitor() + assert monitor is not None + assert monitor.transverse is not None + assert monitor.transverse.every_step + backend._bind_monitor(monitor) + return backend, monitor.transverse + + +def _noise(problem: Any, seed: int) -> np.ndarray: + """Return white noise on the active band, filling every line.""" + rng = np.random.default_rng(seed) + shape = problem.shape + state = rng.standard_normal(shape) + 1j * rng.standard_normal(shape) + state[..., ~np.asarray(problem.grids.active_mask)] = 0.0 + return state + + +@pytest.mark.parametrize("route", ROUTES) +def test_native_reads_a_collars_strip_and_current_as_the_host_does(route: str) -> None: + simulation = _case(route) + problem = simulation.translate() + state = _noise(problem, 67) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading( + backend._require_state(), edge, current=True, full=True + ) + host = edge.reduction.reading(state[0], current=True, full=True) + finally: + backend.close() + # Rounding of one transform round trip and a sum of non-negative terms, each side. + budget = 4 * fft_bound(state.shape[1] * state.shape[3]) + assert native.strip.shape == host.strip.shape + assert np.all(host.strip[:, 1] > 0.0) + np.testing.assert_allclose(native.strip, host.strip, rtol=budget) + # The current is a slope's product, whose terms nearly cancel per row before they're + # rectified. A noise state's sum is still of the terms' size, so the bound scales + # with the transform's, over the slope's factor from the nodes' spacing. + assert host.current > 0.0 + assert native.current == pytest.approx(host.current, rel=64 * budget) + np.testing.assert_allclose(native.totals, host.totals, rtol=budget) + + +def test_a_plane_wave_reads_its_exact_current() -> None: + # On a uniform grid the central difference of exp(i kappa s) is exactly + # i sin(kappa h) / h times it, so J = |E|^2 sin(kappa h) / (h k). It flows towards + # smaller s on the face where it's inward, and the other face rectifies it away. + simulation = _case("line") + problem = simulation.translate() + monitor = simulation._boundary_monitor() + assert monitor is not None + edge = monitor.transverse + assert edge is not None + space = edge.space + x = np.asarray(space.x) + h = float(x[1] - x[0]) + mode = 40 + kappa = 2 * math.pi * mode / (space.n_x * h) + wave = np.exp(1j * kappa * x) + active = np.nonzero(np.asarray(problem.grids.active_mask))[0] + state = np.zeros(problem.shape, dtype=np.complex128) + state[0, :, 0, active[3]] = np.fft.fft(wave) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading( + backend._require_state(), edge, faces=False, current=True + ) + finally: + backend.close() + expected = math.sin(kappa * h) / h / edge.k_min + assert native.current == pytest.approx(expected, rel=64 * fft_bound(space.n_x)) + + +def test_the_current_is_rectified_per_row_and_not_after_summing() -> None: + # One bin inward and one outward at one amplitude: their fluxes cancel in a sum, + # and a current rectified after summing would read nothing on either face. + simulation = _case("line") + problem = simulation.translate() + monitor = simulation._boundary_monitor() + assert monitor is not None + edge = monitor.transverse + assert edge is not None + space = edge.space + x = np.asarray(space.x) + h = float(x[1] - x[0]) + mode = 40 + kappa = 2 * math.pi * mode / (space.n_x * h) + active = np.nonzero(np.asarray(problem.grids.active_mask))[0] + state = np.zeros(problem.shape, dtype=np.complex128) + state[0, :, 0, active[3]] = np.fft.fft(np.exp(1j * kappa * x)) + state[0, :, 0, active[5]] = np.fft.fft(np.exp(-1j * kappa * x)) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading( + backend._require_state(), edge, faces=False, current=True + ) + finally: + backend.close() + expected = 2 * math.sin(kappa * h) / h / edge.k_min + assert native.current == pytest.approx(expected, rel=64 * fft_bound(space.n_x)) + + +def _packet(space: LineGrid, centre: float, kappa: float) -> np.ndarray: + """Return a Gaussian packet at `centre` with transverse wavenumber `kappa`.""" + x = np.asarray(space.x) + field = np.exp(-(((x - centre) / 60e-6) ** 2) + 1j * kappa * x) + return np.fft.fft(field) + + +@pytest.mark.parametrize( + ("kappa", "reads_inward"), + # Well inside this grid's Nyquist of 2.5e5 rad/m, and above the strip's 16 / s. + [(1.2e5, True), (-1.2e5, False)], + ids=["inward", "outward"], +) +def test_the_strip_tells_a_packets_direction(kappa: float, reads_inward: bool) -> None: + # Section 5.3: the windowed strip resolves direction above 16 / s, so a packet in it + # moving outward reads below the window's leakage of itself. + simulation = _case("line") + problem = simulation.translate() + monitor = simulation._boundary_monitor() + assert monitor is not None + edge = monitor.transverse + assert edge is not None + space = edge.space + bounds = edge.reduction.lines[0] + x = np.asarray(space.x) + centre = float(np.mean(x[bounds.strip])) + active = np.nonzero(np.asarray(problem.grids.active_mask))[0] + state = np.zeros(problem.shape, dtype=np.complex128) + state[0, :, 0, active[3]] = _packet(space, centre, kappa) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading(backend._require_state(), edge, full=True) + finally: + backend.close() + inward, windowed = native.strip[0] + assert windowed > 0.0 + assert (inward / windowed > 0.1) == reads_inward + if not reads_inward: + assert inward / windowed < 1e-5 + + +@pytest.mark.parametrize("route", ROUTES) +def test_a_small_batch_reads_what_one_batch_reads( + route: str, monkeypatch: pytest.MonkeyPatch +) -> None: + # A strip runs in batches of planes to bound its buffer. They only change the order + # of a non-negative sum, so that much rounding apart. A batch of a row or two here. + simulation = _case(route) + problem = simulation.translate() + state = _noise(problem, 71) + backend, edge = _attached(simulation, state) + try: + whole = backend._edge_reading(backend._require_state(), edge, full=True) + finally: + backend.close() + monkeypatch.setattr(transverse_monitor, "STRIP_BATCH_BYTES", 2**12) + backend, edge = _attached(simulation, state) + try: + small = backend._edge_reading(backend._require_state(), edge, full=True) + finally: + backend.close() + budget = 4 * fft_bound(state.shape[1] * state.shape[3]) + np.testing.assert_allclose(small.strip, whole.strip, rtol=budget) + + +@pytest.mark.parametrize("route", ROUTES) +def test_a_collars_memory_is_its_row(route: str) -> None: + simulation = _case(route) + problem = simulation.translate() + state = _noise(problem, 73) + backend, edge = _attached(simulation, state) + try: + _, _, n_pol, n_omega = problem.shape + expected = transverse_monitor.transverse_edge_bytes( + edge.space, edge.design, n_pol, n_omega + ) + assert backend.memory_ledger()["transverse_edge"] == expected + assert backend.edge_memory(edge.space) == expected + finally: + backend.close() + + +@pytest.mark.parametrize( + "march", + [RungeKutta(), Multistep(), LowStorageRungeKutta()], + ids=["runge_kutta", "adams", "low_storage"], +) +@pytest.mark.parametrize("route", ROUTES) +def test_a_collars_read_leaves_the_march_where_it_was( + route: str, march: object, monkeypatch: pytest.MonkeyPatch +) -> None: + # A collar reads at every commit, on the transverse scratch, which every march has + # to write again before it's used. Against the same run read on the host, which + # touches no scratch: same steps, same monitor, one process, so no rounding apart. + def final() -> tuple[bytes, dict[str, Any]]: + # The planar kind's collar has the much shorter stability limit. + steps = 16 * STEPS if route == "planar" else STEPS + schedule = FixedSchedule.uniform( + 0.0, LENGTH, LENGTH / steps, save_planes=[LENGTH] + ) + simulation = _case( + route, + integrator=march, + schedule=schedule, + save_planes=[LENGTH], + controller=None, + ) + problem = simulation.translate() + backend = cast( + "Any", + select( + "native", + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ), + ) + try: + backend.set_initial(problem.initial.field0, None) + monitor = simulation._boundary_monitor() + assert monitor is not None + with warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + backend.attach_monitor(monitor) + backend.propagate_to(LENGTH) + record = cast("dict[str, Any]", monitor.summary())["transverse"] + return backend.observe(field=True).require_field().tobytes(), record + finally: + backend.close() + + native, record = final() + with monkeypatch.context() as patch: + patch.setattr(NativeBackend, "_edge_reading", StepDriver._edge_reading) + host, host_record = final() + assert native == host + assert record["reads"] == host_record["reads"] >= STEPS // 2 + # The same numbers by two reducers: their rounding, on a beam far from the edge. + assert record["current"] == pytest.approx( + host_record["current"], rel=1e-8, abs=1e-15 + ) + + +@pytest.mark.parametrize("route", ROUTES) +def test_a_collar_run_brings_no_state_to_the_host( + route: str, monkeypatch: pytest.MonkeyPatch +) -> None: + def refused(self: Any, state: Any) -> np.ndarray: + raise AssertionError("a field-free collar run brought the state to the host") + + monkeypatch.setattr(NativeBackend, "_observed_field", refused) + simulation = _case(route, fields="none") + field = np.asarray(simulation.translate().initial.field0)[0] + with simulation.runner() as runner, warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + output = runner.run(field, progress="none") + assert isinstance(runner.backend, NativeBackend) + to_host, from_host = cast("Any", runner.backend)._native_state.crossed_bytes() + record = cast("Any", output).outcome.execution["boundary_monitor"]["transverse"] + assert record["reads"] > 0 + assert to_host == 0 + assert from_host == field.nbytes diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index 021d2f45..1117a367 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -526,7 +526,10 @@ def test_the_edge_holds_memory_only_once_a_monitor_binds_it(route: str) -> None: assert monitor is not None assert monitor.transverse is not None backend._bind_monitor(monitor) - expected = transverse_edge_bytes(monitor.transverse.space) + _, _, n_pol, n_omega = problem.shape + expected = transverse_edge_bytes( + monitor.transverse.space, monitor.transverse.design, n_pol, n_omega + ) assert backend.memory_ledger()["transverse_edge"] == expected assert backend.edge_memory(monitor.transverse.space) == expected finally: diff --git a/tests/test_transverse_monitor.py b/tests/test_transverse_monitor.py index cfc0c9a4..938bef5f 100644 --- a/tests/test_transverse_monitor.py +++ b/tests/test_transverse_monitor.py @@ -42,7 +42,7 @@ def _launch(edge: TransverseEdge, state: np.ndarray, z: float, **options: Any) - reading = edge.reduction.reading( state, full=not edge.resumed, - current=edge.current_scale if edge.every_step and edge.resumed else None, + current=edge.every_step and edge.resumed, ) edge.launch(reading, z, clean_columns=clean, **options) @@ -54,12 +54,12 @@ def _read(edge: TransverseEdge, state: np.ndarray, z: float) -> None: def _current(edge: TransverseEdge, state: np.ndarray) -> float: """Return the state's inward current as a share of the launch.""" - return edge.reduction.current(state, *edge.current_scale) / edge._launch # pyright: ignore[reportPrivateUsage] + return edge.reduction.current(state) / edge._launch # pyright: ignore[reportPrivateUsage] def _step(edge: TransverseEdge, state: np.ndarray, z: float) -> None: """Accumulate `edge`'s current on a state's host reading.""" - edge.step(edge.reduction.current(state, *edge.current_scale), z) + edge.step(edge.reduction.current(state), z) def edge_record(output: object) -> dict[str, Any]: From 1939ac34c7ecfa94a8f8d2f90c4f76a853010c54 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:12:03 +0000 Subject: [PATCH 060/122] Redraw the run figure for kernel schema 47 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/figures/one_run.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/figures/one_run.svg b/docs/dev/figures/one_run.svg index 45e6478d..056a2c71 100644 --- a/docs/dev/figures/one_run.svg +++ b/docs/dev/figures/one_run.svg @@ -93,7 +93,7 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: backends.select(name, problem, grid=, modes=, profiles=) KernelProblem -plain arrays, frozen, validated, schema_version 46 +plain arrays, frozen, validated, schema_version 47 validated arrays, From 7971931c3cbb3f4340cc051c7dee69a5ce8fc194 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:25:08 +0000 Subject: [PATCH 061/122] Hold a collar's read to the rest of SEP 44's probes Padded grids, the smallest node chunk, each march and scheme, a converging beam against numpy_ref on a matched schedule, a supplied unmasked draw, and a resume with no record of the monitor. The noise test found a launch with an input draw and no ramp bringing a state to the host for columns nothing reads, which is fixed here. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/backends/driver.py | 12 +- tests/backends/test_native_edge_collar.py | 429 ++++++++++++++++++++- tests/backends/test_native_edge_reading.py | 183 ++++++++- tests/test_transverse_reads.py | 4 +- 4 files changed, 598 insertions(+), 30 deletions(-) diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index c56ce737..e1046f55 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -2028,9 +2028,10 @@ def _edge_clean_columns( ) -> Float[np.ndarray, " n_columns"] | None: """Return each column's launched energy without the input noise, or ``None``. - What the safety ramp is judged on. It is formed where the load has both parts, - which needs no state on the host. Only a resume that found no record of the - monitor has none, and then the state has to cross once. + What the safety ramp is judged on, so a run with no ramp needs none. It is formed + where the load has both parts, which needs no state on the host. Only a resume + that found no record of the monitor has none, and then the state has to cross + once. Parameters ---------- @@ -2040,10 +2041,11 @@ def _edge_clean_columns( Returns ------- numpy.ndarray or None - The unweighted energies, or ``None`` with no noise or on a resumed launch. + The unweighted energies, or ``None`` with no noise, no ramp or on a resumed + launch. """ floor = self._input_floor - if floor is None or edge.resumed: + if floor is None or edge.resumed or edge.design is None: return None if self._clean_columns is not None: return self._clean_columns diff --git a/tests/backends/test_native_edge_collar.py b/tests/backends/test_native_edge_collar.py index 227f077e..c2228f8c 100644 --- a/tests/backends/test_native_edge_collar.py +++ b/tests/backends/test_native_edge_collar.py @@ -9,8 +9,11 @@ to the host's on the same state, and each direction to where a packet actually goes. """ +import contextlib import math +import threading import warnings +from pathlib import Path from typing import Any, cast import numpy as np @@ -19,12 +22,19 @@ from bounds import fft_bound from solprop import transverse_monitor from solprop.backends.driver import StepDriver +from solprop.backends.memory import low_storage_stage_register from solprop.backends.registry import available, select from solprop.boundaries import SpatialAbsorber from solprop.driver import Simulation, SimulationSettings -from solprop.exceptions import BoundaryWarning -from solprop.grid import EnvelopeGrid, FreeGrid, LineGrid, RadialGrid +from solprop.exceptions import BoundaryWarning, StopRequested +from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, LineGrid, RadialGrid +from solprop.log import NullReporter from solprop.modes import PlanarGrid, SlabMode +from solprop.monitor import BoundaryCheck +from solprop.noise import NoiseRealisation +from solprop.nonlinear.responses import KerrResponse +from solprop.output.hdf5 import resume +from solprop.physdata import gamma3 from solprop.pulses import GaussianPulse, PlanarPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import ( @@ -63,34 +73,72 @@ def _settings(*, planar: bool = False, **overrides: Any) -> SimulationSettings: return SimulationSettings(**(keywords | overrides)) -def _case(route: str, **overrides: Any) -> Simulation: - """Return a linear run with a requested collar outside its aperture.""" - grid = EnvelopeGrid.from_limits(LENGTH, 800e-9, (700e-9, 900e-9), 200e-15) +def _case( + route: str, + *, + kerr: bool = False, + waist: float = 40e-6, + propz: float = 0.0, + noise: str | None = None, + **overrides: Any, +) -> Simulation: + """Return a run with a requested collar outside its aperture. + + Parameters + ---------- + route : str + The route. + kerr : bool, optional + Whether the medium has a Kerr response, on a field grid, whose time is padded. + waist : float, optional + The beam's waist on a 300 um aperture, m; the planar case's scales with its own. + propz : float, optional + The launch's signed distance from the waist, m: negative is converging. A + planar launch is at the guide's entrance, so it ignores this. + noise : str or None, optional + The noise model; the planar route takes none. + **overrides : Any + Settings to change. + + Returns + ------- + solprop.driver.Simulation + The run. + """ + grid = ( + FieldGrid.from_limits(LENGTH, 800e-9, (600e-9, 1.2e-6), 150e-15) + if kerr + else EnvelopeGrid.from_limits(LENGTH, 800e-9, (700e-9, 900e-9), 200e-15) + ) temporal = GaussianPulse(wavelength=800e-9, fwhm=50e-15, energy=1e-9) settings = _settings(planar=route == "planar", **overrides) + responses: list[Any] = ( + [KerrResponse(gamma3("Ar", wavelength=800e-9), thg=False)] if kerr else [] + ) + medium = SpatialMedium("Ar", 1.0) if kerr else SpatialMedium(1.0) if route == "planar": modes = [SlabMode(63.5e-6, "Ar", 1.0, order=p) for p in (1, 3)] return Simulation.from_planar( EnvelopeGrid.from_limits(LENGTH, 800e-9, (650e-9, 1000e-9), 600e-15), PlanarGrid(modes, 1e-4, 40, collar_fraction=1.0), - [], + responses, PlanarPulse( GaussianPulse(wavelength=800e-9, fwhm=100e-15, energy=1e-3), - 20e-6, + waist / 2.0, waist_x=0.735 * 63.5e-6, ), settings=settings, ) - pulse = SpatialPulse(temporal, 40e-6) + pulse = SpatialPulse(temporal, waist, propz=propz) if route == "radial": return Simulation.from_radial( grid, RadialGrid(300e-6, 48, collar_fraction=1.0), - SpatialMedium(1.0), - [], + medium, + responses, pulse, settings=settings, - noise=None, + noise=cast("Any", noise), ) space = ( LineGrid(300e-6, 96, collar_fraction=1.0) @@ -98,10 +146,21 @@ def _case(route: str, **overrides: Any) -> Simulation: else FreeGrid(300e-6, 64, 360e-6, 80, collar_fraction=1.0) ) return Simulation.from_free( - grid, space, SpatialMedium(1.0), [], pulse, settings=settings, noise=None + grid, + space, + medium, + responses, + pulse, + settings=settings, + noise=cast("Any", noise), ) +def node_chunk(problem: Any) -> int: + """Return how many nodes a pass of a problem's march takes at once.""" + return int(problem.transform.node_chunk) + + def _attached(simulation: Simulation, state: np.ndarray) -> tuple[Any, Any]: """Return a native backend loaded with `state`, its edge bound.""" problem = simulation.translate() @@ -133,10 +192,16 @@ def _noise(problem: Any, seed: int) -> np.ndarray: return state +@pytest.mark.parametrize("kerr", [False, True], ids=["linear", "padded"]) @pytest.mark.parametrize("route", ROUTES) -def test_native_reads_a_collars_strip_and_current_as_the_host_does(route: str) -> None: - simulation = _case(route) +def test_native_reads_a_collars_strip_and_current_as_the_host_does( + route: str, kerr: bool +) -> None: + simulation = _case(route, kerr=kerr) problem = simulation.translate() + if kerr and route != "planar": + # A radial synthesis carries the padding's scale, and a read must too. + assert problem.grids.n_to != problem.grids.n_t state = _noise(problem, 67) backend, edge = _attached(simulation, state) try: @@ -170,6 +235,7 @@ def test_a_plane_wave_reads_its_exact_current() -> None: edge = monitor.transverse assert edge is not None space = edge.space + assert isinstance(space, LineGrid) x = np.asarray(space.x) h = float(x[1] - x[0]) mode = 40 @@ -199,6 +265,7 @@ def test_the_current_is_rectified_per_row_and_not_after_summing() -> None: edge = monitor.transverse assert edge is not None space = edge.space + assert isinstance(space, LineGrid) x = np.asarray(space.x) h = float(x[1] - x[0]) mode = 40 @@ -241,6 +308,7 @@ def test_the_strip_tells_a_packets_direction(kappa: float, reads_inward: bool) - edge = monitor.transverse assert edge is not None space = edge.space + assert isinstance(space, LineGrid) bounds = edge.reduction.lines[0] x = np.asarray(space.x) centre = float(np.mean(x[bounds.strip])) @@ -300,21 +368,31 @@ def test_a_collars_memory_is_its_row(route: str) -> None: backend.close() -@pytest.mark.parametrize( - "march", - [RungeKutta(), Multistep(), LowStorageRungeKutta()], - ids=["runge_kutta", "adams", "low_storage"], -) +MARCHES = { + "runge_kutta": (RungeKutta(), None), + "runge_kutta_split": (RungeKutta(), "split_step"), + "runge_kutta_in_stage": (RungeKutta(), "nonlinear"), + "adams_exponential": (Multistep(), None), + "adams_interaction": (Multistep(formulation="interaction"), None), + "low_storage": (LowStorageRungeKutta(), None), + "low_storage_in_stage": (LowStorageRungeKutta(), "nonlinear"), +} +"""Each march and temporal collar scheme a read has to leave undisturbed.""" + + +@pytest.mark.parametrize("name", MARCHES) @pytest.mark.parametrize("route", ROUTES) def test_a_collars_read_leaves_the_march_where_it_was( - route: str, march: object, monkeypatch: pytest.MonkeyPatch + route: str, name: str, monkeypatch: pytest.MonkeyPatch ) -> None: + march, scheme = MARCHES[name] + # A collar reads at every commit, on the transverse scratch, which every march has # to write again before it's used. Against the same run read on the host, which # touches no scratch: same steps, same monitor, one process, so no rounding apart. def final() -> tuple[bytes, dict[str, Any]]: # The planar kind's collar has the much shorter stability limit. - steps = 16 * STEPS if route == "planar" else STEPS + steps = 16 * STEPS if route == "planar" or scheme == "nonlinear" else STEPS schedule = FixedSchedule.uniform( 0.0, LENGTH, LENGTH / steps, save_planes=[LENGTH] ) @@ -324,6 +402,8 @@ def final() -> tuple[bytes, dict[str, Any]]: schedule=schedule, save_planes=[LENGTH], controller=None, + absorber_scheme=scheme, + dz_max=LENGTH / steps, ) problem = simulation.translate() backend = cast( @@ -380,3 +460,310 @@ def refused(self: Any, state: Any) -> np.ndarray: assert record["reads"] > 0 assert to_host == 0 assert from_host == field.nbytes + + +def test_a_lit_node_reads_the_collars_rate_from_its_design() -> None: + # `totals[1]` is what `_collar_rate` reads at every read: `alpha w |E|^2`. The + # design gives alpha and the grid gives w, so this needs neither reducer's tables. + simulation = _case("line") + problem = simulation.translate() + monitor = simulation._boundary_monitor() + assert monitor is not None + edge = monitor.transverse + assert edge is not None + space = edge.space + assert isinstance(space, LineGrid) + assert edge.design is not None + rate = np.asarray(edge.design.collar_rate) + node = int(np.argmax(rate)) + assert rate[node] > 0.0 + phase = np.exp(-2j * np.pi * np.arange(space.n_x) * node / space.n_x) + active = np.nonzero(np.asarray(problem.grids.active_mask))[0] + state = np.zeros(problem.shape, dtype=np.complex128) + state[0, :, 0, active[3]] = phase + backend, edge = _attached(simulation, state) + try: + read = backend._edge_reading(backend._require_state(), edge) + finally: + backend.close() + expected = rate[node] * float(np.asarray(space.spatial_weights)[node]) + assert read.totals[1] == pytest.approx(expected, rel=64 * fft_bound(space.n_x)) + + +def test_a_standing_wave_reads_almost_no_current() -> None: + # A real field carries no flux, so each row's product cancels to rounding: there the + # bound is absolute, set by the terms and not by the result. + simulation = _case("line") + problem = simulation.translate() + monitor = simulation._boundary_monitor() + assert monitor is not None + edge = monitor.transverse + assert edge is not None + space = edge.space + assert isinstance(space, LineGrid) + x = np.asarray(space.x) + h = float(x[1] - x[0]) + kappa = 2 * math.pi * 40 / (space.n_x * h) + active = np.nonzero(np.asarray(problem.grids.active_mask))[0] + state = np.zeros(problem.shape, dtype=np.complex128) + state[0, :, 0, active[3]] = np.fft.fft(np.cos(kappa * x).astype(np.complex128)) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading( + backend._require_state(), edge, faces=False, current=True + ) + finally: + backend.close() + # Unit field: |v| |s| / k, for |s| at most 2 / (2 h), times the transform's + # rounding, on each of the two lines, which rectification can only keep. + bound = 2 * fft_bound(space.n_x) / h / edge.k_min + assert 0.0 <= native.current <= bound + + +class _StopAtFirstPlane(NullReporter): + """Set a run's stop flag at its first saved plane, between two edge reads.""" + + def __init__(self, flag: threading.Event) -> None: + self.flag = flag + + def plane(self, z: float, summary: object) -> None: + del z, summary + self.flag.set() + + +@pytest.mark.parametrize( + "march", + [RungeKutta(), Multistep(), LowStorageRungeKutta()], + ids=["runge_kutta", "adams", "low_storage"], +) +def test_a_stopped_collar_run_resumes_to_the_whole_runs_record( + march: object, tmp_path: Path +) -> None: + # #512: a resumed collar run judged against zero, with its current and peaks + # restarted. The trapezoid picks up from the resumed state's own sample, so the + # current is the whole run's. On a fixed schedule, so both take the same steps. + def build() -> Simulation: + schedule = FixedSchedule.uniform( + 0.0, LENGTH, LENGTH / STEPS, save_planes=[LENGTH / 2, LENGTH] + ) + return _case("radial", integrator=march, schedule=schedule, controller=None) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + whole = cast("Any", build().run(progress="none")) + path = tmp_path / "run.h5" + flag = threading.Event() + build().run(path, progress=_StopAtFirstPlane(flag), stop=flag) + build().run(path, resume=True, progress="none") + resumed = resume(path).outcome + assert resumed is not None + expected = whole.outcome.execution["boundary_monitor"]["transverse"] + found = cast("Any", resumed).execution["boundary_monitor"]["transverse"] + assert found["launch_energy"] == pytest.approx(expected["launch_energy"], rel=1e-12) + # Field rounding, carried through a share of a share: nothing's restarted. + assert expected["current"] > 0.0 + assert found["current"] == pytest.approx(expected["current"], rel=1e-8) + assert found["strip_max"] == pytest.approx(expected["strip_max"], rel=1e-8) + assert found["current_baseline"] == pytest.approx( + expected["current_baseline"], rel=1e-12 + ) + + +def test_a_collar_run_stopped_before_its_first_step_resumes_as_a_launch( + tmp_path: Path, +) -> None: + # A launch-only record: the run's first step never happened, so the next execution + # has a launch to continue from and no integral yet. + def build() -> Simulation: + return _case("radial", integrator=RungeKutta()) + + flag = threading.Event() + flag.set() + path = tmp_path / "run.h5" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + with contextlib.suppress(StopRequested): + build().run(path, progress="none", stop=flag) + whole = cast("Any", build().run(progress="none")) + build().run(path, resume=True, progress="none") + resumed = resume(path).outcome + assert resumed is not None + expected = whole.outcome.execution["boundary_monitor"]["transverse"] + found = cast("Any", resumed).execution["boundary_monitor"]["transverse"] + assert found["launch_energy"] == pytest.approx(expected["launch_energy"], rel=1e-12) + assert found["reads"] == expected["reads"] + + +def test_a_collars_low_storage_march_holds_its_stage_register() -> None: + # A march without one writes the next pass's input straight into the transverse + # scratch, which is what a read takes. `test_native_edge_reading` holds that one. + simulation = _case( + "line", + integrator=LowStorageRungeKutta(), + schedule=FixedSchedule.uniform(0.0, LENGTH, LENGTH / STEPS), + controller=None, + dz_max=LENGTH / STEPS, + ) + assert low_storage_stage_register(simulation.translate(), "native") + + +@pytest.mark.parametrize("route", ROUTES) +def test_the_smallest_node_chunk_reads_what_the_default_reads(route: str) -> None: + # A read owes nothing to how a march chunks its nodes, on one state. + simulation = _case(route) + problem = simulation.translate() + state = _noise(problem, 83) + backend, edge = _attached(simulation, state) + try: + whole = backend._edge_reading( + backend._require_state(), edge, current=True, full=True + ) + finally: + backend.close() + budget = 20 * 16 * problem.shape[2] * problem.grids.n_to + small = _case(route, node_budget=budget) + assert node_chunk(small.translate()) < node_chunk(problem) + backend, edge = _attached(small, state) + try: + read = backend._edge_reading( + backend._require_state(), edge, current=True, full=True + ) + finally: + backend.close() + assert read.strip.tobytes() == whole.strip.tobytes() + assert read.totals.tobytes() == whole.totals.tobytes() + assert read.current == whole.current + + +@pytest.mark.parametrize("march", [RungeKutta(), Multistep()], ids=["rk", "adams"]) +@pytest.mark.parametrize("route", ROUTES) +def test_a_collar_run_matches_numpy_ref_on_a_matched_schedule( + route: str, march: object +) -> None: + # A controlled witness: one schedule, so the same reads at the same places, and a + # converging beam reaching the collar, so that the current's truly inward and above + # rounding, and the relative comparison means something. + steps = 16 * STEPS if route == "planar" else STEPS + + def run(backend: str) -> tuple[np.ndarray, dict[str, Any]]: + simulation = _case( + route, + waist=60e-6, + propz=-2.4e-2, + backend=backend, + boundary_check=BoundaryCheck( + budget=1.0, stop_factor=1.0, transverse_budget=1.0 + ), + integrator=march, + schedule=FixedSchedule.uniform( + 0.0, LENGTH, LENGTH / steps, save_planes=[LENGTH / 2, LENGTH] + ), + controller=None, + dz_max=LENGTH / steps, + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + output = cast("Any", simulation.run(progress="none")) + record = output.outcome.execution["boundary_monitor"]["transverse"] + return np.asarray(output["field"][-1]), record + + expected, reference = run("numpy_ref") + found, native = run("native") + assert np.linalg.norm(found - expected) <= 1e-10 * np.linalg.norm(expected) + assert native["reads"] == reference["reads"] > 0 + assert native["transverse_status"] == reference["transverse_status"] + # A planar launch can't converge, so its current stays at rounding: what's left to + # hold there is its absolute size. + if route != "planar": + assert reference["current"] > 1e-14 + assert reference["spatial_removed"] > 1e-14 + for key in ("current", "spatial_removed", "strip_max"): + assert native[key] == pytest.approx(reference[key], rel=1e-8, abs=1e-15), key + + +def _unmasked(problem: Any, seed: int) -> NoiseRealisation: + """Return a hand-built input draw with energy outside the active band.""" + rng = np.random.default_rng(seed) + shape = problem.shape[1:] + spectrum = 1e-9 * (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)) + return NoiseRealisation(spectrum=spectrum, seed=None, model="input") + + +@pytest.mark.parametrize("route", ["radial", "cartesian", "line"]) +def test_a_supplied_unmasked_draw_judges_as_the_host_does_with_no_transfer( + route: str, monkeypatch: pytest.MonkeyPatch +) -> None: + # The ramp judges the launch without its noise, which is where the load has both + # parts. A draw that's been masked would hide the mask's own part of that. + def launch() -> tuple[dict[str, Any], int]: + simulation = _case(route, noise="input") + problem = simulation.translate() + assert problem.transverse is not None + backend = cast( + "Any", + select( + "native", + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ), + ) + try: + backend.set_initial(problem.initial.field0, _unmasked(problem, 89)) + monitor = simulation._boundary_monitor() + assert monitor is not None + with warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + backend.attach_monitor(monitor) + record = cast("dict[str, Any]", monitor.summary())["transverse"] + return record, backend._native_state.crossed_bytes()[0] + finally: + backend.close() + + native, to_host = launch() + assert to_host == 0 + with monkeypatch.context() as patch: + patch.setattr(NativeBackend, "_edge_reading", StepDriver._edge_reading) + patch.setattr(NativeBackend, "_edge_clean_columns", _host_clean_columns) + host, _ = launch() + for key in ("launch_energy", "noise_allowance", "noise_share_in_ramp"): + assert native[key] == pytest.approx(host[key], rel=1e-11, abs=1e-30), key + assert native["noise_share_in_ramp"] > 0.0 + + +def _host_clean_columns(self: Any, edge: Any) -> np.ndarray | None: + """Return a launch's clean columns the way a state on the host gives them.""" + floor = self._input_floor + if floor is None or edge.resumed: + return None + state = np.asarray(self._observed_field(self._require_state())[0]) + return edge.reduction.column_power(state - np.asarray(floor[0])) + + +def test_a_resume_with_no_monitor_record_crosses_once() -> None: + # The one launch whose clean columns the load didn't form: a resumed state with its + # draw, whose run has no record of the monitor, so it's judged here as a launch. + simulation = _case("line", noise="input") + problem = simulation.translate() + backend = cast( + "Any", + select( + "native", + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + ), + ) + try: + state = np.asarray(problem.initial.field0) + backend.set_initial(state, _unmasked(problem, 97), resume=True) + monitor = simulation._boundary_monitor() + assert monitor is not None + with warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + backend.attach_monitor(monitor) + assert backend._native_state.crossed_bytes()[0] == state.nbytes + finally: + backend.close() diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index 1117a367..8a57b631 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -19,15 +19,22 @@ from capability_routes import LENGTH, SPATIAL_GRID from capability_routes import ROUTES as CASES from solprop.backends.driver import StepDriver +from solprop.backends.memory import low_storage_stage_register from solprop.backends.registry import available, select -from solprop.driver import Simulation +from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import BoundaryError, BoundaryWarning -from solprop.grid import FreeGrid, LineGrid, RadialGrid +from solprop.grid import FieldGrid, FreeGrid, LineGrid, RadialGrid from solprop.modes import PlanarGrid from solprop.monitor import BoundaryCheck from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium -from solprop.stepper import FixedSchedule, LowStorageRungeKutta, Multistep, RungeKutta +from solprop.stepper import ( + FixedSchedule, + LowStorageRungeKutta, + Multistep, + PIController, + RungeKutta, +) from solprop.transverse_monitor import GUARD, EdgeReading, transverse_edge_bytes from test_transverse_reads import REREVIEW, packet_run @@ -283,6 +290,19 @@ def final() -> tuple[np.ndarray, int]: assert native.tobytes() == host.tobytes() +@pytest.mark.parametrize("route", ["cartesian", "line"]) +def test_the_march_above_runs_without_its_stage_register(route: str) -> None: + # Then a pass writes the next input straight into the transverse scratch, which + # a read takes. If this stopped being so, the test above would stop covering it. + simulation = _case(route) + simulation.settings = dataclasses.replace( + simulation.settings, + integrator=LowStorageRungeKutta(), + schedule=FixedSchedule.uniform(0.0, LENGTH, LENGTH / 4, save_planes=[LENGTH]), + ) + assert not low_storage_stage_register(simulation.translate(), "native") + + def _direct_node_energies(space: Any, state: np.ndarray) -> np.ndarray: """Return each node's ``sum |E|^2`` by a plain transform, not the edge's code.""" flat = state.reshape(state.shape[0], -1) @@ -553,3 +573,160 @@ def estimated(check: BoundaryCheck | None) -> int: monkeypatch.setattr(NativeBackend, "edge_memory", lambda _self, _space: sentinel) assert estimated(BoundaryCheck(budget=1.0, stop_factor=1.0)) >= sentinel assert estimated(None) < sentinel + + +def _with_noise(route: str, model: str | None, *, ramp: bool) -> Simulation: + """Return a tiny route with a noise model, field-free and on native.""" + simulation = CASES[route](model) + simulation.settings = dataclasses.replace( + simulation.settings, + boundary_check=BoundaryCheck( + budget=1.0, stop_factor=1.0, transverse_budget=1.0 + ), + fields="none", + backend="native", + spatial_absorber=None if ramp else False, + ) + return simulation + + +@pytest.mark.parametrize("ramp", [True, False], ids=["ramp", "no_ramp"]) +@pytest.mark.parametrize("model", ["input", "modified", None], ids=str) +@pytest.mark.parametrize("route", ["radial", "cartesian", "line"]) +def test_a_launch_with_noise_judges_as_the_host_does_with_no_transfer( + route: str, model: str | None, ramp: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + # The clean columns are formed at the load, so judging a noisy launch needs no copy + # of the state to subtract the noise from. Against the same launch read on the host. + def launch() -> tuple[dict[str, Any], tuple[int, int]]: + simulation = _with_noise(route, model, ramp=ramp) + field = np.asarray(simulation.translate().initial.field0)[0] + with simulation.runner() as runner, warnings.catch_warnings(): + warnings.simplefilter("ignore", BoundaryWarning) + output = cast("Any", runner.run(field, progress="none")) + crossed = cast("Any", runner.backend)._native_state.crossed_bytes() + record = output.outcome.execution["boundary_monitor"]["transverse"] + return cast("dict[str, Any]", record), crossed + + native, (to_host, _) = launch() + assert to_host == 0 + with monkeypatch.context() as patch: + patch.setattr(NativeBackend, "_edge_reading", StepDriver._edge_reading) + host, _ = launch() + assert native["launch_energy"] == pytest.approx(host["launch_energy"], rel=1e-11) + assert native["noise_allowance"] == pytest.approx( + host["noise_allowance"], rel=1e-11, abs=1e-30 + ) + assert native["noise_share_in_ramp"] == pytest.approx( + host["noise_share_in_ramp"], rel=1e-11, abs=1e-30 + ) + + +def test_a_field_grid_reads_as_the_host_does() -> None: + # A real-signal grid's bins carry their own weights elsewhere: a reading has none on + # either kind of grid, and a padded field grid's `n_to != n_t` changes nothing here. + grid = FieldGrid.from_limits(1e-4, 800e-9, (500e-9, 1.6e-6), 200e-15) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 80e-6 + ) + settings = SimulationSettings( + controller=PIController(rtol=1e-6, atol=0.0), + save_planes=[1e-4], + dz_max=1e-4, + dz_init=1e-5, + backend="native", + boundary_check=BoundaryCheck( + budget=1.0, stop_factor=1.0, transverse_budget=1.0 + ), + ) + for build, space in ( + (Simulation.from_radial, RadialGrid(6e-4, 32)), + (Simulation.from_free, LineGrid(3e-4, 16, collar_fraction=0)), + ): + simulation = build( + grid, + space, + SpatialMedium("Ar", 1.0), + [], + pulse, + settings=settings, + noise=None, + ) + problem = simulation.translate() + state = _noise_state(problem.shape, np.asarray(problem.grids.active_mask), 79) + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading(backend._require_state(), edge, full=True) + host = edge.reduction.reading(state[0], full=True) + finally: + backend.close() + budget = 4 * fft_bound(state.shape[1] * state.shape[3]) + np.testing.assert_allclose(native.faces, host.faces, rtol=budget) + np.testing.assert_allclose(native.columns, host.columns, rtol=budget) + + +@pytest.mark.parametrize( + "where", ["last_bin", "dc_column", "nyquist_column", "inactive_bin"], ids=str +) +def test_energy_in_one_extreme_bin_reads_as_the_host_does(where: str) -> None: + # Off-by-one and stride errors hide under white noise. Unit energy in one element + # of the state, at the band's last bin, at the transverse DC, or at its Nyquist. + simulation = _case("cartesian") + problem = simulation.translate() + monitor = simulation._boundary_monitor() + assert monitor is not None + assert monitor.transverse is not None + space = monitor.transverse.space + assert isinstance(space, FreeGrid) + active = np.nonzero(np.asarray(problem.grids.active_mask))[0] + state = np.zeros(problem.shape, dtype=np.complex128) + if where == "last_bin": + state[0, 5, 0, active[-1]] = 1.0 + elif where == "dc_column": + state[0, 0, 0, active[0]] = 1.0 + elif where == "inactive_bin": + # The load masks it, so a read sees only the lit bin beside it. + state[0, 5, 0, active[0]] = 1.0 + state[0, 7, 0, np.nonzero(~np.asarray(problem.grids.active_mask))[0][0]] = 1.0 + else: + nyquist = (space.n_y // 2) * space.n_x + space.n_x // 2 + state[0, nyquist, 0, active[0]] = 1.0 + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading(backend._require_state(), edge, full=True) + held = backend.observe(field=True).require_field() + host = edge.reduction.reading(held[0], full=True) + finally: + backend.close() + budget = 4 * fft_bound(space.n_y * space.n_x) + np.testing.assert_allclose(native.faces, host.faces, rtol=budget) + np.testing.assert_allclose(native.totals, host.totals, rtol=budget, atol=1e-30) + np.testing.assert_allclose(native.columns, host.columns, rtol=budget) + + +def test_a_planar_channel_above_the_first_reads_its_own_column() -> None: + # A planar column is (y, p), with its own place in the scratch: the energy has to + # come back on that column, and on the same row's node whichever channel holds it. + simulation = _case("planar") + problem = simulation.translate() + monitor = simulation._boundary_monitor() + assert monitor is not None + assert monitor.transverse is not None + space = monitor.transverse.space + assert isinstance(space, PlanarGrid) + assert space.n_p >= 2 + active = np.nonzero(np.asarray(problem.grids.active_mask))[0] + column = 1 * space.n_p + 1 # y row 1, channel 1 + state = np.zeros(problem.shape, dtype=np.complex128) + state[0, column, 0, active[0]] = 1.0 + backend, edge = _attached(simulation, state) + try: + native = backend._edge_reading(backend._require_state(), edge, full=True) + host = edge.reduction.reading(state[0], full=True) + finally: + backend.close() + expected = np.zeros(problem.shape[1]) + expected[column] = 1.0 + np.testing.assert_array_equal(native.columns, expected) + budget = 4 * fft_bound(space.n_y) + np.testing.assert_allclose(native.totals, host.totals, rtol=budget, atol=1e-30) diff --git a/tests/test_transverse_reads.py b/tests/test_transverse_reads.py index 51768f6d..6f25fa03 100644 --- a/tests/test_transverse_reads.py +++ b/tests/test_transverse_reads.py @@ -398,7 +398,9 @@ def test_the_scaled_miss_is_read_far_above_the_stop() -> None: def _driver_class(backend: str) -> type: """Return the class of `backend`'s step driver, which holds its edge reading.""" if backend == "native": - from solprop_native.backend import NativeBackend # noqa: PLC0415 + from solprop_native.backend import ( # pyright: ignore[reportMissingImports] # noqa: PLC0415 + NativeBackend, + ) return NativeBackend from solprop.backends.driver import StepDriver # noqa: PLC0415 From c07bbfc25be3cc2f4bca535112415c79f2c3c63d Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:25:49 +0000 Subject: [PATCH 062/122] Reduce a separable fluence and a plane's widths natively A width's fluence on a separable grid took a temporal transform of every node chunk, twice, for a sum that Parseval's theorem forms from the bins the transverse pass has already synthesised. On a Cartesian plane the centroid, the second moment and the azimuthal average were also read on the host, from a copy of every node's fluence and magnitude, one Python ring at a time: 0.23 s of the call on an Intel Xeon Gold 6240 at 768 x 768. They are now reductions where the field lives, and only the moments and one value per ring cross. solprop keeps the rule: it hands over the rings and reads the widths. The rounding budget's scale comes from a Cauchy-Schwarz bound on the reference's time-domain magnitude. A separable route no longer forms the axis's fluence, which only a radial width reads, or holds a magnitude per sample. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/include/solprop_native/core.hpp | 19 +- native/src/bindings.cpp | 31 ++ native/src/general.cpp | 439 +++++++++++++++++--- native/src/solprop_native/_core.pyi | 12 + native/src/solprop_native/backend.py | 49 ++- src/solprop/backends/spatial.py | 121 +++++- src/solprop/spatial.py | 102 ++++- tests/backends/test_flux_parity.py | 4 +- tests/backends/test_native_widths.py | 545 +++++++++++++++++++++++++ 9 files changed, 1234 insertions(+), 88 deletions(-) create mode 100644 tests/backends/test_native_widths.py diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index eb0dc470..d3cb9cfb 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -838,11 +838,26 @@ class GeneralState { * The spatial statistics of the accepted state at the plane `z`, measured by the * medium's axial flux (solprop SEP 34): flags 1 energy, 2 on-axis peak intensity, 4 * peak intensity, 8 on-axis spectrum, 16 the widths' fluence block (the signed - * fluence at each node, the axis fluence, then each node's magnitude - * `dt sum |E| |G|`), packed in that order. + * fluence at each node, the axis fluence, which a separable route leaves NaN since + * only a radial width reads it, then the bound on the largest magnitude), 32 a + * Cartesian plane's fluence moments, which hold its scaled profile for + * `plane_fluence_trace` (the lowest value, its node, the bound on the largest + * magnitude, the largest value, the count of values that aren't finite, then the + * scaled sum and its x and y index moments), packed in that order. */ [[nodiscard]] std::vector spatial_statistics(std::uint64_t flags, double intensity_factor, double z); + /** + * The second moment and the azimuthal average of the plane's scaled fluence that a + * `spatial_statistics` call with flag 32 left, on rings solprop's own rule fixes, + * with the centre in metres from the axes' first samples. Returns the second-moment + * sum, then one average per ring. The profile itself stays in the execution space. + */ + [[nodiscard]] std::vector + plane_fluence_trace(double center_x, double center_y, double dx, double dy, + double extent, double spacing, std::size_t n_radii, + const std::vector& cosines, + const std::vector& sines); /** * solprop SEP 23's far-field statistics of the accepted state on a Cartesian or line * grid, one banded pass; see `FarFieldPass::run` (`far_field.hpp`) for the tables, diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index 4d8a92e5..4d9b7b45 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -1343,6 +1343,16 @@ class BoundState { require_open(); return general_state_->spatial_statistics(flags, intensity_factor, z); } + std::vector plane_fluence_trace(const double center_x, const double center_y, + const double dx, const double dy, + const double extent, const double spacing, + const std::size_t n_radii, + const std::vector& cosines, + const std::vector& sines) { + require_open(); + return general_state_->plane_fluence_trace(center_x, center_y, dx, dy, extent, + spacing, n_radii, cosines, sines); + } std::vector aperture_statistics(const std::uint64_t flags, const std::vector& apertures, const int exponent, @@ -2737,6 +2747,27 @@ NB_MODULE(_core, module_) { return real_output(std::move(values), 1); }, "flags"_a, "intensity_factor"_a, "z"_a) + .def( + "plane_fluence_trace", + [](BoundState& self, const double center_x, const double center_y, + const double dx, const double dy, const double extent, + const double spacing, const std::size_t n_radii, const RealInput cosines, + const RealInput sines) { + if (cosines.ndim() != 1 || sines.ndim() != 1) + throw std::invalid_argument("fluence rings must be one-dimensional"); + const auto held_cosines = real_vector(cosines); + const auto held_sines = real_vector(sines); + std::vector values; + { + const nb::gil_scoped_release release; + values = + self.plane_fluence_trace(center_x, center_y, dx, dy, extent, spacing, + n_radii, held_cosines, held_sines); + } + return real_output(std::move(values), 1); + }, + "center_x"_a, "center_y"_a, "dx"_a, "dy"_a, "extent"_a, "spacing"_a, + "n_radii"_a, "cosines"_a.noconvert(), "sines"_a.noconvert()) .def( "aperture_statistics", [](BoundState& self, const std::uint64_t flags, const RealInput apertures, diff --git a/native/src/general.cpp b/native/src/general.cpp index 17d3e5c7..250a9f77 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -66,6 +66,25 @@ template <> struct Kokkos::reduction_identity { KOKKOS_FUNCTION static solprop_native::host::NormSums sum() { return {}; } }; +namespace solprop_native { +/** A plane's scaled fluence, one reduction value: its sum and its index moments. */ +struct FluenceMoments { + double total = 0.0; + double x = 0.0; + double y = 0.0; + KOKKOS_INLINE_FUNCTION FluenceMoments& operator+=(const FluenceMoments& other) { + total += other.total; + x += other.x; + y += other.y; + return *this; + } +}; +} // namespace solprop_native + +template <> struct Kokkos::reduction_identity { + KOKKOS_FUNCTION static solprop_native::FluenceMoments sum() { return {}; } +}; + namespace solprop_native { namespace { @@ -547,6 +566,31 @@ struct TransverseLayout { } }; +// A node's physical spectrum in a transverse volume after the inverse transverse plan: +// on free space the node's own cell; on the hybrid planar kind the slab rule over its y +// row's channels, without the temporal pad scale the planar synthesis carries, since +// the statistics read the coarse field (solprop's `collect_spatial`). +struct PhysicalSpectrum { + TransverseLayout layout{}; + RealView2 synthesis{}; + std::size_t n_x{}, n_p{}; + double crop_scale = 1.0; + bool slab = false; + template + KOKKOS_INLINE_FUNCTION Complex read(const View& source, const std::size_t plane, + const std::size_t node) const { + if (!slab) + return layout.cell(source, plane, node); + const auto ix = node % n_x; + const auto rows = (node / n_x) * n_p; + Complex sum{}; + for (std::size_t channel = 0; channel < n_p; ++channel) + sum += synthesis(ix, channel) * + source(plane, layout.first(rows + channel), layout.second(rows + channel)); + return crop_scale * sum; + } +}; + // Where a separable route's low-storage registers (the accumulator, the stage register // and the flow table) hold the element (column, omega). On the host, at the transverse // scratch's own position: a fold then reads them in the scratch's order beside it, and @@ -2016,6 +2060,9 @@ struct StageTerms { } // namespace +// What a Cartesian plane's widths read of its fluence (`plane_fluence_moments`). +inline constexpr std::size_t plane_moment_slots = 8; + class GeneralWorkspace { public: explicit GeneralWorkspace(Problem problem) : problem_(std::move(problem)) { @@ -2158,6 +2205,11 @@ class GeneralWorkspace { const std::vector& densities); std::vector spatial_statistics(std::uint64_t flags, double intensity_factor, double z); + std::vector plane_fluence_trace(double center_x, double center_y, double dx, + double dy, double extent, double spacing, + std::size_t n_radii, + const std::vector& cosines, + const std::vector& sines); std::vector aperture_statistics(std::uint64_t flags, const std::vector& apertures, int exponent, @@ -2356,8 +2408,10 @@ class GeneralWorkspace { void separable_nodes(const TransverseChunk& chunk); void slab_project(const TransverseChunk& chunk, const EnvelopeStream* stream); void separable_finish_transform(std::size_t stage, bool material); - void separable_time_statistics(std::size_t axis_peak, std::size_t peak, - std::size_t fluence, double intensity_factor); + void separable_time_statistics(std::size_t axis_peak, std::size_t peak, bool fluence, + double intensity_factor); + void separable_fluence(double intensity_factor); + void plane_fluence_moments(double (&moments)[plane_moment_slots]); void scale_transform(std::size_t stage, double taper); void synthesise(std::size_t first_node, std::size_t node_count); void response(std::size_t stage, std::size_t first_node, std::size_t node_count); @@ -2704,9 +2758,11 @@ class GeneralWorkspace { RealView2 chunk_projection_stacked_, tail_projection_stacked_; RealView2 partials_, quadrature_partials_, stash_candidate_, stash_current_; RealView2 statistics_power_, plasma_statistics_scratch_; - // solprop SEP 34: each node's `|E| |G|` beside its signed flux, which bounds the + // solprop SEP 34: each node's `|E| |G|` beside its signed flux, which bounds a radial // fluence's rounding; allocated with the closure's buffers. RealView2 statistics_magnitude_; + // `spatial_statistics_partials_` holds a plane's scaled fluence for its trace. + bool plane_fluence_held_ = false; RealView2 plasma_rate_, plasma_exposure_, plasma_fraction_; RealView2 plasma_current_integrand_, plasma_free_current_; // The device trapezoid plasma's tile totals, by component row, and its tile count @@ -3523,7 +3579,7 @@ void GeneralWorkspace::allocate() { problem_.n_t); statistics_power_ = RealView2("solprop_native.statistics.power", statistic_nodes, problem_.n_t); - if (problem_.flux_closure) + if (problem_.flux_closure && !problem_.separable_geometry()) statistics_magnitude_ = RealView2("solprop_native.statistics.flux_magnitude", statistic_nodes, problem_.n_t); statistics_total_power_ = @@ -13652,7 +13708,8 @@ std::vector GeneralWorkspace::statistics( void GeneralWorkspace::require_flux_closure() { const auto rows = statistics_time_.extent(0); const bool time = statistics_weighted_time_.extent(0) != rows; - const bool magnitude = statistics_magnitude_.extent(0) != statistics_power_.extent(0); + const bool magnitude = !problem_.separable_geometry() && + statistics_magnitude_.extent(0) != statistics_power_.extent(0); const bool volume = problem_.separable_geometry() && flux_transverse_.empty(); if (!time && !magnitude && !volume) return; @@ -13702,7 +13759,7 @@ void GeneralWorkspace::fill_statistics_closure() { void GeneralWorkspace::separable_time_statistics(const std::size_t axis_peak, const std::size_t peak, - const std::size_t fluence, + const bool fluence, const double intensity_factor) { constexpr auto absent = std::numeric_limits::max(); constexpr double lowest = std::numeric_limits::lowest(); @@ -13715,7 +13772,6 @@ void GeneralWorkspace::separable_time_statistics(const std::size_t axis_peak, const auto scratch = transverse_; const auto closure_scratch = flux_transverse_; const auto power = statistics_power_; - const auto magnitude = statistics_magnitude_; const auto partials = spatial_statistics_partials_; const auto synthesis = synthesis_; const auto flux = flux_; @@ -13727,9 +13783,8 @@ void GeneralWorkspace::separable_time_statistics(const std::size_t axis_peak, const auto layout = transverse_layout(); const auto n = problem_.n_omega; const auto n_t = problem_.n_t; - const auto dt = problem_.dt; const bool envelope = problem_.family == FieldFamily::envelope; - if (axis_peak != absent || fluence != absent) { + if (axis_peak != absent) { // The axis's closure first, kept in the closure rows, then its field: each the // coherent on-axis sum of the analytic spectrum (solprop SEP 34). for (const bool closed : {true, false}) { @@ -13764,21 +13819,17 @@ void GeneralWorkspace::separable_time_statistics(const std::size_t axis_peak, "solprop_native.free.statistics_axis_reduce", Kokkos::RangePolicy(execution_space_, 0, 1), KOKKOS_LAMBDA(const std::size_t) { - double sum = 0.0, maximum = lowest; + double maximum = lowest; for (std::size_t time = 0; time < n_t; ++time) { double value = 0.0; for (std::size_t pol = 0; pol < n_pol; ++pol) value += (field(pol, time) * Kokkos::conj(closure(pol, time))).real(); - sum += value; maximum = Kokkos::max(maximum, value); } - if (axis_peak != absent) - packed(axis_peak) = intensity_factor * maximum; - if (fluence != absent) - packed(fluence + n_nodes) = intensity_factor * dt * sum; + packed(axis_peak) = intensity_factor * maximum; }); } - if (peak == absent && fluence == absent) + if (peak == absent && !fluence) return; const auto transverse_count = static_cast(problem_.slab_geometry() ? problem_.n_y : n_modes); @@ -13795,6 +13846,10 @@ void GeneralWorkspace::separable_time_statistics(const std::size_t axis_peak, }); transverse_inverse(); transverse_inverse(flux_transverse_); + if (fluence) + separable_fluence(intensity_factor); + if (peak == absent) + return; const bool slab = problem_.slab_geometry(); const auto n_x = problem_.n_x; const auto n_p = problem_.n_p; @@ -13849,41 +13904,280 @@ void GeneralWorkspace::separable_time_statistics(const std::size_t axis_peak, "solprop_native.free.statistics_intensity", Range2(execution_space_, {0, 0}, {count, n_t}), KOKKOS_LAMBDA(const std::size_t node, const std::size_t time) { - double value = 0.0, size = 0.0; - for (std::size_t pol = 0; pol < n_pol; ++pol) { - const Complex electric = field(node * n_pol + pol, time); - const Complex magnetic = closure(node * n_pol + pol, time); - value += (electric * Kokkos::conj(magnetic)).real(); - size += Kokkos::sqrt(squared_abs(electric) * squared_abs(magnetic)); - } + double value = 0.0; + for (std::size_t pol = 0; pol < n_pol; ++pol) + value += (field(node * n_pol + pol, time) * + Kokkos::conj(closure(node * n_pol + pol, time))) + .real(); power(node, time) = intensity_factor * value; - magnitude(node, time) = intensity_factor * size; }); launch::parallel_for( - "solprop_native.free.statistics_fluence", + "solprop_native.free.statistics_node_peak", Kokkos::RangePolicy(execution_space_, 0, count), KOKKOS_LAMBDA(const std::size_t node) { - double maximum = lowest, sum = 0.0, size = 0.0; - for (std::size_t time = 0; time < n_t; ++time) { - sum += power(node, time); - size += magnitude(node, time); + double maximum = lowest; + for (std::size_t time = 0; time < n_t; ++time) maximum = Kokkos::max(maximum, power(node, time)); - } partials(first + node, 1) = maximum; - if (fluence != absent) { - packed(fluence + first + node) = dt * sum; - packed(fluence + n_nodes + 1 + first + node) = dt * size; - } }); } - if (peak != absent) + launch::parallel_reduce( + "solprop_native.free.statistics_peak", + Kokkos::RangePolicy(execution_space_, 0, n_nodes), + KOKKOS_LAMBDA(const std::size_t node, double& maximum) { + maximum = Kokkos::max(maximum, partials(node, 1)); + }, + Kokkos::Max(Kokkos::subview(packed, peak))); +} + +// Each node's signed fluence into `partials(node, 0)` and a bound on its magnitude into +// `partials(node, 2)`, from the physical spectra of the transverse volumes, with no +// time transform. The analytic signal's spectrum is `s_k a_k`, with `s_k` one or two +// over `n_t`, so Parseval's theorem over the `n_t` samples gives `sum_t Re(E(t) +// conj(G(t))) = (1 / n_t) sum_k w_k Re(a_k conj(b_k))`, with `w_k` one or four: exactly +// the time sum the reference takes. The same pairs bound that sum's magnitude: by +// Cauchy-Schwarz over time and polarisation, `sum_t |E(t)| |G(t)| <= (1 / n_t) +// sqrt(sum_k w_k |a_k|^2 sum_k w_k |b_k|^2)`, with equality where `G` is proportional +// to `E`, as in a beam whose admittance varies little across its band. So a rounding +// budget set from it is never below the reference's. +void GeneralWorkspace::separable_fluence(const double intensity_factor) { + const auto scratch = transverse_; + const auto closure_scratch = flux_transverse_; + const auto partials = spatial_statistics_partials_; + const PhysicalSpectrum spectrum{transverse_layout(), + synthesis_, + problem_.n_x, + problem_.n_p, + static_cast(problem_.n_t) / + static_cast(problem_.n_to), + problem_.slab_geometry()}; + const auto n_nodes = problem_.n_nodes; + const auto n_pol = problem_.n_pol_state; + const auto n = problem_.n_omega; + const bool envelope = problem_.family == FieldFamily::envelope; + const double measure = + intensity_factor * problem_.dt / static_cast(problem_.n_t); +#if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) + // One node per thread, whole planes per block: adjacent threads read adjacent cells. + launch::parallel_for( + "solprop_native.free.statistics_fluence", + Kokkos::RangePolicy(execution_space_, 0, n_nodes), + KOKKOS_LAMBDA(const std::size_t node) { + double sum = 0.0, electric_size = 0.0, magnetic_size = 0.0; + for (std::size_t index = 0; index < n; ++index) { + const double weight = envelope || index == 0 || index + 1 == n ? 1.0 : 4.0; + for (std::size_t pol = 0; pol < n_pol; ++pol) { + const Complex electric = spectrum.read(scratch, index * n_pol + pol, node); + const Complex magnetic = + spectrum.read(closure_scratch, index * n_pol + pol, node); + sum += weight * (electric.real() * magnetic.real() + + electric.imag() * magnetic.imag()); + electric_size += weight * squared_abs(electric); + magnetic_size += weight * squared_abs(magnetic); + } + } + partials(node, 0) = measure * sum; + partials(node, 2) = measure * Kokkos::sqrt(electric_size * magnetic_size); + }); +#else + // A task takes a run of adjacent nodes and reads each plane's cells of the run in + // order: one node at a time would touch a new plane at every sample. + constexpr std::size_t run = 256; + const std::size_t runs = (n_nodes + run - 1) / run; + launch::parallel_for( + "solprop_native.free.statistics_fluence", + Kokkos::RangePolicy(execution_space_, 0, runs), + KOKKOS_LAMBDA(const std::size_t item) { + const std::size_t first = item * run; + const std::size_t count = n_nodes - first < run ? n_nodes - first : run; + double sum[run] = {}, electric_size[run] = {}, magnetic_size[run] = {}; + for (std::size_t index = 0; index < n; ++index) { + const double weight = envelope || index == 0 || index + 1 == n ? 1.0 : 4.0; + for (std::size_t pol = 0; pol < n_pol; ++pol) { + const auto plane = index * n_pol + pol; + for (std::size_t offset = 0; offset < count; ++offset) { + const Complex electric = spectrum.read(scratch, plane, first + offset); + const Complex magnetic = + spectrum.read(closure_scratch, plane, first + offset); + sum[offset] += weight * (electric.real() * magnetic.real() + + electric.imag() * magnetic.imag()); + electric_size[offset] += weight * squared_abs(electric); + magnetic_size[offset] += weight * squared_abs(magnetic); + } + } + } + for (std::size_t offset = 0; offset < count; ++offset) { + partials(first + offset, 0) = measure * sum[offset]; + partials(first + offset, 2) = + measure * Kokkos::sqrt(electric_size[offset] * magnetic_size[offset]); + } + }); +#endif +} + +// What a plane's widths read of its fluence (`separable_fluence`), each a reduction +// over the nodes, so that the profile itself stays on the device: `moments` takes the +// lowest value and its node, the largest magnitude, the largest value and the count of +// values that aren't finite and, when the plane has a positive value and none that +// isn't finite, the scaled profile's sum and index moments. That scaled profile is left +// in `partials(node, 0)` for `plane_fluence_trace`. Each reduction blocks: one plane's +// work. +void GeneralWorkspace::plane_fluence_moments(double (&moments)[plane_moment_slots]) { + const auto partials = spatial_statistics_partials_; + const auto n_nodes = problem_.n_nodes; + const auto n_x = problem_.n_x; + const Kokkos::RangePolicy nodes(execution_space_, 0, n_nodes); + std::size_t nonfinite = 0; + launch::parallel_reduce( + "solprop_native.free.plane_fluence_finite", nodes, + KOKKOS_LAMBDA(const std::size_t node, std::size_t& update) { + if (!Kokkos::isfinite(partials(node, 0))) + ++update; + }, + Kokkos::Sum(nonfinite)); + Kokkos::MinLoc::value_type worst{}; + launch::parallel_reduce( + "solprop_native.free.plane_fluence_worst", nodes, + KOKKOS_LAMBDA(const std::size_t node, + Kokkos::MinLoc::value_type& update) { + if (partials(node, 0) < update.val) { + update.val = partials(node, 0); + update.loc = node; + } + }, + Kokkos::MinLoc(worst)); + double size = 0.0; + launch::parallel_reduce( + "solprop_native.free.plane_fluence_size", nodes, + KOKKOS_LAMBDA(const std::size_t node, double& update) { + update = Kokkos::max(update, partials(node, 2)); + }, + Kokkos::Max(size)); + double highest = 0.0; + launch::parallel_reduce( + "solprop_native.free.plane_fluence_highest", nodes, + KOKKOS_LAMBDA(const std::size_t node, double& update) { + update = Kokkos::max(update, partials(node, 0)); + }, + Kokkos::Max(highest)); + // A negative value within its budget is read as zero, as the reference reads it. + const double maximum = Kokkos::max(highest, 0.0); + FluenceMoments sums; + plane_fluence_held_ = nonfinite == 0 && maximum > 0.0; + if (plane_fluence_held_) launch::parallel_reduce( - "solprop_native.free.statistics_peak", - Kokkos::RangePolicy(execution_space_, 0, n_nodes), - KOKKOS_LAMBDA(const std::size_t node, double& maximum) { - maximum = Kokkos::max(maximum, partials(node, 1)); + "solprop_native.free.plane_fluence_moments", nodes, + KOKKOS_LAMBDA(const std::size_t node, FluenceMoments& update) { + // Scaling cancels in the centroid and the widths and keeps them from + // overflowing on a bright beam. + const double sample = Kokkos::max(partials(node, 0), 0.0) / maximum; + partials(node, 0) = sample; + update.total += sample; + update.x += sample * static_cast(node % n_x); + update.y += sample * static_cast(node / n_x); }, - Kokkos::Max(Kokkos::subview(packed, peak))); + Kokkos::Sum(sums)); + moments[0] = worst.val; + moments[1] = static_cast(worst.loc); + moments[2] = size; + moments[3] = maximum; + moments[4] = static_cast(nonfinite); + moments[5] = sums.total; + moments[6] = sums.x; + moments[7] = sums.y; +} + +// The second moment and the azimuthal average of a plane's scaled fluence, left by a +// call that asked for its moments. The rule is solprop's +// (`solprop.spatial.cartesian_fluence_widths`), which owns the rings and hands them +// over: each ring samples the bilinear interpolant at `cosines.size()` angles about the +// centroid, with the centre in metres from the axes' first samples, so that only the +// trace crosses, not the profile. Returned: the second-moment sum, then the trace. +std::vector GeneralWorkspace::plane_fluence_trace( + const double center_x, const double center_y, const double dx, const double dy, + const double extent, const double spacing, const std::size_t n_radii, + const std::vector& cosines, const std::vector& sines) { + require_open(); + if (!plane_fluence_held_) + throw std::logic_error("a plane's fluence trace follows the call that holds it"); + plane_fluence_held_ = false; + if (cosines.size() != sines.size() || (n_radii != 0 && cosines.empty()) || + !(dx > 0.0) || !(dy > 0.0) || !(extent > 0.0) || + (n_radii != 0 && !(spacing > 0.0))) + throw std::invalid_argument("invalid fluence rings"); + const auto n_angles = cosines.size(); + const auto packed_size = + checked_sum(checked_sum(1, n_radii, "fluence trace"), + checked_product(2, n_angles, "fluence rings"), "fluence trace"); + if (statistics_packed_.size() < packed_size) { + execution_space_.fence("solprop_native spatial statistics output resize"); + statistics_packed_ = RealView("solprop_native.statistics.packed", packed_size); + } + const auto packed = statistics_packed_; + const auto samples = spatial_statistics_partials_; + const auto n_x = problem_.n_x; + const auto n_y = problem_.n_y; + const auto first_ring = n_radii + 1; + if (n_angles != 0) { + std::vector rings(cosines); + rings.insert(rings.end(), sines.begin(), sines.end()); + copy_vector(execution_space_, + Kokkos::subview(packed, std::make_pair(first_ring, packed_size)), + rings); + } + double second = 0.0; + launch::parallel_reduce( + "solprop_native.free.plane_fluence_second", + Kokkos::RangePolicy(execution_space_, 0, problem_.n_nodes), + KOKKOS_LAMBDA(const std::size_t node, double& update) { + const double x = (static_cast(node % n_x) * dx - center_x) / extent; + const double y = (static_cast(node / n_x) * dy - center_y) / extent; + update += samples(node, 0) * (x * x + y * y); + }, + Kokkos::Sum(second)); + const double last_x = static_cast(n_x - 1); + const double last_y = static_cast(n_y - 1); + // One thread per ring: a ring's samples are few, and a thread's sum needs no + // reduction. + launch::parallel_for( + "solprop_native.free.plane_fluence_rings", + Kokkos::RangePolicy(execution_space_, 0, n_radii), + KOKKOS_LAMBDA(const std::size_t ring) { + const double distance = spacing * static_cast(ring); + double sum = 0.0; + for (std::size_t angle = 0; angle < n_angles; ++angle) { + const double horizontal = Kokkos::min( + Kokkos::max((center_x + distance * packed(first_ring + angle)) / dx, 0.0), + last_x); + const double vertical = Kokkos::min( + Kokkos::max( + (center_y + distance * packed(first_ring + n_angles + angle)) / dy, + 0.0), + last_y); + const auto ix = + Kokkos::min(static_cast(horizontal), n_x - std::size_t{2}); + const auto iy = + Kokkos::min(static_cast(vertical), n_y - std::size_t{2}); + const double fx = horizontal - static_cast(ix); + const double fy = vertical - static_cast(iy); + const auto low = iy * n_x + ix; + const auto high = low + n_x; + sum += + (1.0 - fy) * ((1.0 - fx) * samples(low, 0) + fx * samples(low + 1, 0)) + + fy * ((1.0 - fx) * samples(high, 0) + fx * samples(high + 1, 0)); + } + packed(1 + ring) = sum / static_cast(n_angles); + }); + const auto trace = + Kokkos::subview(packed, std::make_pair(std::size_t{1}, first_ring)); + auto host = Kokkos::create_mirror_view(trace); + graph::deep_copy(execution_space_, host, trace); + execution_space_.fence("solprop_native fluence trace boundary"); + std::vector result(first_ring); + result[0] = second; + for (std::size_t ring = 0; ring < n_radii; ++ring) + result[1 + ring] = host(ring); + return result; } std::vector GeneralWorkspace::aperture_statistics( @@ -13911,10 +14205,13 @@ std::vector GeneralWorkspace::spatial_statistics(const std::uint64_t fla "spatial statistics need an initial field and spatial geometry"); if (flux_.square.empty()) throw std::logic_error("spatial statistics need the run's plane measure"); - if ((flags & ~std::uint64_t{31}) != 0 || !std::isfinite(intensity_factor) || + if ((flags & ~std::uint64_t{63}) != 0 || !std::isfinite(intensity_factor) || intensity_factor <= 0.0 || !std::isfinite(z)) throw std::invalid_argument( "invalid spatial statistic flags, intensity factor or position"); + if ((flags & 32U) != 0 && + (!problem_.free_geometry() || problem_.slab_geometry() || problem_.n_y < 2)) + throw std::invalid_argument("a plane's fluence moments need a Cartesian plane"); constexpr auto absent = std::numeric_limits::max(); constexpr double lowest = std::numeric_limits::lowest(); std::size_t packed_size = 0; @@ -13930,12 +14227,13 @@ std::vector GeneralWorkspace::spatial_statistics(const std::uint64_t fla const auto axis_peak_offset = reserve(2, 1); const auto peak_offset = reserve(4, 1); const auto spectrum_offset = reserve(8, problem_.n_omega); - // Widths read the signed fluence at the nodes, the separately reconstructed axis - // and each node's magnitude `dt sum |E| |G|`, whose largest value fixes the + // Widths read the signed fluence at the nodes, the separately reconstructed axis, + // which only a radial width reads, and the largest magnitude, which fixes the // fluence's rounding budget (solprop SEP 34). const auto fluence_offset = - reserve(16, checked_sum(checked_product(2, problem_.n_nodes, "spatial nodes"), 1, - "spatial statistics output")); + reserve(16, checked_sum(problem_.n_nodes, 2, "spatial statistics output")); + // On a Cartesian plane the widths reduce here instead, and the moments cross. + const auto moments_offset = reserve(32, plane_moment_slots); if (packed_size == 0) return {}; if (statistics_packed_.size() < packed_size) { @@ -13944,7 +14242,7 @@ std::vector GeneralWorkspace::spatial_statistics(const std::uint64_t fla } // Every field statistic is the axial flux of the medium at the plane z. flux_at(z); - const bool local = (flags & (2U | 4U | 16U)) != 0; + const bool local = (flags & (2U | 4U | 16U | 32U)) != 0; if (local) require_flux_closure(); const auto packed = statistics_packed_; @@ -14014,9 +14312,29 @@ std::vector GeneralWorkspace::spatial_statistics(const std::uint64_t fla (2.0 * Kokkos::numbers::pi_v)*weight * power; }); } + double moments[plane_moment_slots] = {}; if (problem_.separable_geometry()) { - separable_time_statistics(axis_peak_offset, peak_offset, fluence_offset, + separable_time_statistics(axis_peak_offset, peak_offset, + fluence_offset != absent || moments_offset != absent, intensity_factor); + if (fluence_offset != absent) { + launch::parallel_for( + "solprop_native.free.fluence_rows", + Kokkos::RangePolicy(execution_space_, 0, n_nodes), + KOKKOS_LAMBDA(const std::size_t node) { + packed(fluence_offset + node) = partials(node, 0); + }); + launch::parallel_reduce( + "solprop_native.free.fluence_largest", + Kokkos::RangePolicy(execution_space_, 0, n_nodes), + KOKKOS_LAMBDA(const std::size_t node, double& maximum) { + maximum = Kokkos::max(maximum, partials(node, 2)); + }, + Kokkos::Max( + Kokkos::subview(packed, fluence_offset + n_nodes + 1))); + } + if (moments_offset != absent) + plane_fluence_moments(moments); } else if (local) { if (n_nodes != n_modes) throw std::logic_error("radial statistics need one node per Hankel column"); @@ -14119,11 +14437,19 @@ std::vector GeneralWorkspace::spatial_statistics(const std::uint64_t fla size += magnitude(node, time); } partials(node, 1) = peak; - if (fluence_offset != absent) { + partials(node, 2) = dt * size; + if (fluence_offset != absent) packed(fluence_offset + node) = dt * sum; - packed(fluence_offset + n_nodes + 1 + node) = dt * size; - } }); + if (fluence_offset != absent) + launch::parallel_reduce( + "solprop_native.spatial.fluence_largest", + Kokkos::RangePolicy(execution_space_, 0, n_nodes), + KOKKOS_LAMBDA(const std::size_t node, double& maximum) { + maximum = Kokkos::max(maximum, partials(node, 2)); + }, + Kokkos::Max( + Kokkos::subview(packed, fluence_offset + n_nodes + 1))); if (peak_offset != absent) launch::parallel_for( "solprop_native.spatial.pack_peak", @@ -14144,6 +14470,11 @@ std::vector GeneralWorkspace::spatial_statistics(const std::uint64_t fla std::vector result(packed_size); for (std::size_t index = 0; index < packed_size; ++index) result[index] = host(index); + // The separable routes don't form the axis's fluence: only a radial width reads it. + if (fluence_offset != absent && problem_.separable_geometry()) + result[fluence_offset + n_nodes] = Kokkos::Experimental::quiet_NaN_v; + if (moments_offset != absent) + std::copy_n(moments, plane_moment_slots, result.data() + moments_offset); return result; } @@ -15777,6 +16108,14 @@ std::vector GeneralState::spatial_statistics(const std::uint64_t flags, const CpuTeamScope team(workspace_->cpu_team()); return workspace_->spatial_statistics(flags, intensity_factor, z); } +std::vector GeneralState::plane_fluence_trace( + const double center_x, const double center_y, const double dx, const double dy, + const double extent, const double spacing, const std::size_t n_radii, + const std::vector& cosines, const std::vector& sines) { + const CpuTeamScope team(workspace_->cpu_team()); + return workspace_->plane_fluence_trace(center_x, center_y, dx, dy, extent, spacing, + n_radii, cosines, sines); +} std::vector GeneralState::aperture_statistics( const std::uint64_t flags, const std::vector& apertures, const int exponent, const std::vector& regions, const double intensity_factor) { diff --git a/native/src/solprop_native/_core.pyi b/native/src/solprop_native/_core.pyi index 59e33d4a..066406b8 100644 --- a/native/src/solprop_native/_core.pyi +++ b/native/src/solprop_native/_core.pyi @@ -50,6 +50,18 @@ class State: def spatial_statistics( self, flags: int, intensity_factor: float, z: float ) -> npt.NDArray[np.float64]: ... + def plane_fluence_trace( + self, + center_x: float, + center_y: float, + dx: float, + dy: float, + extent: float, + spacing: float, + n_radii: int, + cosines: npt.NDArray[np.float64], + sines: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: ... def aperture_statistics( self, flags: int, diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 4de12081..dcf2e9b3 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -65,6 +65,7 @@ FarFieldTables, far_field_partials, fluence_budget, + plane_fluence_widths, radial_absorber_matrix, signed_fluence_widths, ) @@ -201,6 +202,17 @@ def __exit__( "fwhm_r": 16, } +_PLANE_WIDTHS_FLAG = 32 +"""A Cartesian plane's widths: the native moments of its fluence, not flag 16's rows. + +The native pass holds the fluence and +:func:`solprop.backends.spatial.plane_fluence_widths` reads the reductions. Radial, line +and planar profiles are small, and cross. +""" + +_PLANE_MOMENTS = 8 +"""The values flag 32 packs, in the order ``plane_fluence_widths`` reads them.""" + _FAR_FIELD_FLAGS = { "beam_spectrum": 1 << 5, "absorbed_power": 1 << 7, @@ -1430,6 +1442,9 @@ def _spatial_statistics( flags = 0 for name in names: flags |= _SPATIAL_STAT_FLAGS[name] + plane = geometry.kind == "free" and geometry.axes[1].size > 1 + if plane and flags & _SPATIAL_STAT_FLAGS["beam_radius"]: + flags += _PLANE_WIDTHS_FLAG - _SPATIAL_STAT_FLAGS["beam_radius"] packed = np.asarray( self._native_state.spatial_statistics(flags, 0.5 * EPS0 * C, self._z) ).ravel() @@ -1446,23 +1461,33 @@ def _spatial_statistics( values[name] = data[None] if name == "on_axis_spectrum" else data offset += count if names & {"beam_radius", "fwhm_r"}: - # The signed fluence, the axis fluence and each node's magnitude, whose - # largest value fixes the rounding budget (solprop SEP 34). - count = self._problem.transform.n_nodes - fluence = packed[offset : offset + count] - axis_fluence = float(packed[offset + count]) - magnitude = packed[offset + count + 1 : offset + 2 * count + 1] - n_pol = self._problem.shape[2] - budget = fluence_budget( - magnitude, - n_pol * self._problem.grids.n_t + self._problem.transform.n_modes, + n_terms = ( + self._problem.shape[2] * self._problem.grids.n_t + + self._problem.transform.n_modes ) - widths = signed_fluence_widths(geometry, fluence, budget, axis_fluence) + if plane: + widths = plane_fluence_widths( + geometry, + packed[offset : offset + _PLANE_MOMENTS], + n_terms, + self._native_state.plane_fluence_trace, + ) + offset += _PLANE_MOMENTS + else: + # The signed fluence, the axis fluence (a radial width's alone) and the + # largest magnitude, which fixes the rounding budget (solprop SEP 34). + count = self._problem.transform.n_nodes + widths = signed_fluence_widths( + geometry, + packed[offset : offset + count], + fluence_budget(np.full(count, packed[offset + count + 1]), n_terms), + float(packed[offset + count]), + ) + offset += count + 2 if "beam_radius" in names: values["beam_radius"] = np.asarray([widths[:2]]) if "fwhm_r" in names: values["fwhm_r"] = np.asarray([widths[2]]) - offset += 2 * count + 1 if offset != packed.size: raise BackendError( f"native spatial statistics returned {packed.size} values; " diff --git a/src/solprop/backends/spatial.py b/src/solprop/backends/spatial.py index 9563df5c..f3370d28 100644 --- a/src/solprop/backends/spatial.py +++ b/src/solprop/backends/spatial.py @@ -4,7 +4,8 @@ """Collect spatial host statistics through bounded transverse reconstruction chunks.""" -from collections.abc import Collection, Mapping +import math +from collections.abc import Callable, Collection, Mapping from dataclasses import dataclass, fields import numpy as np @@ -12,7 +13,7 @@ from solprop import fourier from solprop.constants import EPS0, C -from solprop.exceptions import BackendError +from solprop.exceptions import BackendError, GridError from solprop.grid import TimeGrid from solprop.profiles import ZProfile from solprop.spatial import ( @@ -22,7 +23,9 @@ ApertureTables, aperture_statistics, cartesian_fluence_widths, + fluence_rings, radial_fluence_widths, + ring_fluence_widths, ) from solprop.stats import PROFILE_STATISTICS, StatsSpec, analytic_field, profile_value from solprop.translate.modal import Transform @@ -50,6 +53,7 @@ "far_field_layout", "far_field_partials", "fluence_budget", + "plane_fluence_widths", "radial_absorber_matrix", "signed_fluence_widths", "slab_columns", @@ -800,12 +804,7 @@ def signed_fluence_widths( """ if np.any(fluence < -budget): worst = int(np.argmin(fluence + budget)) - msg = ( - "beam_radius and fwhm_r need a nonnegative fluence profile, and the " - f"signed axial flux is {fluence[worst]:.3e} J/m^2 at node {worst}, " - "beyond its rounding budget; the width is undefined for it" - ) - raise BackendError(msg) + raise _negative_fluence(float(fluence[worst]), worst) fluence = np.maximum(fluence, 0.0) if geometry.kind == "radial": return radial_fluence_widths( @@ -817,6 +816,112 @@ def signed_fluence_widths( return cartesian_fluence_widths(geometry.axes[0], geometry.axes[1], fluence) +def plane_fluence_widths( + geometry: SpatialGeometry, + moments: Float[np.ndarray, " 8"], + n_terms: int, + trace: Callable[..., np.ndarray], +) -> tuple[float, float, float]: + """Return a Cartesian plane's widths from reductions a backend took of its fluence. + + :func:`signed_fluence_widths` and :func:`solprop.spatial.cartesian_fluence_widths` + for a backend that reduces the fluence where the field lives, so that the profile + itself never crosses: the refusal, the clamp, the centroid, the rings and the widths + are read as those functions read them. + + Parameters + ---------- + geometry : solprop.translate.spatial.SpatialGeometry + The plane's ``"free"`` geometry, with at least two samples on each axis. + moments : numpy.ndarray + The lowest fluence in J/m^2, its node, the bound on the largest magnitude in + J/m^2 (at least :func:`fluence_budget`'s), the largest fluence read as + nonnegative, the count of values that aren't finite, then the sum of the fluence + scaled by that largest value, clamped to be nonnegative, and that sum weighted + by the samples' x and y indices. + n_terms : int + As for :func:`fluence_budget`. + trace : collections.abc.Callable + Called with the centroid in m from the axes' first samples, their spacings, the + extent, the rings' spacing, their count, and the rings' cosines and sines. It + returns the sum of the scaled fluence times the squared distance from the + centroid over the squared extent, then the scaled fluence averaged on each ring. + + Returns + ------- + tuple of float + Second-moment, 1/e^2 and FWHM widths in m. + + Raises + ------ + BackendError + If the fluence is negative beyond its budget anywhere. + GridError + If a value isn't finite or the second moment isn't representable. + """ + worst, node, size, maximum, nonfinite, total, moment_x, moment_y = moments + if nonfinite: + msg = ( + "Cartesian fluence widths require finite axes " + "and matching nonnegative samples" + ) + raise GridError(msg) + if worst < -fluence_budget(np.array([size]), n_terms)[0]: + raise _negative_fluence(float(worst), int(node)) + if maximum == 0: + return math.nan, math.nan, math.nan + x, y = geometry.axes + dx, dy = float(x[1] - x[0]), float(y[1] - y[0]) + center_x = float(x[0]) + dx * float(moment_x / total) + center_y = float(y[0]) + dy * float(moment_y / total) + extent = math.hypot( + max(center_x - x[0], x[-1] - center_x), max(center_y - y[0], y[-1] - center_y) + ) + rings = fluence_rings(x, y, center_x, center_y) + empty = np.empty(0) + packed = trace( + center_x - float(x[0]), + center_y - float(y[0]), + dx, + dy, + extent, + float(rings.radii[1]) if rings is not None else 0.0, + 0 if rings is None else rings.radii.size, + empty if rings is None else rings.cosines, + empty if rings is None else rings.sines, + ) + values = np.ravel(packed) + second = extent * math.sqrt(2 * float(values[0]) / float(total)) + if not math.isfinite(second): + msg = "Cartesian fluence moments are not representable" + raise GridError(msg) + if rings is None: + return second, math.nan, math.nan + return second, *ring_fluence_widths(rings.radii, values[1:]) + + +def _negative_fluence(value: float, node: int) -> BackendError: + """Return the refusal of a fluence negative beyond its rounding budget. + + Parameters + ---------- + value : float + The fluence in J/m^2. + node : int + Its node. + + Returns + ------- + solprop.exceptions.BackendError + The refusal, to raise. + """ + return BackendError( + "beam_radius and fwhm_r need a nonnegative fluence profile, and the " + f"signed axial flux is {value:.3e} J/m^2 at node {node}, " + "beyond its rounding budget; the width is undefined for it" + ) + + def _rows( transform: Transform, grid: TimeGrid, diff --git a/src/solprop/spatial.py b/src/solprop/spatial.py index 84e67fd6..4c801076 100644 --- a/src/solprop/spatial.py +++ b/src/solprop/spatial.py @@ -53,6 +53,7 @@ "ApertureShape", "ApertureTables", "AxialFactors", + "FluenceRings", "FluxSide", "SpatialDispersion", "SpatialFlux", @@ -68,6 +69,7 @@ "check_aperture_shape", "critical_power", "edge_profile", + "fluence_rings", "fwhm_r", "mask_plane_offsets", "mask_plane_radius", @@ -82,6 +84,7 @@ "power_trace", "quadrature_half_width", "radial_fluence_widths", + "ring_fluence_widths", "spatial_energy", "spatial_energy_spectrum", "spatial_fluence", @@ -3096,24 +3099,16 @@ def cartesian_fluence_widths( if not math.isfinite(second): msg = "Cartesian fluence moments are not representable" raise GridError(msg) - limit = min(center_x - x[0], x[-1] - center_x, center_y - y[0], y[-1] - center_y) - spacing = min(x[1] - x[0], y[1] - y[0]) / 2 - if limit < 2 * spacing: + rings = fluence_rings(x, y, center_x, center_y) + if rings is None: return second, math.nan, math.nan - # Anchor the rule to the grid, not the rounded centroid: otherwise a - # one-ulp change in the limiting radius can change every interpolation - # point through ceil/linspace, amplifying roundoff into sampling error. - radii = spacing * np.arange(math.floor(limit / spacing) + 1) - largest_circle = min(x[-1] - x[0], y[-1] - y[0]) / 2 - n_angles = max(8, math.ceil(2 * np.pi * largest_circle / spacing)) - angles = 2 * np.pi * np.arange(n_angles) / n_angles - averaged = np.empty(radii.size) - for index, distance in enumerate(radii): + averaged = np.empty(rings.radii.size) + for index, distance in enumerate(rings.radii): horizontal = np.clip( - (center_x + distance * np.cos(angles) - x[0]) / (x[1] - x[0]), 0, x.size - 1 + (center_x + distance * rings.cosines - x[0]) / (x[1] - x[0]), 0, x.size - 1 ) vertical = np.clip( - (center_y + distance * np.sin(angles) - y[0]) / (y[1] - y[0]), 0, y.size - 1 + (center_y + distance * rings.sines - y[0]) / (y[1] - y[0]), 0, y.size - 1 ) ix = np.minimum(horizontal.astype(np.intp), x.size - 2) iy = np.minimum(vertical.astype(np.intp), y.size - 2) @@ -3122,10 +3117,87 @@ def cartesian_fluence_widths( (1 - fx) * samples[iy, ix] + fx * samples[iy, ix + 1] ) + fy * ((1 - fx) * samples[iy + 1, ix] + fx * samples[iy + 1, ix + 1]) averaged[index] = np.mean(interpolated) + return second, *ring_fluence_widths(rings.radii, averaged) + + +@dataclass(frozen=True) +class FluenceRings: + """The rings on which a Cartesian fluence is averaged about its centroid. + + Attributes + ---------- + radii : numpy.ndarray + The rings' radii in m, the first zero, shape ``(n_radii,)``. + cosines, sines : numpy.ndarray + The cosine and the sine of each ring's sampling angles, shape ``(n_angles,)``. + """ + + radii: Float[np.ndarray, " n_radii"] + cosines: Float[np.ndarray, " n_angles"] + sines: Float[np.ndarray, " n_angles"] + + +def fluence_rings( + x: Float[np.ndarray, " n_x"], + y: Float[np.ndarray, " n_y"], + center_x: float, + center_y: float, +) -> FluenceRings | None: + """Return the rings of :func:`cartesian_fluence_widths` about a centroid. + + Half the finest pixel spacing in radius, out to the largest circle about the + centroid inside the axes, and an angular spacing no larger than that arc length on + the largest ring of the plane. + + Parameters + ---------- + x, y : numpy.ndarray + Uniform increasing physical axes in m, of at least two samples each. + center_x, center_y : float + The fluence centroid in m. + + Returns + ------- + FluenceRings or None + The rings, or ``None`` when the centroid lies within two half-spacings of an + edge, where the threshold widths are undefined. + """ + limit = min(center_x - x[0], x[-1] - center_x, center_y - y[0], y[-1] - center_y) + spacing = min(x[1] - x[0], y[1] - y[0]) / 2 + if limit < 2 * spacing: + return None + # Anchor the rule to the grid, not the rounded centroid: otherwise a + # one-ulp change in the limiting radius can change every interpolation + # point through ceil/linspace, amplifying roundoff into sampling error. + radii = spacing * np.arange(math.floor(limit / spacing) + 1) + largest_circle = min(x[-1] - x[0], y[-1] - y[0]) / 2 + n_angles = max(8, math.ceil(2 * np.pi * largest_circle / spacing)) + angles = 2 * np.pi * np.arange(n_angles) / n_angles + return FluenceRings(radii, np.cos(angles), np.sin(angles)) + + +def ring_fluence_widths( + radii: Float[np.ndarray, " n_radii"], averaged: Float[np.ndarray, " n_radii"] +) -> tuple[float, float]: + """Return the threshold widths of a fluence averaged on its rings. + + Parameters + ---------- + radii : numpy.ndarray + The rings' radii in m, the first zero (:func:`fluence_rings`). + averaged : numpy.ndarray + The nonnegative fluence averaged on each ring. + + Returns + ------- + tuple of float + The first 1/e^2 radius and the FWHM in m, as :func:`radial_fluence_widths` + defines them, with the first ring as the axis. + """ _, radius_e2, width = radial_fluence_widths( radii[1:], averaged[1:], np.ones(radii.size - 1), float(averaged[0]) ) - return second, radius_e2, width + return radius_e2, width def _line_fluence_widths( diff --git a/tests/backends/test_flux_parity.py b/tests/backends/test_flux_parity.py index b717f545..06caf005 100644 --- a/tests/backends/test_flux_parity.py +++ b/tests/backends/test_flux_parity.py @@ -228,7 +228,8 @@ def test_the_native_closure_buffers_are_in_the_ledger(): assert ledger["flux_closure_transverse"] == closure # A separable run's plane measure is its operator's own views. assert ledger["flux_coefficients"] == ledger["flux_table"] == 0 - assert ledger["statistics_flux_magnitude"] > 0 + # Its fluence takes no time transform, so it holds no magnitude per sample. + assert ledger["statistics_flux_magnitude"] == 0 finally: owner.close() # Without a local statistic nothing is held until a call first needs it. @@ -255,5 +256,6 @@ def test_the_native_closure_buffers_are_in_the_ledger(): flux.material.coefficients.size * 16 + flux.material.breakpoints.size * 8 ) assert ledger["flux_closure_transverse"] == 0 + assert ledger["statistics_flux_magnitude"] > 0 finally: owner.close() diff --git a/tests/backends/test_native_widths.py b/tests/backends/test_native_widths.py new file mode 100644 index 00000000..6abf818d --- /dev/null +++ b/tests/backends/test_native_widths.py @@ -0,0 +1,545 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The fluence widths the native backend reduces in place, against the reference. + +A separable route's fluence takes no time transform natively: Parseval's theorem turns +the reference's sum over ``n_t`` samples into a sum over the bins it has already +synthesised. On a Cartesian plane the centroid, the second moment and the azimuthal +average are reductions too, so only their numbers cross. The reference forms each of +these on the host, in time. Every comparison is one state, loaded at one plane, held to +a rounding budget fixed here before the comparison (SEP 25), on the states a width is +most exposed on: a beam off centre, a hollow profile, one node, one column, one bin, a +beam in a corner, and two beams that cancel on the axis. +""" + +import dataclasses +import importlib.util +import math + +import numpy as np +import pytest + +import solprop.backends.spatial as host_spatial +from backends.planar_cases import planar_case +from bounds import assert_within, device_backend, gamma +from solprop.backends import select +from solprop.backends.spatial import fluence_budget, plane_fluence_widths +from solprop.constants import EPS0, C +from solprop.driver import Simulation, SimulationSettings +from solprop.exceptions import BackendError, GridError +from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, LineGrid +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import ( + SpatialMedium, + cartesian_fluence_widths, + fluence_rings, + ring_fluence_widths, +) +from solprop.stats import StatsSpec +from solprop.stepper import PIController, RungeKutta + +pytestmark = [ + pytest.mark.filterwarnings("ignore::solprop.exceptions.MaterialRangeWarning"), + pytest.mark.filterwarnings( + "ignore:the temporal absorber attenuates a packet walking off" + ":solprop.exceptions.SolpropWarning" + ), +] + +WIDTHS = ("beam_radius", "fwhm_r") +LOSSY = SpatialMedium(1.45 + 2e-3j) +"""Glass whose admittance is complex, so that the closure's cross terms are real.""" + +HAS_NATIVE = importlib.util.find_spec("solprop_native") is not None +native_only = pytest.mark.skipif(not HAS_NATIVE, reason="solprop-native is absent") + + +def _time_grid(kind): + if kind == "envelope": + return EnvelopeGrid.from_limits(1e-3, 800e-9, (600e-9, 1.2e-6), 150e-15) + return FieldGrid.from_limits(1e-3, 800e-9, (400e-9, 2e-6), 40e-15) + + +def _simulation(kind, space, n_pol, medium=LOSSY, waist=60e-6): + grid = _time_grid(kind) + settings = SimulationSettings( + controller=PIController(rtol=1e-6, atol=1e-14), + save_planes=[1e-3], + dz_max=2.5e-4, + dz_init=2.5e-4, + absorber=None, + integrator=RungeKutta(), + stats=StatsSpec(names=WIDTHS), + ) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), + waist, + **({"jones": (1, 0.5j)} if n_pol == 2 else {}), + ) + simulation = Simulation.from_free( + grid, space, medium, [], pulse, settings=settings, noise=None + ) + return grid, simulation.translate() + + +def _gaussian(x, y, x0=0.0, y0=0.0, waist=6e-5): + return np.exp(-(((x - x0) ** 2 + (y - y0) ** 2) / waist**2)) + + +def _profile(name, x, y): + """Return one state's transverse amplitude, on the grid of ``x`` and ``y``.""" + if name == "off_centre": + return _gaussian(x, y, 7.3e-5, -4.1e-5) + if name == "hollow": + return np.hypot(x, y) / 6e-5 * _gaussian(x, y) + if name == "one_node": + return ((x == x.flat[x.size // 2]) & (y == y.flat[y.size // 2])).astype(float) + if name == "corner": + return _gaussian(x, y, x.min(), y.min(), 2e-5) + # A line of near-cancellation through the axis, where the budget is read as zero. + assert name == "cancelling" + return _gaussian(x, y, 2e-5, 0.0) - _gaussian(x, y, -2e-5, 0.0) + + +def _state(problem, grid, name): + """Return one member's internal spectrum, with the launch's own temporal shape.""" + geometry = problem.transform.geometry + launch = np.asarray(problem.initial.field0) + n_y, n_x = geometry.axes[1].size, geometry.axes[0].size + n_pol, n_omega = problem.shape[2], problem.shape[3] + # The launch's own bins, weighted by polarisation, and a chirp-like phase, so that + # the bins interfere in time and the temporal sum is not trivially the spectral one. + rng = np.random.default_rng(34) + spectrum = launch.reshape(n_y * n_x, n_pol, n_omega).sum(axis=0) + spectrum = spectrum * np.exp(1j * rng.uniform(-0.3, 0.3, n_omega)) + if name == "one_bin": + peak = int(np.argmax(np.sum(np.abs(spectrum) ** 2, axis=0))) + bin_only = np.zeros_like(spectrum) + bin_only[:, peak] = spectrum[:, peak] + spectrum = bin_only + x, y = np.meshgrid(geometry.axes[0], geometry.axes[1]) + columns = np.fft.fft2(_gaussian(x, y)).ravel() + elif name == "one_column": + columns = np.zeros(n_y * n_x, complex) + columns[n_x + 1] = 1.0 + else: + x, y = np.meshgrid(geometry.axes[0], geometry.axes[1]) + columns = np.fft.fft2(_profile(name, x, y)).ravel() + field = np.einsum("r,pw->rpw", columns, spectrum)[None] + return field * (1e3 / np.max(np.abs(field))) + + +def _measure(name, grid, problem, field, spec): + backend = select(name, problem, grid=grid) + try: + backend.set_initial(field, None, z=0.0) + return backend.stats(spec) + finally: + backend.close() + + +def _width_budget(grid, problem): + """A width's rounding budget, relative to its value (``test_flux_parity._budget``). + + The fluence synthesises E and G at the nodes (``n_columns`` terms, a transverse + FFT) and the reference inverts the time transform (log2 n_t stages); the pairing + sums ``n_pol * n_t`` products. A width interpolates a fluence whose relative slope + is of order one at its crossing, which a factor of eight covers. + """ + n_columns, n_pol = problem.shape[1], problem.shape[2] + return gamma(8 * 16 * (n_columns + n_pol * grid.n_t + 64 * int(np.log2(grid.n_t)))) + + +def _agree(got, want, bound, pixel, what): + for name in WIDTHS: + actual, expected = np.ravel(got[name]), np.ravel(want[name]) + # A width the rule leaves undefined (a hollow profile, a beam at an edge) is + # undefined on both, an exact structural contract. + np.testing.assert_array_equal( + np.isnan(actual), np.isnan(expected), err_msg=what + ) + live = ~np.isnan(expected) + if np.any(live): + # A pixel at least: a point beam's moment is its centroid's rounding, whose + # size is the axes', not the width's. + scale = max(float(np.linalg.norm(expected[live])), pixel) + assert_within( + actual[live], expected[live], bound, scale=scale, what=f"{what} {name}" + ) + + +STATES = ( + "off_centre", + "hollow", + "one_node", + "one_column", + "one_bin", + "corner", + "cancelling", +) + + +def _check_plane(kind, n_pol, state): + # Unequal samples and spacings: the rule's dx, dy and its y-first order. + space = FreeGrid(3e-4, 32, 4e-4, 24) + grid, problem = _simulation(kind, space, n_pol) + assert problem.shape[2] == n_pol + field = _state(problem, grid, state) + spec = StatsSpec(names=WIDTHS) + want = _measure("numpy_ref", grid, problem, field, spec) + got = _measure("native", grid, problem, field, spec) + x = problem.transform.geometry.axes[0] + pixel = float(x[1] - x[0]) + _agree(got, want, _width_budget(grid, problem), pixel, f"{kind} {n_pol} {state}") + + +@native_only +@pytest.mark.parametrize("state", STATES) +@pytest.mark.parametrize("n_pol", [1, 2]) +@pytest.mark.parametrize("kind", ["envelope", "field"]) +def test_a_plane_s_widths_are_the_reference_s(kind, n_pol, state): + _check_plane(kind, n_pol, state) + + +def _require_device(): + if device_backend("native") == "none": + pytest.skip("the installed solprop-native build has no accelerator backend") + + +@pytest.mark.gpu +@native_only +@pytest.mark.parametrize("state", STATES) +@pytest.mark.parametrize("n_pol", [1, 2]) +@pytest.mark.parametrize("kind", ["envelope", "field"]) +def test_a_device_s_widths_are_the_reference_s(kind, n_pol, state): + # The reductions run in the device's memory space; only the numbers cross. + _require_device() + _check_plane(kind, n_pol, state) + + +def _route(route, kind="envelope"): + if route == "plane": + return _simulation(kind, FreeGrid(3e-4, 32, 4e-4, 24), 2) + if route == "plane_scalar": + return _simulation(kind, FreeGrid(3e-4, 32, 4e-4, 24), 1) + if route == "line": + return _simulation(kind, LineGrid(4e-4, 48), 2) + assert route == "planar" + grid, _, problem = planar_case("kerr", steps=0) + return grid, dataclasses.replace(problem, stats_spec=StatsSpec(names=WIDTHS)) + + +def _field_for(route, grid, problem): + rng = np.random.default_rng(34) + field = np.asarray(problem.initial.field0) + # The bins and the columns interfere at the nodes, so every node's cross terms and + # every bin's weight enter its fluence. + field = field * np.exp(1j * rng.uniform(-0.3, 0.3, field.shape[-1])) + return field * np.exp(1j * rng.uniform(-0.3, 0.3, (1, field.shape[1], 1, 1))) + + +def _check_fluence(monkeypatch, route, kind): + grid, problem = _route(route, kind) + field = _field_for(route, grid, problem) + held = {} + function = host_spatial.signed_fluence_widths + + def capture(geometry, fluence, budget, axis): + held.update(fluence=fluence.copy(), budget=budget.copy()) + return function(geometry, fluence, budget, axis) + + monkeypatch.setattr(host_spatial, "signed_fluence_widths", capture) + _measure("numpy_ref", grid, problem, field, StatsSpec(names=WIDTHS)) + monkeypatch.undo() + backend = select("native", problem, grid=grid) + try: + backend.set_initial(field, None, z=0.0) + state = backend._native_state # pyright: ignore[reportAttributeAccessIssue] + packed = np.ravel(state.spatial_statistics(16, 0.5 * EPS0 * C, 0.0)) + finally: + backend.close() + n_nodes = problem.transform.n_nodes + fluence, axis, largest = packed[:n_nodes], packed[n_nodes], packed[n_nodes + 1] + # A separable route leaves the axis to a radial width alone. + assert math.isnan(axis) + n_terms = problem.shape[2] * grid.n_t + problem.transform.n_modes + budget = fluence_budget(np.full(n_nodes, largest), n_terms) + # Cauchy-Schwarz bounds the reference's magnitude by the native one, to the rounding + # of the two sums. + assert budget[0] >= held["budget"][0] * (1 - gamma(4 * n_terms)) + # Each fluence errs within its own normwise budget (`fluence_budget`), and the two + # share the state, so they differ by at most the two budgets. + assert_within( + fluence, + held["fluence"], + budget + held["budget"], + what=f"{route} {kind} fluence", + elementwise=True, + ) + return budget[0], held["budget"][0] + + +@native_only +@pytest.mark.parametrize("kind", ["envelope", "field"]) +@pytest.mark.parametrize("route", ["plane", "plane_scalar", "line"]) +def test_a_separable_fluence_is_the_reference_s_at_every_node(monkeypatch, route, kind): + native, reference = _check_fluence(monkeypatch, route, kind) + # A beam whose admittance varies little over its band: the bound is nearly tight, + # so the refusal is the reference's in all but a narrow band of rounding. + assert native <= 1.05 * reference + + +@native_only +def test_a_planar_fluence_is_the_reference_s_at_every_node(monkeypatch): + # The hybrid kind's guide has real dispersion across its slab orders, so its bound + # need not be nearly tight. It is only held to bound the reference's from above. + _check_fluence(monkeypatch, "planar", "envelope") + + +class _NoState: + """A native state that refuses to bring its field across.""" + + def __init__(self, state): + self._state = state + self.largest = 0 + + def field_to_host(self): + raise AssertionError("a statistic brought the state to the host") + + def __getattr__(self, name): + attribute = getattr(self._state, name) + + def counted(*arguments, **keywords): + value = attribute(*arguments, **keywords) + self.largest = max(self.largest, int(np.size(value))) + return value + + return counted if callable(attribute) else attribute + + +@native_only +@pytest.mark.parametrize("n_pol", [1, 2]) +def test_a_plane_s_widths_bring_only_numbers_across(n_pol): + grid, problem = _simulation("envelope", FreeGrid(3e-4, 32, 4e-4, 24), n_pol) + backend = select("native", problem, grid=grid) + try: + backend.set_initial(problem.initial.field0, None, z=0.0) + spy = _NoState(backend._native_state) # pyright: ignore[reportAttributeAccessIssue] + backend._native_state = spy # pyright: ignore[reportAttributeAccessIssue] + backend.stats(StatsSpec(names=WIDTHS)) + backend._native_state = spy._state # pyright: ignore[reportAttributeAccessIssue] + finally: + backend.close() + # The moments, then the trace: one value per ring, against one per node. + assert 0 < spy.largest < problem.transform.n_nodes / 4 + + +@native_only +@pytest.mark.parametrize("kind", ["envelope", "field"]) +def test_widths_beside_the_other_statistics_read_the_same_volumes(kind): + """One call forms the fluence and the peak from one pair of transverse volumes.""" + grid, problem = _route("plane", kind) + field = _field_for("plane", grid, problem) + together = ( + "energy", + "on_axis_spectrum", + "on_axis_intensity", + "peak_intensity", + *WIDTHS, + ) + joint = _measure("native", grid, problem, field, StatsSpec(names=together)) + reference = _measure("numpy_ref", grid, problem, field, StatsSpec(names=together)) + bound = _width_budget(grid, problem) + for name in together: + one = _measure("native", grid, problem, field, StatsSpec(names=(name,))) + # The same statistic of the same state, in two orders of the same kernels. + assert_within(joint[name], one[name], bound, what=f"joint {name}") + assert_within(joint[name], reference[name], bound, what=f"reference {name}") + + +@native_only +@pytest.mark.parametrize("names", [WIDTHS, ("peak_intensity", *WIDTHS)]) +def test_an_empty_plane_has_no_widths(names): + grid, problem = _route("plane") + field = np.zeros_like(problem.initial.field0) + for backend in ("numpy_ref", "native"): + values = _measure(backend, grid, problem, field, StatsSpec(names=WIDTHS)) + for name in WIDTHS: + assert np.all(np.isnan(values[name])) + + +@native_only +def test_a_negative_lobe_is_refused_on_both(): + """A fluence negative beyond its rounding has no width, on either backend. + + Two plane waves in one bin, ``E = A + B exp(i k x)`` and + ``G = Y0 A + Yk B exp(i k x)``, have ``Re(E G*) = (Y0 A - Yk B)(A - B)`` where they + oppose. On half-micrometre pixels the highest column's admittance ``Yk`` is 17 % + below the axis's ``Y0``, which puts ``A = 0.9 B`` between ``B`` and ``Yk B / Y0``: + a negative lobe of about 0.2 % of the peak, against a budget of about 1e-12 of it. + """ + space = FreeGrid(6e-6, 24, 6e-6, 24) + grid, problem = _simulation("envelope", space, 1, SpatialMedium(1.45), waist=2e-6) + columns = np.zeros(problem.shape[1], complex) + columns[0] = 0.9 + columns[12] = 1.0 + spectrum = np.zeros((1, problem.shape[3]), complex) + spectrum[0, 0] = 1.0 + field = np.einsum("r,pw->rpw", columns, spectrum)[None] * 1e6 + spec = StatsSpec(names=WIDTHS) + for backend in ("numpy_ref", "native"): + with pytest.raises(BackendError, match="nonnegative fluence profile"): + _measure(backend, grid, problem, field, spec) + + +def _ring_trace(samples, x, y): + """The trace a backend forms: the reference's arithmetic, on the scaled samples.""" + + def trace(*arguments): + center_x, center_y, dx, dy, extent, spacing, n_radii, cosines, sines = arguments + offset_x = np.arange(x.size) * dx - center_x + offset_y = np.arange(y.size) * dy - center_y + second = float( + np.sum( + samples + * ( + (offset_x[None, :] / extent) ** 2 + + (offset_y[:, None] / extent) ** 2 + ) + ) + ) + averaged = np.empty(n_radii) + for ring in range(n_radii): + horizontal = np.clip( + (center_x + spacing * ring * cosines) / dx, 0, x.size - 1 + ) + vertical = np.clip((center_y + spacing * ring * sines) / dy, 0, y.size - 1) + ix = np.minimum(horizontal.astype(np.intp), x.size - 2) + iy = np.minimum(vertical.astype(np.intp), y.size - 2) + fx, fy = horizontal - ix, vertical - iy + averaged[ring] = np.mean( + (1 - fy) * ((1 - fx) * samples[iy, ix] + fx * samples[iy, ix + 1]) + + fy * ((1 - fx) * samples[iy + 1, ix] + fx * samples[iy + 1, ix + 1]) + ) + return np.array([[second, *averaged]]) + + return trace + + +def _moments(profile, size=None, worst=None): + maximum = float(np.max(profile)) + samples = profile / maximum + n_y, n_x = profile.shape + return np.array( + [ + float(np.min(profile)) if worst is None else worst, + 0.0, + maximum if size is None else size, + maximum, + 0.0, + float(np.sum(samples)), + float(np.sum(samples * np.arange(n_x)[None, :])), + float(np.sum(samples * np.arange(n_y)[:, None])), + ] + ), samples + + +def _plane_geometry(): + _, problem = _route("plane") + geometry = problem.transform.geometry + assert geometry is not None + return geometry + + +def test_plane_widths_read_the_reductions_as_the_host_widths_read_the_profile(): + geometry = _plane_geometry() + x, y = geometry.axes + grid_x, grid_y = np.meshgrid(x, y) + profile = _gaussian(grid_x, grid_y, 3e-5, -2e-5) + moments, samples = _moments(profile) + got = plane_fluence_widths(geometry, moments, 100, _ring_trace(samples, x, y)) + want = cartesian_fluence_widths(x, y, profile.ravel()) + # One rule on one profile: only the summation order and the axes' accumulated + # rounding differ, a few hundred units over the plane's nodes. + assert_within(got, want, gamma(16 * profile.size)) + + +def test_plane_widths_refuse_a_negative_lobe_beyond_its_budget(): + geometry = _plane_geometry() + x, y = geometry.axes + grid_x, grid_y = np.meshgrid(x, y) + moments, samples = _moments(_gaussian(grid_x, grid_y), size=1.0, worst=-1e-6) + with pytest.raises(BackendError, match="nonnegative fluence profile"): + plane_fluence_widths(geometry, moments, 100, _ring_trace(samples, x, y)) + # Far below the peak, and within its budget: rounding, read as zero. + within = moments.copy() + within[0] = -0.5 * fluence_budget(np.array([1.0]), 100)[0] + widths = plane_fluence_widths(geometry, within, 100, _ring_trace(samples, x, y)) + assert np.isfinite(widths[0]) + + +def test_plane_widths_refuse_a_value_that_is_not_finite(): + geometry = _plane_geometry() + moments = np.zeros(8) + moments[4] = 1.0 + with pytest.raises(GridError, match="finite"): + plane_fluence_widths(geometry, moments, 100, _unreachable) + + +def test_plane_widths_of_an_empty_plane_are_undefined(): + assert all( + math.isnan(width) + for width in plane_fluence_widths( + _plane_geometry(), np.zeros(8), 100, _unreachable + ) + ) + + +def _unreachable(*arguments): + raise AssertionError("a plane with nothing to trace was traced") + + +def test_plane_widths_refuse_a_second_moment_past_the_floats(): + geometry = _plane_geometry() + x, y = geometry.axes + grid_x, grid_y = np.meshgrid(x, y) + moments, _ = _moments(_gaussian(grid_x, grid_y)) + + def trace(*arguments): + return np.array([math.inf, *np.ones(arguments[6])]) + + with pytest.raises(GridError, match="not representable"): + plane_fluence_widths(geometry, moments, 100, trace) + + +def test_plane_widths_at_an_edge_have_only_a_moment(): + geometry = _plane_geometry() + x, y = geometry.axes + grid_x, grid_y = np.meshgrid(x, y) + profile = _gaussian(grid_x, grid_y, x[0], y[0], 1e-5) + moments, samples = _moments(profile) + got = plane_fluence_widths(geometry, moments, 100, _ring_trace(samples, x, y)) + want = cartesian_fluence_widths(x, y, profile.ravel()) + assert math.isnan(got[1]) and math.isnan(got[2]) + assert math.isnan(want[1]) and math.isnan(want[2]) + assert_within(got[0], want[0], gamma(16 * profile.size)) + + +def test_the_rings_are_the_host_widths_rule(): + x = np.linspace(-1.0, 1.0, 9) + y = np.linspace(-2.0, 2.0, 17) + rings = fluence_rings(x, y, 0.0, 0.0) + assert rings is not None + # Half the finest spacing, to the largest circle inside the axes about the centroid. + assert rings.radii[1] == 0.125 + assert rings.radii[-1] == 1.0 + assert rings.cosines.size == rings.sines.size >= 8 + # A centroid within two half-spacings of an edge has none. + assert fluence_rings(x, y, 0.9, 0.0) is None + # A Gaussian trace: its 1/e^2 radius and its FWHM, to the interpolant's accuracy. + radii = np.linspace(0.0, 4.0, 4001) + radius, width = ring_fluence_widths(radii, np.exp(-2 * radii**2)) + assert radius == pytest.approx(1.0, abs=1e-6) + assert width == pytest.approx(np.sqrt(2 * np.log(2)), abs=1e-6) From 8d3a2196124f362bf54b25102e01ab4bec1fd869 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:26:12 +0000 Subject: [PATCH 063/122] Read the on-axis row natively, not the whole state The `on_axis_*` statistics copied the state to the host at each save plane to scan one row, on every route. The synthesis is linear, so the row can be formed before the time transform. The native state now forms the axis row's spectrum, and only that crosses: at most 2 x n_omega samples. The host scan is unchanged, and the reference keeps its own order, so the two agree to the row's rounding. A Radau rule's axis node needs no row at all, and now reads nothing. The frozen driver surface gains `_axis_row_populations`, which a device overrides. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/359-on-axis-row.changed.md | 1 + docs/guide/performance/native.md | 8 +- native/include/solprop_native/core.hpp | 6 ++ native/src/bindings.cpp | 18 ++++ native/src/general.cpp | 61 +++++++++++ native/src/solprop_native/__init__.py | 1 + native/src/solprop_native/_core.pyi | 3 + native/src/solprop_native/backend.py | 41 +++++++- src/solprop/backends/driver.py | 47 +++++++-- src/solprop/backends/populations.py | 101 ++++++++++++++++-- tests/backends/test_driver.py | 8 +- tests/backends/test_on_axis_statistics.py | 119 ++++++++++++++++++++++ 12 files changed, 391 insertions(+), 23 deletions(-) create mode 100644 changelog.d/359-on-axis-row.changed.md diff --git a/changelog.d/359-on-axis-row.changed.md b/changelog.d/359-on-axis-row.changed.md new file mode 100644 index 00000000..8d0a674f --- /dev/null +++ b/changelog.d/359-on-axis-row.changed.md @@ -0,0 +1 @@ +A device run's `on_axis_*` plasma statistics no longer copy the state to the host at each save plane: only the axis row's spectrum crosses, and the host scans it as before (#359). `StepDriver` gains the `_axis_row_populations` hook, and `_axis_statistics` takes `None` for a backend that reads the row itself. diff --git a/docs/guide/performance/native.md b/docs/guide/performance/native.md index c3ee28c3..4ca78377 100644 --- a/docs/guide/performance/native.md +++ b/docs/guide/performance/native.md @@ -158,10 +158,10 @@ planes. Things to know: problem is small enough for it. The GPU wins on large grids: multimode and general modal guides with many nodes, coherent walls with many channels, broadband supercontinuum grids, and free-space runs. -- **On-axis plasma statistics.** The four modal `on_axis_*` plasma statistics - require a host copy of the field at each requested save plane. Their CPU parity - is qualified; they are not yet qualified on a GPU, and there is no device-only - axis scan. See [the statistics catalogue](../results/statistics-catalogue.md). +- **On-axis plasma statistics.** The four modal `on_axis_*` plasma statistics bring only + the axis row's spectrum to the host at each requested save plane, not the field, and + scan it there. Their CPU parity is qualified; they are not yet qualified on a GPU. See + [the statistics catalogue](../results/statistics-catalogue.md). - **Reproducibility.** A device answer is not bit for bit the CPU build's, because the device sums in its own order; the difference is at the level of rounding and is covered by the same derived error bounds that every backend is tested against. A diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index d3cb9cfb..14f3ef64 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -858,6 +858,12 @@ class GeneralState { double extent, double spacing, std::size_t n_radii, const std::vector& cosines, const std::vector& sines); + /** + * The modal state's spectrum at one physical point: `matrix`, one or two rows of + * `n_modes` columns, contracted with the modes, as `(n_rows * n_pol, n_omega)` + * samples. What an on-axis scan needs of the state. + */ + [[nodiscard]] std::vector axis_spectrum(const std::vector& matrix); /** * solprop SEP 23's far-field statistics of the accepted state on a Cartesian or line * grid, one banded pass; see `FarFieldPass::run` (`far_field.hpp`) for the tables, diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index 4d9b7b45..447f8ae9 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -1353,6 +1353,10 @@ class BoundState { return general_state_->plane_fluence_trace(center_x, center_y, dx, dy, extent, spacing, n_radii, cosines, sines); } + std::vector axis_spectrum(const std::vector& matrix) { + require_open(); + return general_state_->axis_spectrum(matrix); + } std::vector aperture_statistics(const std::uint64_t flags, const std::vector& apertures, const int exponent, @@ -2768,6 +2772,20 @@ NB_MODULE(_core, module_) { }, "center_x"_a, "center_y"_a, "dx"_a, "dy"_a, "extent"_a, "spacing"_a, "n_radii"_a, "cosines"_a.noconvert(), "sines"_a.noconvert()) + .def( + "axis_spectrum", + [](BoundState& self, const RealInput matrix) { + if (matrix.ndim() != 2) + throw std::invalid_argument("an on-axis matrix must be two-dimensional"); + const auto held = real_vector(matrix); + std::vector values; + { + const nb::gil_scoped_release release; + values = self.axis_spectrum(held); + } + return complex_output(values, {values.size()}); + }, + "matrix"_a.noconvert()) .def( "aperture_statistics", [](BoundState& self, const std::uint64_t flags, const RealInput apertures, diff --git a/native/src/general.cpp b/native/src/general.cpp index 250a9f77..85a6bafd 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -2210,6 +2210,7 @@ class GeneralWorkspace { std::size_t n_radii, const std::vector& cosines, const std::vector& sines); + std::vector axis_spectrum(const std::vector& matrix); std::vector aperture_statistics(std::uint64_t flags, const std::vector& apertures, int exponent, @@ -14180,6 +14181,62 @@ std::vector GeneralWorkspace::plane_fluence_trace( return result; } +// The field's spectrum at one physical point, the matrix `(n_rows, n_modes)` contracted +// with the modal state: `(n_rows * n_pol, n_omega)` samples, each a real and an +// imaginary part in turn. The on-axis statistics scan this row on the host (solprop SEP +// 19), so that it is all a plane's scan brings across. +std::vector +GeneralWorkspace::axis_spectrum(const std::vector& matrix) { + require_open(); + if (!initialized_) + throw std::logic_error("no initial field has been loaded"); + if (problem_.separable_geometry() || matrix.empty() || + matrix.size() % problem_.n_modes != 0 || + matrix.size() / problem_.n_modes > statistics_on_axis_.size() / problem_.n_modes) + throw std::invalid_argument( + "an on-axis row needs a modal state and at most two rows of n_modes columns"); + const auto n_rows = matrix.size() / problem_.n_modes; + const auto n_pol = problem_.n_pol_state; + const auto n_modes = problem_.n_modes; + const auto n = problem_.n_omega; + const auto columns = checked_product(n_rows, n_pol, "on-axis columns"); + const auto samples = checked_product(columns, n, "on-axis samples"); + const auto packed_size = checked_product(2, samples, "on-axis samples"); + if (statistics_packed_.size() < packed_size) { + execution_space_.fence("solprop_native statistics output resize"); + statistics_packed_ = RealView("solprop_native.statistics.packed", packed_size); + } + copy_vector(execution_space_, + Kokkos::subview(statistics_on_axis_, + std::make_pair(0, matrix.size())), + matrix); + const auto weights = statistics_on_axis_; + const auto packed = statistics_packed_; + const auto current = current_; + launch::parallel_for( + "solprop_native.statistics.axis_spectrum", + Range2(execution_space_, {0, 0}, {columns, n}), + KOKKOS_LAMBDA(const std::size_t column, const std::size_t index) { + const auto row = column / n_pol; + const auto pol = column % n_pol; + Complex sum{}; + for (std::size_t mode = 0; mode < n_modes; ++mode) + sum += weights(row * n_modes + mode) * current(mode * n_pol + pol, index); + const auto at = 2 * (column * n + index); + packed(at) = sum.real(); + packed(at + 1) = sum.imag(); + }); + const auto filled = + Kokkos::subview(packed, std::make_pair(0, packed_size)); + auto host = Kokkos::create_mirror_view(filled); + graph::deep_copy(execution_space_, host, filled); + execution_space_.fence("solprop_native on-axis row boundary"); + std::vector result(samples); + for (std::size_t index = 0; index < samples; ++index) + result[index] = Complex(host(2 * index), host(2 * index + 1)); + return result; +} + std::vector GeneralWorkspace::aperture_statistics( const std::uint64_t flags, const std::vector& apertures, const int exponent, const std::vector& regions, const double intensity_factor) { @@ -16116,6 +16173,10 @@ std::vector GeneralState::plane_fluence_trace( return workspace_->plane_fluence_trace(center_x, center_y, dx, dy, extent, spacing, n_radii, cosines, sines); } +std::vector GeneralState::axis_spectrum(const std::vector& matrix) { + const CpuTeamScope team(workspace_->cpu_team()); + return workspace_->axis_spectrum(matrix); +} std::vector GeneralState::aperture_statistics( const std::uint64_t flags, const std::vector& apertures, const int exponent, const std::vector& regions, const double intensity_factor) { diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py index e7ff759b..8d689db5 100644 --- a/native/src/solprop_native/__init__.py +++ b/native/src/solprop_native/__init__.py @@ -180,6 +180,7 @@ def _band_request() -> int: "_adams_source", "_adams_state", "_apply_mask", + "_axis_row_populations", "_axis_statistics", "_begin_plane", "_bind_monitor", diff --git a/native/src/solprop_native/_core.pyi b/native/src/solprop_native/_core.pyi index 066406b8..0af15f8b 100644 --- a/native/src/solprop_native/_core.pyi +++ b/native/src/solprop_native/_core.pyi @@ -62,6 +62,9 @@ class State: cosines: npt.NDArray[np.float64], sines: npt.NDArray[np.float64], ) -> npt.NDArray[np.float64]: ... + def axis_spectrum( + self, matrix: npt.NDArray[np.float64] + ) -> npt.NDArray[np.complex128]: ... def aperture_statistics( self, flags: int, diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index dcf2e9b3..1257deea 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -59,7 +59,12 @@ node_kerr_tensor, ) from solprop.backends.numpy_ref.passes import plasma_integration_statistics -from solprop.backends.populations import axis_refusal, population_statistics +from solprop.backends.populations import ( + axis_refusal, + axis_row_matrix, + axis_row_populations, + population_statistics, +) from solprop.backends.spatial import ( AXIS_SPECTRUM_ONLY, FarFieldTables, @@ -1361,9 +1366,9 @@ def stats(self, spec: StatsSpec) -> dict[str, np.ndarray]: if axis_names: # SEP 19: the axis row is one host scan per saved plane, shared with the # other backends; a Radau rule's axis node comes from the device's own - # node scan of this call when the species statistics asked for it. - field = self._native_state.field_to_host().reshape(self._problem.shape) - axis = self._axis_statistics(field[0], scan) + # node scan of this call when the species statistics asked for it. Only + # the row's spectrum crosses, never the state (`_axis_row_populations`). + axis = self._axis_statistics(None, scan) values.update({name: axis[name][None] for name in axis_names}) if integration_names: ratios = dict( @@ -1381,6 +1386,34 @@ def stats(self, spec: StatsSpec) -> dict[str, np.ndarray]: values.update(self._profile_statistics(spec)) return values + def _axis_row_populations( + self, + field: ComplexArray | None, + axis: RealArray, + ) -> tuple[RealArray, RealArray]: + """Scan the axis row from its spectrum, read natively (solprop #359). + + Parameters + ---------- + field : None + Unused: the row is read from the native state. + axis : numpy.ndarray + The mode set's synthesis on the axis at this position. + + Returns + ------- + tuple of numpy.ndarray + The final ionised fraction and the largest rate on the axis, per channel. + """ + del field + matrix = np.ascontiguousarray(axis_row_matrix(self._problem, axis)) + spectrum = self._native_state.axis_spectrum(matrix) + return axis_row_populations( + self._problem, + np.asarray(spectrum).reshape(1, -1, self._problem.shape[-1]), + self._z, + ) + def _general_statistics( self, spec: StatsSpec, grid: TimeGrid ) -> dict[str, np.ndarray]: diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index 18bcf7a6..8d469f24 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -2399,7 +2399,7 @@ def _noise_phase( def _axis_statistics( self, - field: Complex[np.ndarray, "n_modes n_pol n_omega"], + field: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, scan: tuple[np.ndarray, np.ndarray] | None, ) -> dict[str, np.ndarray]: """Return the four on-axis plasma statistics of one batch member (SEP 19). @@ -2410,8 +2410,9 @@ def _axis_statistics( Parameters ---------- - field : numpy.ndarray - One batch member at this plane, shape (n_modes, n_pol, n_omega). + field : numpy.ndarray or None + One batch member at this plane, shape (n_modes, n_pol, n_omega), or ``None`` + from a backend whose :meth:`_axis_row_populations` reads its own state. scan : tuple or None This observation's channel fractions and peak rates at every node, when the node statistics already scanned them. @@ -2435,7 +2436,6 @@ def _axis_statistics( raise BackendError(msg) from solprop.backends.populations import ( # noqa: PLC0415 - a cycle axis_node, - axis_populations, axis_statistics, ) @@ -2457,11 +2457,46 @@ def _axis_statistics( self._problem, self._z, fractions[:, node], peaks[:, node] ) self.axis_scans += 1 - fractions, peaks = axis_populations( - self._problem, field, self._z, self._on_axis_synthesis(self._modes) + fractions, peaks = self._axis_row_populations( + field, self._on_axis_synthesis(self._modes) ) return axis_statistics(self._problem, self._z, fractions, peaks) + def _axis_row_populations( + self, + field: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, + axis: Float[np.ndarray, "n_rows n_modes"], + ) -> tuple[Float[np.ndarray, " n_channels"], Float[np.ndarray, " n_channels"]]: + """Scan this plane's axis row: one row, every channel. + + A device backend overrides this hook to read only the row from its state. + + Parameters + ---------- + field : numpy.ndarray or None + One batch member at this plane, shape (n_modes, n_pol, n_omega). + axis : numpy.ndarray + The mode set's synthesis on the axis at this position + (:meth:`_on_axis_synthesis`). + + Returns + ------- + tuple of numpy.ndarray + The final ionised fraction and the largest rate on the axis, per channel. + + Raises + ------ + BackendError + If this backend needs the field and was not given it. + """ + if field is None: + raise BackendError("the on-axis statistics scan needs the field") + from solprop.backends.populations import ( # noqa: PLC0415 - a cycle + axis_populations, + ) + + return axis_populations(self._problem, field, self._z, axis) + def _on_axis_synthesis( self, modes: Sequence[Mode] ) -> Float[np.ndarray, "n_rows n_modes"]: diff --git a/src/solprop/backends/populations.py b/src/solprop/backends/populations.py index dfb4200c..ec6708ad 100644 --- a/src/solprop/backends/populations.py +++ b/src/solprop/backends/populations.py @@ -7,7 +7,7 @@ from collections.abc import Iterable import numpy as np -from jaxtyping import Complex, Float +from jaxtyping import Complex, Float, Inexact from solprop.backends.numpy_ref import passes from solprop.backends.numpy_ref.envelope_plasma import envelope_populations @@ -19,6 +19,8 @@ "axis_node", "axis_populations", "axis_refusal", + "axis_row_matrix", + "axis_row_populations", "axis_statistics", "population_statistics", ] @@ -210,7 +212,8 @@ def axis_populations( The one implementation every backend uses, on the host: the axis is one row per saved plane, against every node of every stage, so its scan costs nothing a - backend would compile. The field is the same as the node fields' -- the same pad + backend would compile. A device reads only the row + (:func:`axis_row_populations`). The field is the same as the node fields' -- the same pad scale, taper and fine grid -- with both Cartesian components of the mode profiles, as for ``peak_intensity``; when the state carries the components itself, the profile's magnitude on the axis is the scalar synthesis. The tables, @@ -234,16 +237,98 @@ def axis_populations( The final ionised fraction and the largest rate (cycle-averaged on an envelope grid) on the axis, one per channel. """ - responses, grids = problem.responses, problem.grids - scaled = axis * grids.pad_scale + grids = problem.grids modal_t = passes.to_time( field, grids.crop_index, grids.n_to, kind=problem.grid_kind ) - row = ( - passes.synthesise(modal_t, scaled, 1.0, 2) - if field.shape[1] == 1 - else passes.synthesise(modal_t, np.hypot(scaled[0], scaled[1])[None], 1.0, 1) + row = passes.synthesise( + modal_t, axis_row_matrix(problem, axis), 1.0, 2 if field.shape[1] == 1 else 1 ) + return _scan_axis_row(problem, row, z) + + +def axis_row_matrix( + problem: KernelProblem, axis: Float[np.ndarray, "2 n_modes"] +) -> Float[np.ndarray, "n_rows n_modes"]: + """Return the matrix that synthesises the axis row from the modal state. + + With both Cartesian components of the mode profiles, as for ``peak_intensity``; + when the state carries the components itself, the profile's magnitude on the axis. + + Parameters + ---------- + problem : KernelProblem + The translated problem. + axis : numpy.ndarray + The mode set's synthesis on the axis at this position, taper included, rows x + and y (``StepDriver._on_axis_synthesis``). + + Returns + ------- + numpy.ndarray + The rows, carrying the pad scale: x and y on a scalar state, or the one + magnitude row on a state with two polarisations. + """ + scaled = axis * problem.grids.pad_scale + if problem.shape[2] == 1: + return scaled + return np.hypot(scaled[0], scaled[1])[None] + + +def axis_row_populations( + problem: KernelProblem, + spectrum: Complex[np.ndarray, "1 n_components n_omega"], + z: float, +) -> tuple[Float[np.ndarray, " n_channels"], Float[np.ndarray, " n_channels"]]: + """Scan the axis row from its spectrum, for a backend that reads only that row. + + The synthesis is linear, so a row synthesised before the time transform is the row + :func:`axis_populations` synthesises after it, to rounding, and the state need not + leave the device for it. + + Parameters + ---------- + problem : KernelProblem + The translated problem. + spectrum : numpy.ndarray + The :func:`axis_row_matrix` rows contracted with the modal state, one column for + each row and polarisation, in V/m on the coarse frequency axis. + z : float + Plane position in m. + + Returns + ------- + tuple of numpy.ndarray + The final ionised fraction and the largest rate (cycle-averaged on an envelope + grid) on the axis, one per channel. + """ + grids = problem.grids + row = passes.to_time(spectrum, grids.crop_index, grids.n_to, kind=problem.grid_kind) + return _scan_axis_row(problem, row, z) + + +def _scan_axis_row( + problem: KernelProblem, + row: Inexact[np.ndarray, "1 n_components n_to"], + z: float, +) -> tuple[Float[np.ndarray, " n_channels"], Float[np.ndarray, " n_channels"]]: + """Scan one row of the field in time: every channel, with the node scan's tables. + + Parameters + ---------- + problem : KernelProblem + The translated problem. + row : numpy.ndarray + The field on the axis on the fine time grid in V/m. + z : float + Plane position in m. + + Returns + ------- + tuple of numpy.ndarray + The final ionised fraction and the largest rate on the axis, one per channel. + """ + responses, grids = problem.responses, problem.grids preionisation = tuple( float(channel.preionisation(z)) for channel in responses.population_channels ) diff --git a/tests/backends/test_driver.py b/tests/backends/test_driver.py index 3ac3e9da..c0c3dccc 100644 --- a/tests/backends/test_driver.py +++ b/tests/backends/test_driver.py @@ -944,8 +944,14 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): "propagate_to": "(self, z: float) -> solprop.backends.protocol.StepSummary", "available_device_memory": "(self) -> int | None", # A subclass's and the shared marches' bookkeeping. + "_axis_row_populations": ( + "(self, field: jaxtyping.Complex[ndarray, 'n_modes n_pol n_omega'] | None, " + "axis: jaxtyping.Float[ndarray, 'n_rows n_modes']) -> " + "tuple[jaxtyping.Float[ndarray, 'n_channels'], " + "jaxtyping.Float[ndarray, 'n_channels']]" + ), "_axis_statistics": ( - "(self, field: jaxtyping.Complex[ndarray, 'n_modes n_pol n_omega'], " + "(self, field: jaxtyping.Complex[ndarray, 'n_modes n_pol n_omega'] | None, " "scan: tuple[numpy.ndarray, numpy.ndarray] | None) -> dict[str, numpy.ndarray]" ), "_check_open": "(self) -> None", diff --git a/tests/backends/test_on_axis_statistics.py b/tests/backends/test_on_axis_statistics.py index 077e0e5e..0c6c1b63 100644 --- a/tests/backends/test_on_axis_statistics.py +++ b/tests/backends/test_on_axis_statistics.py @@ -21,6 +21,7 @@ import numpy as np import pytest +from bounds import assert_within, device_backend, fft_bound, gamma, rate_log_sensitivity from rate_oracles import adk_oracle from solprop.backends import select from solprop.backends.driver import StepDriver @@ -316,6 +317,124 @@ def test_the_compiled_backends_match_the_reference( np.testing.assert_allclose(got[statistic], want[statistic], rtol=1e-10) +def _axis_row(modes): + rule = on_axis_rule(modes[0].dimlimits(0.0)[0]) + return mode_matrix(modes, rule, 0.0, components=("x", "y")).synthesis[0] + + +def _axis_state(grid, name): + """Return one adversarial state's modes and spectrum, and how far the axis cancels. + + The native row sums the modes on the axis before the time transform and the + reference after it, so that cancellation between modes amplifies both one's + rounding: ``kappa`` is the largest term over the largest sum, on the row. + """ + built = GaussianPulse(wavelength=800e-9, fwhm=10e-15, energy=ENERGY).build(grid) + if name == "one_mode": + return [argon()], built, 1.0 + if name == "two_polarisations": + return [argon()], np.concatenate([built, 0.3j * built], axis=1), 1.0 + if name == "one_bin": + # All the energy in the peak bin: a tone of the pulse's peak amplitude. + tone = np.zeros_like(built) + peak = int(np.argmax(np.abs(built))) + tone[..., peak] = np.sum(np.abs(built)) + return [argon()], tone, 1.0 + modes = [argon(1), argon(2)] + profile = _axis_row(modes) + if name == "two_modes": + return modes, np.concatenate([built, 0.4j * built], axis=0), 1.0 + # The second mode cancels 90 % of the first on the axis. + assert name == "cancelling" + second = -0.9 * profile[0] / profile[1] * built + return modes, np.concatenate([10 * built, 10 * second], axis=0), 19.0 + + +AXIS_STATES = ("one_mode", "two_polarisations", "one_bin", "two_modes", "cancelling") + + +def _check_axis_row(grid, table, name): + modes, start, kappa = _axis_state(grid, name) + rule = transverse_quadrature(modes, n_r=32, radial_rule="gauss_kronrod") + problem = problem_on(grid, table, modes, rule, start=start) + want, _ = entrance_stats(grid, problem, modes, AXIS) + got, counted = backend_stats("native", grid, problem, modes, AXIS) + assert counted == 1 + rate = float(np.max(want["on_axis_peak_ionisation_rate"])) + assert rate > 0 + # The row's relative error, on both routes: the time transform and the modes' sum, + # amplified by the axis's cancellation. A rate moves by its log sensitivity times + # that, and a final fraction by no more: `1 - exp(-X)` has a log derivative of at + # most one in X, and X's is a weighted mean of the rate's over the pulse. The rate + # is steepest at its weakest, so the sensitivity is read an order of magnitude + # below the peak's and doubled. + sensitivity = rate_log_sensitivity(adk_oracle(Gas.Ar), 1e-1 * rate, (1e9, 2e11)) + row = kappa * (fft_bound(grid.n_to) + gamma(8 * len(modes))) + bound = 2 * sensitivity * row + for statistic in ON_AXIS_PLASMA_STATISTICS: + assert_within( + got[statistic], want[statistic], bound, what=f"{name} {statistic}" + ) + + +@pytest.mark.parametrize("name", AXIS_STATES) +def test_the_native_row_reads_the_reference_s_axis(grid, table, name): + pytest.importorskip("solprop_native") + _check_axis_row(grid, table, name) + + +@pytest.mark.gpu +@pytest.mark.parametrize("name", AXIS_STATES) +def test_a_device_s_row_reads_the_reference_s_axis(grid, table, name): + # The row is formed in the device's memory space; only the row crosses. + pytest.importorskip("solprop_native") + if device_backend("native") == "none": + pytest.skip("the installed solprop-native build has no accelerator backend") + _check_axis_row(grid, table, name) + + +class _StateRefused: + """A native state that refuses to bring its field across (solprop #359).""" + + def __init__(self, state): + self._state = state + + def field_to_host(self): + raise AssertionError("an on-axis statistic brought the state to the host") + + def __getattr__(self, name): + return getattr(self._state, name) + + +@pytest.mark.parametrize("radial_rule", ["field_scale", "field_scale_radau"]) +def test_the_native_axis_row_leaves_the_state_on_the_device(grid, table, radial_rule): + pytest.importorskip("solprop_native") + mode = argon() + rule = transverse_quadrature([mode], n_r=5, radial_rule=radial_rule) + problem = problem_on(grid, table, [mode], rule) + with closing(select("native", problem, grid=grid, modes=[mode])) as backend: + backend.set_initial(problem.initial.field0, None) + held = backend._native_state # pyright: ignore[reportAttributeAccessIssue] + backend._native_state = _StateRefused(held) # pyright: ignore[reportAttributeAccessIssue] + try: + values = backend.stats(StatsSpec(names=AXIS.names)) + finally: + backend._native_state = held # pyright: ignore[reportAttributeAccessIssue] + assert np.all(values["on_axis_peak_ionisation_rate"] > 0) + + +def test_a_host_backend_s_axis_scan_needs_the_field(grid, table): + mode = argon() + rule = transverse_quadrature([mode], n_r=5, radial_rule="field_scale") + problem = problem_on(grid, table, [mode], rule) + with closing(NumpyRefBackend(problem, grid=grid, modes=[mode])) as backend: + backend.set_initial(problem.initial.field0, None) + with pytest.raises(BackendError, match="needs the field"): + backend._axis_row_populations( # pyright: ignore[reportPrivateUsage] + None, np.zeros((2, 1)) + ) + + def test_they_are_saved_plane_statistics_and_tdse_never_defines_them(): with pytest.raises(OutputError, match="between save planes"): StatsSpec(names=ON_AXIS_PLASMA_STATISTICS, periodic=ON_AXIS_PLASMA_STATISTICS) From 05acfa65875cef8eb8f5d096e17bb85c10e2d396 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:31:54 +0000 Subject: [PATCH 064/122] Sum a species' ionisation inventory without a Python loop `species_statistics` walked every node in Python for its inventory: 0.2 s per species at 768 x 768 nodes, on the host, at each plane that asks for it. A running sum over the nodes makes the same additions in the same order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/stats.py | 5 +++-- tests/backends/test_mixture_statistics.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/solprop/stats.py b/src/solprop/stats.py index 868878b9..f2aea7dc 100644 --- a/src/solprop/stats.py +++ b/src/solprop/stats.py @@ -193,10 +193,11 @@ def species_statistics( for row in electrons: total += row densest = int(np.argmax(total)) + # A running sum over the nodes: the same additions, in the same order, as a Python + # loop over them, which took 0.2 s per species at 768 x 768 nodes. energy = np.zeros(len(densities)) for index, (row, potential) in enumerate(zip(electrons, potentials, strict=True)): - for population, weight in zip(row, weights, strict=True): - energy[index] += weight * population * potential + energy[index] = np.add.accumulate(weights * row * potential)[-1] return { "species_ionisation": fractions.copy(), "species_electron_density": electrons[:, densest].copy(), diff --git a/tests/backends/test_mixture_statistics.py b/tests/backends/test_mixture_statistics.py index 8838fc4d..68f85a17 100644 --- a/tests/backends/test_mixture_statistics.py +++ b/tests/backends/test_mixture_statistics.py @@ -5,11 +5,13 @@ """Species axes, transverse populations and final inventory accounting.""" import dataclasses +import math import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal +from bounds import assert_within, gamma from mixture_cases import mixture_case from solprop.backends.numpy_ref import NumpyRefBackend, passes from solprop.backends.populations import population_statistics @@ -24,6 +26,22 @@ from solprop.translate.responses import compile_responses +def test_the_ionisation_inventory_is_the_sum_over_the_nodes(): + """Every node's density, fraction, potential and weight enter the running sum.""" + rng = np.random.default_rng(107) + fractions = rng.uniform(0.0, 1.0, (2, 5000)) + weights = rng.uniform(1e-12, 1e-10, 5000) + density = np.array([2e24, 1e25]) + potential = np.array([2.5e-18, 3.4e-18]) + result = species_statistics(density, fractions, np.ones(2), potential, weights) + exact = [ + math.fsum(weights * density[index] * fractions[index] * potential[index]) + for index in range(2) + ] + # Three products and a sequential sum of 5000 terms: Higham's gamma, in EPS's terms. + assert_within(result["species_ionisation_energy"], exact, gamma(5003)) + + def test_densest_total_node_differs_from_each_individual_species_peak(): density = np.array([2.0, 3.0, 4.0]) fraction = np.array([[0.8, 0.1, 0.4], [0.1, 0.7, 0.3], [0.0, 0.0, 0.0]]) From 8d7f8564d7dac2a5dc671474a0c6ed3b0592f4ba Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:34:28 +0000 Subject: [PATCH 065/122] Poll a run's stop flag before each native Adams call The native march already returns to Python at a call's bound, 256 attempts or 0.1 s, but nothing polled there, so a flag waited for the next plane and a one-plane run ignored it and finished as complete. Poll where the reference does, before the march's next attempts, which costs one Python call per return. `solprop run` and `solprop resume` turn their first SIGTERM or Ctrl-C into that flag, so the same fix stops them; they used to report a stop the file showed had not happened. A signal in a script's `Simulation.run` was never delayed past the call's bound. The manual now says what a stop waits for on this march. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/519-native-adams-stop.fixed.md | 5 + docs/dev/kernel-contract.md | 17 +- docs/dev/stepper.md | 4 +- docs/guide/results/resuming.md | 12 +- docs/guide/workflows/scans.md | 4 +- native/src/solprop_native/__init__.py | 1 + native/src/solprop_native/backend.py | 14 +- src/solprop/backends/driver.py | 9 +- src/solprop/backends/protocol.py | 2 +- src/solprop/driver/executors/pool.py | 10 +- src/solprop/driver/run.py | 7 +- src/solprop/driver/simulation.py | 5 +- tests/driver/test_native_march_lifecycle.py | 171 +++++++++++++++++++- tests/driver/test_scan_stop_progress.py | 38 ++++- tests/driver/test_stop.py | 155 +++++++++++++++++- 15 files changed, 417 insertions(+), 37 deletions(-) create mode 100644 changelog.d/519-native-adams-stop.fixed.md diff --git a/changelog.d/519-native-adams-stop.fixed.md b/changelog.d/519-native-adams-stop.fixed.md new file mode 100644 index 00000000..bf1bb127 --- /dev/null +++ b/changelog.d/519-native-adams-stop.fixed.md @@ -0,0 +1,5 @@ +`stop=` now stops a native `Multistep()` run during its march instead of at its next save +plane: within a native call's bound, 256 attempts or 0.1 s, and the attempt in flight. With +one plane the flag was ignored and the run finished as `complete`. `solprop run` and +`solprop resume`, whose first SIGTERM or Ctrl-C reaches a run through that flag, stop the +same way; they used to report a stop that the file showed had not happened (#519). diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md index 73aed4e4..09453446 100644 --- a/docs/dev/kernel-contract.md +++ b/docs/dev/kernel-contract.md @@ -1205,7 +1205,10 @@ schedule index); and its bounds: the target; for a replay, the edges' slack (`replay_tolerance`); whether the collar rests; the accepted steps after which the driver has work (the next statistics row, natural stop or monitor check, whichever comes first; none for zero); the monitor's last check and interval; and the call's own bound, -256 attempts or 0.1 s of wall time, which bounds a signal's wait. +256 attempts or 0.1 s of wall time, read after each attempt, which bounds the wait of a +signal or a stop flag. Where a call returns is not a decision of the march's: a return +hands the state back unrounded, so the bound moves no number, and a test holds a bound of +one attempt, of seven and of none to one set of bits. **What it returns**: @@ -1221,11 +1224,13 @@ first; none for zero); the monitor's last check and interval; and the call's own (convergence, value, backend) and message; - the state above, every step before the event committed. -**What solprop does with a call** (`_NativeAdamsMarch`): inside one `committing()` it -adopts the state and books each row, an accepted row on which the driver has no work -through `StepDriver._skip_step` and the rest through `StepDriver._record_attempt`; after -the commit it reports progress per attempt, takes the event step's statistics and natural -stop, and raises the error the reason names, with the reference's message. +**What solprop does with a call** (`_NativeAdamsMarch`): before the call it polls the +run's stop flag, which native code cannot read, as the reference polls before an attempt; +inside one `committing()` it makes the call, adopts the state and books each row, an +accepted row on which the driver has no work through `StepDriver._skip_step` and the rest +through `StepDriver._record_attempt`; after the commit it reports progress per attempt, +takes the event step's statistics and natural stop, and raises the error the reason +names, with the reference's message. **Rounding.** The decisions are compiled without contraction or fast-math in their own translation unit and call no elementary function but `pow`, so that they round as diff --git a/docs/dev/stepper.md b/docs/dev/stepper.md index d5fb5747..64c9722e 100644 --- a/docs/dev/stepper.md +++ b/docs/dev/stepper.md @@ -418,7 +418,9 @@ replayed schedules from that record on its resident engine (`native/src/adams_march.cpp`), a run of attempts per call, and returns to solprop only at an event: the target reached or crossed, an accepted step on which the driver has work (a statistics row, a natural stop, a monitor check), the call's bound, the -budget, the floor or a native error. The driver books the call's attempts through +budget, the floor or a native error. The run's stop flag is polled before each call +and a signal's handler runs after it, so the call's bound, 256 attempts or 0.1 s, is +what either waits for. The driver books the call's attempts through `_record_attempt` and `_skip_step`, inside one commit. `AdamsMarch` is the reference and steps every configuration the native march does not cover (the comparison methods' general path, a coupled flow on the host engine (off the eigenbasis route or diff --git a/docs/guide/results/resuming.md b/docs/guide/results/resuming.md index 65a8d6b5..826d7407 100644 --- a/docs/guide/results/resuming.md +++ b/docs/guide/results/resuming.md @@ -187,7 +187,7 @@ is tried. ### Stopping a run `Simulation.run(..., stop=flag)` takes any object with an `is_set()` method, such as a -{py:class}`threading.Event`. The run polls it before its first step and before every +{py:class}`threading.Event`. The run polls it before its first step, then before every trial step, so it answers within one step. When it is set, the run writes its state as SIGTERM would, tells the reporter through `interrupted`, and **returns** the handler: a requested stop is not an error, and the outcome's message says the caller's flag stopped @@ -196,6 +196,16 @@ platform, with no signals; each run polls its own. `solprop run` and `solprop re it on Ctrl-C, SIGTERM and, on Windows, Ctrl-Break, and exit with the usual status; a second Ctrl-C interrupts at once. +The exception is the native backend's `Multistep()` march, which runs many attempts in +one native call, where the flag cannot be read. The run polls before each call instead, +and a call returns after at most 256 attempts or 0.1 s of wall time, read after each +attempt, whichever comes first. So a stop waits for that bound and the attempt in flight: +256 attempts on a small problem, about 0.1 s on a larger one, one attempt where an attempt +takes longer than 0.1 s. A call also returns at a save plane, a statistics row or a +boundary check. Where a call returns changes nothing the run computes. SIGTERM and Ctrl-C +in a script's `Simulation.run` wait for the same return, since Python runs a signal's +handler only then. + ## Completion status and terminal checkpoints Both standard handlers implement the optional diff --git a/docs/guide/workflows/scans.md b/docs/guide/workflows/scans.md index 58c747da..74a124ec 100644 --- a/docs/guide/workflows/scans.md +++ b/docs/guide/workflows/scans.md @@ -320,8 +320,8 @@ running stops as its executor allows: | executor | `stop=` | |---|---| -| `Local` | the point in flight polls the flag before every trial step, as `Simulation.run(stop=...)` does, and stops within one step | -| `Pool` | the controller sends each busy worker a stop message on its pipe, which that worker's run polls before every trial step; it then collects the workers' results and shuts them down. Terminating a worker that does not exit remains the last resort | +| `Local` | the point in flight polls the flag as `Simulation.run(stop=...)` does: before every trial step, or on a native `Multistep()` march each time a native call returns, so it stops within one step or [that call's bound](../results/resuming.md#stopping-a-run) | +| `Pool` | the controller sends each busy worker a stop message on its pipe, which that worker's run polls in the same way; it then collects the workers' results and shuts them down. Terminating a worker that does not exit remains the last resort | | `Batch` | between groups: a group that has started runs to its end, and the claimed points of a group not yet run are given back | | `Queue`, `Slurm` | refused, with an error naming the queue's own tools: the points run in other processes or on other machines, which a flag in this process cannot reach. Use `scancel` for a Slurm job, or stop a queue's controllers with SIGTERM | diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py index e7ff759b..0c04f404 100644 --- a/native/src/solprop_native/__init__.py +++ b/native/src/solprop_native/__init__.py @@ -196,6 +196,7 @@ def _band_request() -> int: "_observed_field", "_on_accepted_step", "_on_axis_synthesis", + "_poll_stop", "_record_attempt", "_require_grid", "_require_state", diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 4de12081..a6e42369 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -2462,21 +2462,26 @@ class _NativeAdamsMarch(AdamsMarch): (a statistics row, a natural stop, a monitor check), the call's bound, the budget, the floor or a native error. Between calls the march's state is this object's, as the Python march keeps it, so a snapshot, a load, a new run and a reset are the - reference's own; each call takes the state and hands it back. + reference's own; each call takes the state and hands it back. Where a call returns + is therefore not a decision of the march's, and its bound depends on the wall clock + without moving a number. One ``committing()`` spans each call, the state's synchronisation and the step history's rows, so a signal arriving meanwhile is raised with the host's records in step with the native state. The progress callbacks, the debug lines and the event - step's work run after it, and an error is raised last. + step's work run after it, and an error is raised last. A run's stop flag, which + native code cannot read, is polled before each call, not before each attempt: both + a signal and a stop wait for the call in hand, at most its bound and one attempt. A configuration the native march does not cover runs the Python march (:attr:`refusal`), with a warning unless the private switch asked for it. """ MAX_ATTEMPTS: ClassVar[int] = 256 - """A call's most attempts, the bound on a signal's wait (solprop SEP 39 Q3).""" + """A call's most attempts, the bound on the wait of a signal or a stop flag (solprop + SEP 39 Q3).""" MAX_SECONDS: ClassVar[float] = 0.1 - """A call's most wall time, s.""" + """A call's most wall time, s, read once per attempt.""" def __init__( self, @@ -2710,6 +2715,7 @@ def _call(self, target: float, slack: float | None = None) -> bool: ``None`` for an adaptive march. """ backend = self._driver + backend._poll_stop() bounds = self._bounds(target, slack) started = time.perf_counter() - backend._clock with committing(): diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index 18bcf7a6..a0bd2674 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -1293,14 +1293,15 @@ def set_progress(self, reporter: ProgressReporter) -> None: self._progress_started = False _stop: StopFlag | None = None - """The flag every trial step polls (:meth:`set_stop`); ``None`` by default.""" + """The flag the march polls (:meth:`set_stop`); ``None`` by default.""" def set_stop(self, flag: StopFlag | None) -> None: - """Attach the flag every trial step polls, or detach it with ``None``. + """Attach the flag the march polls, or detach it with ``None``. A run polls its own flag before each trial (SEP 41, S17), where the state is the last accepted one, so a stop abandons nothing but the trial not yet taken. - Polling changes nothing the propagation computes. + A march that runs its trials in a native call polls before each call. Polling + changes nothing the propagation computes. Parameters ---------- @@ -1311,7 +1312,7 @@ def set_stop(self, flag: StopFlag | None) -> None: self._stop = flag def _poll_stop(self) -> None: - """Raise when the run's stop flag is set; called before every trial step. + """Raise when the run's stop flag is set; called before every trial or call. Raises ------ diff --git a/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py index 06920f61..d127ee2c 100644 --- a/src/solprop/backends/protocol.py +++ b/src/solprop/backends/protocol.py @@ -680,7 +680,7 @@ def set_progress(self, reporter: ProgressReporter) -> None: ... def set_stop(self, flag: StopFlag | None) -> None: - """Attach the flag every trial step polls, or detach it with ``None``. + """Attach the flag the march polls, or detach it with ``None``. Parameters ---------- diff --git a/src/solprop/driver/executors/pool.py b/src/solprop/driver/executors/pool.py index ae0a87a2..ea167979 100644 --- a/src/solprop/driver/executors/pool.py +++ b/src/solprop/driver/executors/pool.py @@ -211,8 +211,8 @@ def _is_stop(message: object) -> bool: class _Channel: """The worker's end of its pipe: commands in, a stop flag, progress out. - :meth:`is_set` is the stop flag the worker's run polls before every trial step. It - reads what has arrived without waiting, remembers a stop, and keeps any other + :meth:`is_set` is the stop flag the worker's run polls, as any run polls its flag. + It reads what has arrived without waiting, remembers a stop, and keeps any other message for :meth:`command`, so polling never consumes a command. Parameters @@ -857,9 +857,9 @@ def run[T, R](self, points: Iterable[T], work: Callable[[T], R]) -> Iterator[R]: Work that implements :class:`ControlledWork` is stopped and watched through each worker's pipe (SEP 41, S4 and S17). Once its caller's flag is set, the - pool sends every busy worker the stop message, which that worker's run polls - before every trial step, collects their results, dispatches nothing more and - shuts the workers down; the abrupt ``terminate`` of a worker that does not + pool sends every busy worker the stop message, which that worker's run polls, + collects their results, dispatches nothing more and shuts the workers down; + the abrupt ``terminate`` of a worker that does not exit stays the last resort. While a watched point runs, its worker sends the run's reporter calls, which the pool hands to :meth:`ControlledWork.relay`. diff --git a/src/solprop/driver/run.py b/src/solprop/driver/run.py index a141d5f8..5f21111e 100644 --- a/src/solprop/driver/run.py +++ b/src/solprop/driver/run.py @@ -405,9 +405,10 @@ def run_problem( # noqa: PLR0912, PLR0913, PLR0915 - the loop's inputs and comm fraction is reported at the end. ``None`` watches nothing. stop : solprop.log.StopFlag or None, optional The run's own stop flag, any object with an ``is_set()`` method (SEP 41, - S17). It is polled before the first step, before every trial step and between - save planes; once it is set the run writes its state as for SIGTERM, tells the - reporter through ``interrupted``, and raises + S17). It is polled before the first step, before every trial step (on a native + ``Multistep()`` march, before every native call) and between save planes; once + it is set the run writes its state as for SIGTERM, tells the reporter through + ``interrupted``, and raises :class:`~solprop.exceptions.StopRequested`, which :meth:`~solprop.driver.simulation.Simulation.run` turns into a return. recipe : solprop.output.schema.RecipeRecord or None, optional diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 3d4561f0..f099b722 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -3517,7 +3517,10 @@ def run( # noqa: PLR0913 - independent execution and output controls This run's own stop flag: any object with an ``is_set()`` method, such as a :class:`threading.Event` (SEP 41, S17). It is polled before the first step and before every trial step, so the run answers within one step or - trial. When it is set the run writes its state as for SIGTERM, tells the + trial; on a native ``Multistep()`` march, whose trials run in native calls + that cannot read it, before every call, so the run answers within 256 + attempts or 0.1 s and the attempt in flight, whichever comes first. When it + is set the run writes its state as for SIGTERM, tells the reporter through ``interrupted``, and returns the handler: a requested stop is not an error. The outcome's message says the caller's flag stopped it, and ``resume=True`` continues the file. A front end sets it diff --git a/tests/driver/test_native_march_lifecycle.py b/tests/driver/test_native_march_lifecycle.py index 9dbf3122..de5523a3 100644 --- a/tests/driver/test_native_march_lifecycle.py +++ b/tests/driver/test_native_march_lifecycle.py @@ -4,16 +4,18 @@ """The native Adams march stops where the Python march would (solprop SEP 39 7.5). -A native call runs many attempts and returns at an event; whatever stops the run -- -a native failure at the startup sample, a trial or an accept, the floor, the budget, a -signal during the call, a progress reporter's exception as the call's steps are -recorded -- leaves the backend at the call's last accepted step, its state, position, -history and ledger together, and a terminal checkpoint that a resume continues. +A native call runs many attempts and returns at an event or its bound; whatever stops +the run -- a native failure at the startup sample, a trial or an accept, the floor, the +budget, a signal or a stop flag during the call, a progress reporter's exception as the +call's steps are recorded -- leaves the backend at the call's last accepted step, its +state, position, history and ledger together, and a terminal checkpoint that a resume +continues. """ import dataclasses import signal -from typing import Any +import threading +from typing import Any, cast import pytest @@ -22,8 +24,9 @@ from driver.test_resume_history import RTOL, assert_accurate_run, soliton, tight_run from march_bounds import TIGHTER from solprop.backends import available +from solprop.cli import main from solprop.exceptions import ConvergenceError, ResponseBudgetError, RunTerminated -from solprop.output.hdf5 import resume +from solprop.output.hdf5 import open_file, resume from solprop.stepper.multistep import Multistep pytestmark = [ @@ -174,6 +177,160 @@ def signalled(self, state, bounds): assert state.march.z == state.checkpoint.plane.z > seen[0] +FORMS = { + "exponential": Multistep(), + "interaction": Multistep(formulation="interaction"), +} +"""The two forms of the native march.""" + +needs_default_sigint = pytest.mark.skipif( + signal.getsignal(signal.SIGINT) is not signal.default_int_handler, + reason="something else handles SIGINT here", +) + + +ONE_PLANE_LENGTH = 0.2 +"""The one-plane run's length, m, with an N = 10 soliton: about a thousand steps.""" + + +def one_plane(build_simulation, form: str): + """Return an N = 10 soliton saved only at its end, with no boundary monitor: only a + native call's own bound returns the march to Python before the end of the run.""" + + def unmonitored(): + simulation = build_simulation( + order=10, + length=ONE_PLANE_LENGTH, + n_save=2, + backend="native", + rtol=RTOL, + integrator=FORMS[form], + ) + simulation.settings = dataclasses.replace( + simulation.settings, boundary_check=None + ) + return simulation + + return unmonitored + + +def calling(monkeypatch, at_call: int, act) -> list[int]: + """Run `act` as native call number `at_call` begins; return each call's attempts.""" + run = NativeAdamsMarch._native_run + attempts: list[int] = [] + + def counted(self, state, bounds): + if len(attempts) + 1 == at_call: + act() + result = run(self, state, bounds) + attempts.append(len(result["rows"])) + return result + + monkeypatch.setattr(NativeAdamsMarch, "_native_run", counted) + return attempts + + +def assert_stopped_at_its_call(part, attempts: list[int], at_call: int) -> None: + """Assert the run made no native call after `at_call`, and stopped at its end.""" + assert len(attempts) == at_call + assert all(found <= NativeAdamsMarch.MAX_ATTEMPTS for found in attempts) + assert_consistent_stop(part) + with open_file(part) as stored: + assert stored.outcome is not None + assert stored.outcome.status == "failed" + assert 0.0 < stored.outcome.z < ONE_PLANE_LENGTH + assert stored.n_saved == 1 + taken = cast("int", stored.outcome.logical_calls["accepted_steps"]) + assert taken <= sum(attempts) + + +@pytest.mark.parametrize("form", FORMS) +def test_a_flag_set_during_a_native_call_stops_the_march_as_it_returns( + build_simulation, tmp_path, monkeypatch, form +): + flag = threading.Event() + attempts = calling(monkeypatch, 3, flag.set) + part = tmp_path / "part.h5" + one_plane(build_simulation, form)().run(part, progress="none", stop=flag) + assert_stopped_at_its_call(part, attempts, 3) + + +@pytest.mark.medium +@pytest.mark.parametrize("form", FORMS) +@pytest.mark.parametrize( + ("number", "raised"), + [ + pytest.param( + signal.SIGTERM, RunTerminated, marks=needs_default_sigterm, id="sigterm" + ), + pytest.param( + signal.SIGINT, KeyboardInterrupt, marks=needs_default_sigint, id="sigint" + ), + ], +) +def test_a_signal_during_a_one_plane_march_stops_it_as_the_native_call_returns( + build_simulation, tmp_path, monkeypatch, form, number, raised +): + # Python's handler runs when the call's bound brings the main thread back to the + # interpreter, so a signal needs no plane to reach the run. + build = one_plane(build_simulation, form) + part = tmp_path / "part.h5" + with monkeypatch.context() as patch: + attempts = calling(patch, 3, lambda: signal.raise_signal(number)) + with pytest.raises(raised): + build().run(part, progress="none") + assert_stopped_at_its_call(part, attempts, 3) + build().run(part, resume=True, progress="none") + with open_file(part) as stored: + assert stored.outcome is not None + assert stored.outcome.status == "complete" + assert stored.outcome.z == ONE_PLANE_LENGTH + + +ONE_PLANE_RECIPE = f"""\ +[recipe] +format = 1 +route = "gnlse" + +[build] +gamma = 0.11 +length = "{ONE_PLANE_LENGTH} m" +betas = [0, 0, "-0.01183 ps^2/m"] +wavelength = "835 nm" +wavelength_limits = ["500 nm", "2 um"] +trange = "1 ps" +width = "100 fs" +peak_power = "983 W" +shape = "sech" +n_save = 2 +noise = "none" +backend = "native" +integrator = {{type = "Multistep"}} +""" +"""The N = 10 soliton of :func:`one_plane`, as a recipe.""" + + +@needs_default_sigterm +def test_the_command_line_stops_a_one_plane_march_on_its_first_signal( + tmp_path, monkeypatch, capsys +): + # `solprop run` maps the signal onto the run's flag, so here the signal has nothing + # but the flag to reach the march by: unpolled, the run finished its plane. + recipe, part = tmp_path / "soliton.toml", tmp_path / "part.h5" + recipe.write_text(ONE_PLANE_RECIPE, encoding="utf-8") + with monkeypatch.context() as patch: + attempts = calling(patch, 3, lambda: signal.raise_signal(signal.SIGTERM)) + status = main(["run", str(recipe), "-o", str(part), "--progress", "none"]) + assert status == 128 + signal.SIGTERM + assert "solprop resume" in capsys.readouterr().err + assert_stopped_at_its_call(part, attempts, 3) + assert main(["resume", str(part), "--progress", "none"]) == 0 + with open_file(part) as stored: + assert stored.outcome is not None + assert stored.outcome.status == "complete" + assert stored.outcome.z == ONE_PLANE_LENGTH + + def two_marches(build, monkeypatch, run) -> dict[str, Any]: """Run `run(simulation)` on the native march and on the host one; return each march's outcome: the error's class and message, its position and its ledger.""" diff --git a/tests/driver/test_scan_stop_progress.py b/tests/driver/test_scan_stop_progress.py index cfe01bb0..59b96616 100644 --- a/tests/driver/test_scan_stop_progress.py +++ b/tests/driver/test_scan_stop_progress.py @@ -4,6 +4,7 @@ """A scan's portable stop and its point-by-point progress (SEP 41, S4 and S17).""" +import dataclasses import logging import multiprocessing as mp import sys @@ -17,6 +18,7 @@ from legacy_geometry import LEGACY_GRID, bare_absorber from march_bounds import FIXED_ORDER +from solprop.backends.registry import available from solprop.driver import _scan_batch from solprop.driver._scan_run import _collect, _Job, _Options, _ScanEvents, _Work from solprop.driver.executors import Batch, Local, Queue, Slurm, pool @@ -26,7 +28,7 @@ from solprop.exceptions import DriverError, StopRequested from solprop.grid import EnvelopeGrid from solprop.log import NullReporter, NullScanReporter, PlaneSummary -from solprop.stepper import FixedSchedule +from solprop.stepper import FixedSchedule, Multistep pytestmark = pytest.mark.filterwarnings("ignore::solprop.exceptions.BoundaryWarning") @@ -256,6 +258,40 @@ def test_local_stop_mid_point_is_not_committed_and_a_rerun_computes_it(tmp_path) assert rerun.finished() == [(0, "skipped"), (1, "done"), (2, "done")] +@pytest.mark.skipif("native" not in available(), reason="native is not installed") +def test_local_stop_reaches_a_one_plane_native_adams_point(tmp_path): + native = pytest.importorskip("solprop_native.backend") + most = native._NativeAdamsMarch.MAX_ATTEMPTS + + def build(point): + # More steps than a native call's bound, and no plane until the last of them. + simulation = _fibre(point.values["power"], 2 * most, 1) + simulation.settings = dataclasses.replace( + simulation.settings, + integrator=Multistep(), + backend="native", + boundary_check=None, + ) + return simulation + + scan = Scan("stop", power=[1.0, 2.0]) + flag = threading.Event() + reporter = StopAtStep(flag, index=0, steps=10) + output = run_scan( + scan, + build, + output=tmp_path / "scan.h5", + executor=Local(), + progress=reporter, + stop=flag, + ) + assert output.status.tolist() == ["todo", "todo"] + assert reporter.finished() == [(0, "stopped")] + steps = [call for call in reporter.points[0].calls if call[0] == "step"] + # Polled as a native call returns: the rest of that call's attempts report too. + assert 10 <= len(steps) < 10 + most + + def test_a_flag_set_between_local_points_dispatches_no_more(tmp_path): scan = Scan("between", power=[1.0, 2.0, 3.0]) flag = threading.Event() diff --git a/tests/driver/test_stop.py b/tests/driver/test_stop.py index df83e99b..dd1e195f 100644 --- a/tests/driver/test_stop.py +++ b/tests/driver/test_stop.py @@ -4,18 +4,21 @@ """A portable stop: a run's own flag stops it, and the file resumes (SEP 41, S17).""" +import dataclasses import threading import numpy as np import pytest +from driver.test_resume_history import assert_accurate_run, tight_run from legacy_geometry import LEGACY_GRID, bare_absorber +from march_bounds import TIGHTER from solprop.backends.registry import available from solprop.exceptions import StopRequested from solprop.grid import EnvelopeGrid from solprop.log import NullReporter from solprop.output.hdf5 import open_file -from solprop.stepper import FixedSchedule, RungeKutta +from solprop.stepper import FixedSchedule, LowStorageRungeKutta, Multistep, RungeKutta pytestmark = pytest.mark.filterwarnings("ignore::solprop.exceptions.BoundaryWarning") @@ -109,6 +112,156 @@ def test_a_set_flag_stops_the_run_which_returns_and_resumes( ) +NATIVE = pytest.param( + "native", + marks=pytest.mark.skipif( + "native" not in available(), reason="native is not installed" + ), +) + +MARCHES = { + "adams_exponential": Multistep(), + "adams_interaction": Multistep(formulation="interaction"), + "adams_replay": Multistep(), + "runge_kutta": RungeKutta(), + "low_storage": LowStorageRungeKutta(), +} +"""Every march a run can take, keyed by name.""" + +REPLAY_STEPS = {"adams_replay": 4000, "low_storage": 400} +"""The steps of the marches that take a schedule's: the uniform steps an Adams replay of +this soliton stays stable on, and few enough that the host's per-step work stays small +for a march it steps from Python.""" + +LENGTHS = {"runge_kutta": 0.02, "low_storage": 0.02} +"""The marches stepped from Python run a tenth of :data:`ONE_PLANE_LENGTH`, m: they stop +at once, and only their resume runs the whole length.""" + +FLAGGED_AT = 20 +"""The accepted step whose report sets the flag.""" + +ONE_PLANE_LENGTH = 0.2 +"""The one-plane run's length, m, with an N = 10 soliton: the adaptive Adams marches +take about a thousand steps, far more than a native call's bound past +:data:`FLAGGED_AT`.""" + +ONE_PLANE_RTOL = 1e-6 +"""An adaptive one-plane run's relative tolerance.""" + + +def _length(march): + """Return `march`'s one-plane run's length, m.""" + return LENGTHS.get(march, ONE_PLANE_LENGTH) + + +def _one_plane(march, backend, *, rtol=ONE_PLANE_RTOL): + """Return an N = 10 soliton saved only at its end, with no boundary monitor. + + Nothing but a native call's own bound returns a native Adams march to Python here: + no plane, statistics row or monitor check falls due before the end of the run. + """ + length = _length(march) + grid = EnvelopeGrid.from_limits( + length, 835e-9, (500e-9, 2e-6), 1e-12, **LEGACY_GRID + ) + schedule = None + if march in REPLAY_STEPS: + schedule = FixedSchedule.uniform( + 0.0, length, length / REPLAY_STEPS[march], save_planes=[length] + ) + + def build(build_simulation): + simulation = build_simulation( + order=10, + length=length, + grid=grid, + absorber=bare_absorber(grid), + n_save=2, + schedule=schedule, + integrator=MARCHES[march], + backend=backend, + rtol=rtol, + ) + simulation.settings = dataclasses.replace( + simulation.settings, boundary_check=None + ) + return simulation + + return build + + +def _steps_after_the_flag(march, backend): + """Return the most accepted steps a march can report after the flag's report. + + Each Python march polls before every trial, so it takes none. A native Adams march + polls each time a native call returns, and the rest of that call's attempts report + after the one that set the flag. + """ + if backend == "native" and march.startswith("adams"): + native = pytest.importorskip("solprop_native.backend") + return native._NativeAdamsMarch.MAX_ATTEMPTS - 1 + return 0 + + +@pytest.mark.parametrize("backend", ["numpy_ref", NATIVE]) +@pytest.mark.parametrize("march", MARCHES) +def test_a_flag_stops_a_one_plane_run_within_its_march_s_bound( + build_simulation, tmp_path, march, backend +): + path = tmp_path / "stopped.h5" + flag = threading.Event() + reporter = StopAfter(flag, FLAGGED_AT) + simulation = _one_plane(march, backend)(build_simulation) + assert simulation.run(path, progress=reporter, stop=flag) is not None + outcome, saved, _ = _read(path) + assert outcome is not None + assert outcome.status == "failed" + assert "stopped by the caller's stop flag" in (outcome.message or "") + assert reporter.interrupted_at is not None + assert isinstance(reporter.interrupted_at[1], StopRequested) + assert saved == 1 # the entrance alone: the run never reached its plane + assert 0.0 < outcome.z < _length(march) + assert reporter.accepted >= FLAGGED_AT + assert reporter.accepted - FLAGGED_AT <= _steps_after_the_flag(march, backend) + + +@pytest.mark.medium +@pytest.mark.parametrize("backend", [NATIVE]) +@pytest.mark.parametrize("march", MARCHES) +def test_a_stopped_one_plane_run_resumes_to_its_end( + build_simulation, tmp_path, march, backend +): + build = _one_plane(march, backend) + whole, part = tmp_path / "whole.h5", tmp_path / "part.h5" + build(build_simulation).run(whole, progress="none") + flag = threading.Event() + build(build_simulation).run(part, progress=StopAfter(flag, FLAGGED_AT), stop=flag) + stopped = _read(part)[0] + assert stopped is not None + assert stopped.status == "failed" + build(build_simulation).run(part, resume=True, progress="none") + finished, saved, field = _read(part) + assert finished is not None + assert finished.status == "complete" + assert finished.z == _length(march) + assert saved == 2 + if march in REPLAY_STEPS: + # A replayed schedule resumed is the uninterrupted computation, to rounding + # (class R): the same steps from the same stored state. + reference = _read(whole)[2] + np.testing.assert_allclose( + field, reference, rtol=0, atol=1e-12 * np.abs(reference).max() + ) + return + + def tight(): + return _one_plane(march, backend, rtol=ONE_PLANE_RTOL / TIGHTER)( + build_simulation + ) + + assert_accurate_run(whole, part, tight_run(tight, tmp_path / "tight.h5")) + + def test_a_flag_set_before_the_run_stops_it_before_its_first_step( build_simulation, tmp_path ): From 908005c2221b5d71198d342172197499b1bc0ee0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:34:28 +0000 Subject: [PATCH 066/122] Hold a native Adams call's bound to moving no number A call's return points depend on the wall clock, and now a stop flag acts there as well as a signal, so a return has to be an exact no-op. Compare every number a run stores under bounds of one attempt, of seven, of the clock at every attempt and of none: both forms, with and without a monitor, with statistics rows, natural stops, rejections, a replay, interpolated planes, a varying operator, the transverse edge, a requested collar and a split collar. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/driver/test_native_march_call_bound.py | 306 +++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 tests/driver/test_native_march_call_bound.py diff --git a/tests/driver/test_native_march_call_bound.py b/tests/driver/test_native_march_call_bound.py new file mode 100644 index 00000000..59472122 --- /dev/null +++ b/tests/driver/test_native_march_call_bound.py @@ -0,0 +1,306 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Where a native Adams call returns moves no number (solprop SEP 39 section 6.5.7). + +A native call returns at an event or at its bound, 256 attempts or 0.1 s of wall time, +so on a run with few events its return points depend on the machine's speed. The march's +state crosses each return and comes back, and a stop flag or a signal acts only there, +so a return has to be an exact no-op: every number a run stores is compared here, bit +for bit, under bounds that return at every attempt, at every seventh, at the wall +clock's every attempt, and never. +""" + +import dataclasses +import json +import math +from collections.abc import Callable +from typing import cast + +import h5py +import numpy as np +import pytest + +from backends.test_exponential_varying import EXPONENTIAL, gradient +from backends.test_transverse_backends import radial_collar +from legacy_geometry import LEGACY_GRID, bare_absorber +from solprop.backends import available +from solprop.driver import Simulation +from solprop.grid import EnvelopeGrid +from solprop.monitor import BoundaryCheck +from solprop.output.hdf5 import open_file +from solprop.stepper import FixedSchedule, Multistep +from test_split_crossing import _beam +from test_transverse_reads import REREVIEW, packet_run + +pytestmark = [ + pytest.mark.skipif("native" not in available(), reason="native is not installed"), + pytest.mark.filterwarnings("ignore::solprop.exceptions.BoundaryWarning"), +] + +NativeAdamsMarch = pytest.importorskip("solprop_native.backend")._NativeAdamsMarch + +BOUNDS = { + "every_attempt": (1, math.inf), + "every_seventh": (7, math.inf), + "clock_at_every_attempt": (256, 0.0), + "none": (2**62, math.inf), +} +"""Each bound compared, as ``(MAX_ATTEMPTS, MAX_SECONDS)``.""" + +LENGTH = 0.05 +"""The soliton's length, m: a few hundred steps at this order and tolerance.""" + +WINDOW = 4e-12 +"""The soliton's time window, s: wide enough that the guards are quiet and the monitor +reads every eighth step, not every step, so a call has several attempts to bound.""" + +INTERACTION = Multistep(formulation="interaction") + + +def soliton(build_simulation, integrator=EXPONENTIAL, *, check=True, **keywords): + """Return an N = 10 soliton with one plane, on the native march.""" + + def build() -> Simulation: + settings = { + "order": 10, + "length": LENGTH, + "n_save": 2, + "rtol": 1e-6, + "trange": WINDOW, + "backend": "native", + "integrator": integrator, + } | keywords + simulation = build_simulation(**settings) + if not check: + simulation.settings = dataclasses.replace( + simulation.settings, boundary_check=None + ) + return simulation + + return build + + +def with_rows(build: Callable[[], Simulation]) -> Callable[[], Simulation]: + """Return `build` with an energy row every third accepted step.""" + + def built() -> Simulation: + simulation = build() + stats = simulation.settings.stats + assert stats is not None + simulation.settings = dataclasses.replace( + simulation.settings, + stats=dataclasses.replace(stats, periodic=("energy",), period=3), + ) + return simulation + + return built + + +def with_settings( + build: Callable[[], Simulation], **settings: object +) -> Callable[[], Simulation]: + """Return `build` with `settings` replaced.""" + + def built() -> Simulation: + simulation = build() + simulation.settings = dataclasses.replace(simulation.settings, **settings) + return simulation + + return built + + +def replay(build_simulation, integrator) -> Callable[[], Simulation]: + """Return the soliton on a uniform schedule, with no temporal collar to outrun.""" + grid = EnvelopeGrid.from_limits( + LENGTH, 835e-9, (500e-9, 2e-6), 1e-12, **LEGACY_GRID + ) + return soliton( + build_simulation, + integrator, + check=False, + grid=grid, + trange=None, + absorber=bare_absorber(grid), + schedule=FixedSchedule.uniform( + 0.0, LENGTH, LENGTH / 1000, save_planes=[LENGTH] + ), + ) + + +def _gnlse_cases(build_simulation) -> dict[str, Callable[[], Simulation]]: + """Return the soliton's cases: both forms, monitored or not, and what returns.""" + base = soliton(build_simulation) + interaction = soliton(build_simulation, INTERACTION) + return { + "exponential": base, + "exponential_unmonitored": soliton(build_simulation, check=False), + "interaction": interaction, + "interaction_unmonitored": soliton(build_simulation, INTERACTION, check=False), + "exponential_rows": with_rows(base), + "interaction_rows": with_rows(interaction), + # A saved plane every fifth step, the short way: each writes its field. + "natural_stops": with_settings( + soliton(build_simulation, length=LENGTH / 5, check=False), step_planes=5 + ), + # A tight tolerance, and a first step far too long with no sizing of it: the + # march starts by shrinking it, with a return between each rejection and retry. + "rejections": with_settings( + soliton(build_simulation, check=False, rtol=1e-9), + dz_init=LENGTH / 4, + auto_init=False, + ), + "replay": replay(build_simulation, EXPONENTIAL), + "interaction_replay": replay(build_simulation, INTERACTION), + "dense": with_settings( + soliton(build_simulation, n_save=9), interpolate_planes=True + ), + } + + +def _route_cases() -> dict[str, Callable[[], Simulation]]: + """Return the cases whose monitor carries state between the calls.""" + return { + # A varying operator's compensated march, and the host's z-dependent bundles. + "gradient": lambda: gradient( + integrator=EXPONENTIAL, rtol=1e-6, backend="native" + ), + # The transverse edge: its reads, `read_due` and the ceiling they set. + "transverse_edge": lambda: packet_run( + REREVIEW, + integrator=Multistep(), + check=BoundaryCheck(transverse_budget=10.0), + backend="native", + ), + # A requested collar's current, accumulated at every committed step. + "requested_collar": lambda: radial_collar("native", Multistep()), + # The split collar's crossing cap and removal, read at the checks. + "split_collar": lambda: _beam("native", 32, 500e-15, 0.1), + } + + +def _stored(path) -> dict[str, np.ndarray | str]: + """Return every number a closed file stores, but the wall clock's and the record.""" + found: dict[str, np.ndarray | str] = {} + + def take(name: str, item: object) -> None: + if isinstance(item, h5py.Dataset) and not ( + name.startswith("meta/") or name == "step_history/wall_time" + ): + found[name] = np.asarray(item) + + with h5py.File(path) as stored: + stored.visititems(take) + with open_file(path) as handler: + outcome = handler.outcome + assert outcome is not None + assert outcome.status == "complete" + assert outcome.execution["adams_march"] == "native" + found["logical_calls"] = json.dumps(outcome.logical_calls, sort_keys=True) + # The monitor's own record: what it read, removed and skipped. + found["boundary_monitor"] = json.dumps( + outcome.execution.get("boundary_monitor"), sort_keys=True + ) + return found + + +def _counted_attempts(patch: pytest.MonkeyPatch) -> list[int]: + """Return a list that fills with each native call's attempts.""" + run = NativeAdamsMarch._native_run + attempts: list[int] = [] + + def counted(self, state, bounds): + result = run(self, state, bounds) + attempts.append(len(result["rows"])) + return result + + patch.setattr(NativeAdamsMarch, "_native_run", counted) + return attempts + + +def _assert_no_number_moved(build, tmp_path, monkeypatch) -> dict[str, list[int]]: + """Run `build` under every bound; assert the files' numbers are one set of bits. + + A return is an explicitly exact no-op (class P of ``docs/dev/testing.md``): the + state crosses as floats and integers, unrounded, and the native state runs the same + operations in the same order, one call or many, on one thread width. + """ + stored = {} + attempts = {} + for name, (most, seconds) in BOUNDS.items(): + with monkeypatch.context() as patch: + patch.setattr(NativeAdamsMarch, "MAX_ATTEMPTS", most) + patch.setattr(NativeAdamsMarch, "MAX_SECONDS", seconds) + attempts[name] = _counted_attempts(patch) + path = tmp_path / f"{name}.h5" + build().run(path, progress="none") + stored[name] = _stored(path) + reference = stored["none"] + for name, found in stored.items(): + assert found.keys() == reference.keys(), name + for key, value in reference.items(): + np.testing.assert_array_equal(found[key], value, f"{name}: {key}") + # Each bound held: nothing above was compared with itself by accident. + assert max(attempts["every_attempt"]) == 1 + assert max(attempts["every_seventh"]) <= 7 + assert max(attempts["clock_at_every_attempt"]) == 1 + return attempts + + +@pytest.mark.medium +@pytest.mark.parametrize( + "case", + [ + "exponential", + "exponential_unmonitored", + "interaction", + "interaction_unmonitored", + "exponential_rows", + "interaction_rows", + "natural_stops", + "rejections", + "replay", + "interaction_replay", + "dense", + ], +) +def test_a_native_call_s_bound_moves_no_number_on_a_soliton( + build_simulation, tmp_path, monkeypatch, case +): + attempts = _assert_no_number_moved( + _gnlse_cases(build_simulation)[case], tmp_path, monkeypatch + ) + # The bound of one returned where the case's own events did not. + assert len(attempts["every_attempt"]) > len(attempts["none"]) + if "unmonitored" in case: + # One call to the end, which the bounds cut up. + assert len(attempts["none"]) == 1 + assert len(attempts["every_seventh"]) > 1 + + +@pytest.mark.medium +@pytest.mark.parametrize("case", sorted(_route_cases())) +def test_a_native_call_s_bound_moves_no_number_with_a_monitor_s_state( + tmp_path, monkeypatch, case +): + attempts = _assert_no_number_moved(_route_cases()[case], tmp_path, monkeypatch) + assert len(attempts["every_attempt"]) >= len(attempts["none"]) > 0 + + +def test_the_rejections_case_rejects_steps(build_simulation, tmp_path): + # What makes the case above worth its name. Three rejections in a row restarting a + # history that holds more than one sample is left to the scripted traces, which + # return at every attempt too (`test_native_march_traces.py`): a real field does + # not reach it on a case this small. + path = tmp_path / "rejecting.h5" + _gnlse_cases(build_simulation)["rejections"]().run(path, progress="none") + with open_file(path) as handler: + assert handler.outcome is not None + rejected = cast("int", handler.outcome.logical_calls["rejected_steps"]) + assert rejected > 3 + + +def test_the_call_bound_is_the_documented_one(): + # `docs/guide/results/resuming.md` and `docs/dev/kernel-contract.md` state these. + assert (NativeAdamsMarch.MAX_ATTEMPTS, NativeAdamsMarch.MAX_SECONDS) == (256, 0.1) From c209150e17418bfa292eeca6cad596e7edab6a6c Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:36:00 +0000 Subject: [PATCH 067/122] Take the held plane off the seam figure Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/figures/backends.svg | 170 +++++++++++++++++----------------- docs/dev/figures/build.py | 2 - 2 files changed, 83 insertions(+), 89 deletions(-) diff --git a/docs/dev/figures/backends.svg b/docs/dev/figures/backends.svg index cfcee1ff..1a362ad3 100644 --- a/docs/dev/figures/backends.svg +++ b/docs/dev/figures/backends.svg @@ -1,6 +1,6 @@ + width="1200" height="872.5" viewBox="0 0 1200 872.5"> - + One seam, a shared driver, and the backends behind them backends.md §1: the protocol is drawn at the level of operations on the propagated state, never at the array-namespace level. A backend is constructed from a validated KernelProblem and host arrays and returns host arrays; its buffers, layout and plans are private. - + the seam · backends.protocol.Backend set_initial the state and the noise realisation, host to device, once @@ -81,88 +81,84 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: the observer each attempted step reports to; never an input set_stop the flag polled between trials, or none -set_held_plane -whether the low-storage march holds a field-free plane -attach_monitor -watch the time window's edges from here on -propagate_to -the driver's way in, returning a StepSummary -observe -host arrays: the field at the plane and the step history -held_observation -the held plane, for a checkpoint after a failed step -restore_call_ledgers -resume cumulative calls; numerical history starts afresh -march_snapshot -the Adams history for a resume, or None -march_snapshot_nbytes -its size, copying nothing -march_export_refusal -why the march cannot carry its history, or None -restore_march -continue the history, or say why it starts afresh -timed_step -one step of the march, for an estimate to time -stats -one fused pass, a dict of small host arrays -metadata -what the execution was: pools, plans, digests, propagator mode -memory_ledger -every named buffer and its size -available_device_memory -free device bytes for batch admission; None on hosts -close -idempotent - - -backends.registry.select(name, problem, grid=, modes=, profiles=) - -backends/driver.py · StepDriver, and no backend reimplements it -the march and the landing -_march_to -the replay -_replay_to · _schedule_index_at -the history -_record · observe -the progress reporter -set_progress -the statistics channel -_take_step_stats · stats -the noise -_noise_field · _noise_at · _noise_phase -the per-stage bundle -_hoist · _stage_scalars · _delta_phi · _prefactor · -_unitary_phase - -the stepper it drives -stepper.tableau -DOPRI5, Tsit5, the order-condition checks -stepper.controller -PIController: propose, accepts, rejection_factor, -equivalent_norm -stepper.schedule -landing, discover, calibrate, replay -what a backend supplies -_trial_step · _apply_mask · _finish · _begin_plane -· _on_accepted_step - - - -numpy_ref · the oracle -plain NumPy and scipy.fft -one named function per pass -the general representation, the z table included -propagator_mode auto → tabulate -_trial_step · _apply_mask · _finish · _observed_field - - - -native · what auto selects -solprop-native distribution, native/ -C++20 Kokkos + persistent Kokkos-FFT plans -one native call per seven-stage trial -OpenMP on CPUs; CUDA and HIP on GPUs -_trial_step · _apply_mask · _finish · _observed_field -Three things every backend owes beyond the methods: deterministic reductions, the device form of the stage, and the driver's bookkeeping. The error norm is control flow, so -a sum whose association depends on the thread count forks the trajectory. +attach_monitor +watch the time window's edges from here on +propagate_to +the driver's way in, returning a StepSummary +observe +host arrays: the field at the plane and the step history +restore_call_ledgers +resume cumulative calls; numerical history starts afresh +march_snapshot +the Adams history for a resume, or None +march_snapshot_nbytes +its size, copying nothing +march_export_refusal +why the march cannot carry its history, or None +restore_march +continue the history, or say why it starts afresh +timed_step +one step of the march, for an estimate to time +stats +one fused pass, a dict of small host arrays +metadata +what the execution was: pools, plans, digests, propagator mode +memory_ledger +every named buffer and its size +available_device_memory +free device bytes for batch admission; None on hosts +close +idempotent + + +backends.registry.select(name, problem, grid=, modes=, profiles=) + +backends/driver.py · StepDriver, and no backend reimplements it +the march and the landing +_march_to +the replay +_replay_to · _schedule_index_at +the history +_record · observe +the progress reporter +set_progress +the statistics channel +_take_step_stats · stats +the noise +_noise_field · _noise_at · _noise_phase +the per-stage bundle +_hoist · _stage_scalars · _delta_phi · _prefactor · +_unitary_phase + +the stepper it drives +stepper.tableau +DOPRI5, Tsit5, the order-condition checks +stepper.controller +PIController: propose, accepts, rejection_factor, +equivalent_norm +stepper.schedule +landing, discover, calibrate, replay +what a backend supplies +_trial_step · _apply_mask · _finish · _begin_plane +· _on_accepted_step + + + +numpy_ref · the oracle +plain NumPy and scipy.fft +one named function per pass +the general representation, the z table included +propagator_mode auto → tabulate +_trial_step · _apply_mask · _finish · _observed_field + + + +native · what auto selects +solprop-native distribution, native/ +C++20 Kokkos + persistent Kokkos-FFT plans +one native call per seven-stage trial +OpenMP on CPUs; CUDA and HIP on GPUs +_trial_step · _apply_mask · _finish · _observed_field +Three things every backend owes beyond the methods: deterministic reductions, the device form of the stage, and the driver's bookkeeping. The error norm is control flow, so +a sum whose association depends on the thread count forks the trajectory. \ No newline at end of file diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 69d5da5c..14d39b91 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -1135,11 +1135,9 @@ class Pass(NamedTuple): ("set_initial", "the state and the noise realisation, host to device, once"), ("set_progress", "the observer each attempted step reports to; never an input"), ("set_stop", "the flag polled between trials, or none"), - ("set_held_plane", "whether the low-storage march holds a field-free plane"), ("attach_monitor", "watch the time window's edges from here on"), ("propagate_to", "the driver's way in, returning a StepSummary"), ("observe", "host arrays: the field at the plane and the step history"), - ("held_observation", "the held plane, for a checkpoint after a failed step"), ( "restore_call_ledgers", "resume cumulative calls; numerical history starts afresh", From 53b6bd99be919794f623b7a0cae04acdd4446dd0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:43:08 +0000 Subject: [PATCH 068/122] Count page faults only where the platform can, in the device benchmark A test imports this module, and on Windows, with no resource module, the import failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- benchmarks/device_split.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/benchmarks/device_split.py b/benchmarks/device_split.py index bb6eef8a..a08b6a83 100644 --- a/benchmarks/device_split.py +++ b/benchmarks/device_split.py @@ -30,7 +30,6 @@ import json import platform import pstats -import resource import statistics import subprocess import sys @@ -140,14 +139,29 @@ def _profile_summary(profile: cProfile.Profile, top: int = 30) -> dict[str, Any] } +def _minor_faults() -> int: + """Return this process's minor page faults so far: none are counted on Windows. + + Returns + ------- + int + The count. + """ + if sys.platform == "win32": + return 0 + import resource # noqa: PLC0415 - not on Windows + + return resource.getrusage(resource.RUSAGE_SELF).ru_minflt + + def _timed(call: Any, repeats: int) -> dict[str, float]: times, faults = [], 0 for _ in range(repeats): - before = resource.getrusage(resource.RUSAGE_SELF).ru_minflt + before = _minor_faults() started = time.perf_counter() value = call() times.append(time.perf_counter() - started) - faults = resource.getrusage(resource.RUSAGE_SELF).ru_minflt - before + faults = _minor_faults() - before del value return { "median_ms": round(1e3 * statistics.median(times), 2), From 90314b8ee0cd30e683441555e59e32f14a1c8848 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 18:50:02 +0000 Subject: [PATCH 069/122] Say what a collar's read does now, and test it in C++ against a plain DFT The figures, documents and fragment catch up with phase 3, the edge's new public names reach the API pages, and the seam figure gains edge_memory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/sep-0044-native-edge.fixed.md | 2 +- docs/dev/backends.md | 7 +- docs/dev/figures/backends.svg | 164 +++++++++++---------- docs/dev/figures/build.py | 1 + docs/guide/setup/running.md | 6 +- native/tests/native/test_general_modal.cpp | 95 ++++++++++++ src/solprop/backends/driver.py | 8 +- src/solprop/transverse_monitor.py | 4 + tests/backends/test_native_edge_reading.py | 5 +- 9 files changed, 199 insertions(+), 93 deletions(-) diff --git a/changelog.d/sep-0044-native-edge.fixed.md b/changelog.d/sep-0044-native-edge.fixed.md index c296ce48..7abb0b8f 100644 --- a/changelog.d/sep-0044-native-edge.fixed.md +++ b/changelog.d/sep-0044-native-edge.fixed.md @@ -1 +1 @@ -The default boundary check no longer brings a native run's state to the host. Without a requested collar, the transverse edge is read where the state lives and only numbers cross, so a field-free run on a device copies no state at all, and on the CPU a free-space Kerr beam's check costs less. A read now covers every frequency plane: `solprop.transverse_monitor.READ_RESOLUTION` is gone, and a share can read higher than before by at most `1e-3 * budget**2` of the launch energy, which is `1e-9` at the default budget, so a state within that of a threshold can change its verdict. A resumed run with a requested collar now restores its current, baselines and peaks, so it no longer judges against zero. (SEP 44, #385, #512) +The default boundary check no longer brings a native run's state to the host. The transverse edge, a requested collar included, is read where the state lives and only numbers cross, so a field-free run on a device copies no state at all, and on the CPU a free-space Kerr beam's check costs less. A read now covers every frequency plane: `solprop.transverse_monitor.READ_RESOLUTION` is gone, and a share can read higher than before by at most `1e-3 * budget**2` of the launch energy, which is `1e-9` at the default budget, so a state within that of a threshold can change its verdict. A resumed run with a requested collar now restores its current, baselines and peaks, so it no longer judges against zero. (SEP 44, #385, #512) diff --git a/docs/dev/backends.md b/docs/dev/backends.md index ed631a7b..01fafd80 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -494,8 +494,11 @@ requested collar at every committed step. The reading is numbers and never the f column's energy and any collar's strip and current. The default forms them with `HostEdgeReduction` on a host copy, the reference, which `numpy_ref` and `mlx` use. `native` forms them on its own state against the edge's `EdgeTables`, four regions' weights on the nodes, so it holds no face, mask or judgement, and a -field-free run crosses no state (`tests/backends/test_native_edge_reading.py`). A requested collar -still reduces a host copy on `native`. Everything else, the weights, the spacing, the ledgers and the judgement, is one +field-free run crosses no state (`tests/backends/test_native_edge_reading.py`). A requested collar's lines are described +once, as `edge_lines`, for both reductions (`tests/backends/test_native_edge_collar.py`): native takes each strip's padded +transform in batches of planes under `STRIP_BATCH_BYTES`, on buffers listed in the `transverse_edge` ledger row, and the +current from the onset's three nodes, rectified per row, all on one load of the transverse scratch. The current is still +accumulated in Python. Everything else, the weights, the spacing, the ledgers and the judgement, is one code on every backend. The read spacing is a ceiling on the step, and a step is not made to land on a read: the edge is read at the last committed position from which the next step, a replay's scheduled one or else the controller's proposal, would diff --git a/docs/dev/figures/backends.svg b/docs/dev/figures/backends.svg index 1a362ad3..0166bbdb 100644 --- a/docs/dev/figures/backends.svg +++ b/docs/dev/figures/backends.svg @@ -1,6 +1,6 @@ + width="1200" height="888.0" viewBox="0 0 1200 888.0"> - + One seam, a shared driver, and the backends behind them backends.md §1: the protocol is drawn at the level of operations on the propagated state, never at the array-namespace level. A backend is constructed from a validated KernelProblem and host arrays and returns host arrays; its buffers, layout and plans are private. - + the seam · backends.protocol.Backend set_initial the state and the noise realisation, host to device, once @@ -83,82 +83,84 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: the flag polled between trials, or none attach_monitor watch the time window's edges from here on -propagate_to -the driver's way in, returning a StepSummary -observe -host arrays: the field at the plane and the step history -restore_call_ledgers -resume cumulative calls; numerical history starts afresh -march_snapshot -the Adams history for a resume, or None -march_snapshot_nbytes -its size, copying nothing -march_export_refusal -why the march cannot carry its history, or None -restore_march -continue the history, or say why it starts afresh -timed_step -one step of the march, for an estimate to time -stats -one fused pass, a dict of small host arrays -metadata -what the execution was: pools, plans, digests, propagator mode -memory_ledger -every named buffer and its size -available_device_memory -free device bytes for batch admission; None on hosts -close -idempotent - - -backends.registry.select(name, problem, grid=, modes=, profiles=) - -backends/driver.py · StepDriver, and no backend reimplements it -the march and the landing -_march_to -the replay -_replay_to · _schedule_index_at -the history -_record · observe -the progress reporter -set_progress -the statistics channel -_take_step_stats · stats -the noise -_noise_field · _noise_at · _noise_phase -the per-stage bundle -_hoist · _stage_scalars · _delta_phi · _prefactor · -_unitary_phase - -the stepper it drives -stepper.tableau -DOPRI5, Tsit5, the order-condition checks -stepper.controller -PIController: propose, accepts, rejection_factor, -equivalent_norm -stepper.schedule -landing, discover, calibrate, replay -what a backend supplies -_trial_step · _apply_mask · _finish · _begin_plane -· _on_accepted_step - - - -numpy_ref · the oracle -plain NumPy and scipy.fft -one named function per pass -the general representation, the z table included -propagator_mode auto → tabulate -_trial_step · _apply_mask · _finish · _observed_field - - - -native · what auto selects -solprop-native distribution, native/ -C++20 Kokkos + persistent Kokkos-FFT plans -one native call per seven-stage trial -OpenMP on CPUs; CUDA and HIP on GPUs -_trial_step · _apply_mask · _finish · _observed_field -Three things every backend owes beyond the methods: deterministic reductions, the device form of the stage, and the driver's bookkeeping. The error norm is control flow, so -a sum whose association depends on the thread count forks the trajectory. +edge_memory +what reading a grid's edge will hold, for an estimate +propagate_to +the driver's way in, returning a StepSummary +observe +host arrays: the field at the plane and the step history +restore_call_ledgers +resume cumulative calls; numerical history starts afresh +march_snapshot +the Adams history for a resume, or None +march_snapshot_nbytes +its size, copying nothing +march_export_refusal +why the march cannot carry its history, or None +restore_march +continue the history, or say why it starts afresh +timed_step +one step of the march, for an estimate to time +stats +one fused pass, a dict of small host arrays +metadata +what the execution was: pools, plans, digests, propagator mode +memory_ledger +every named buffer and its size +available_device_memory +free device bytes for batch admission; None on hosts +close +idempotent + + +backends.registry.select(name, problem, grid=, modes=, profiles=) + +backends/driver.py · StepDriver, and no backend reimplements it +the march and the landing +_march_to +the replay +_replay_to · _schedule_index_at +the history +_record · observe +the progress reporter +set_progress +the statistics channel +_take_step_stats · stats +the noise +_noise_field · _noise_at · _noise_phase +the per-stage bundle +_hoist · _stage_scalars · _delta_phi · _prefactor · +_unitary_phase + +the stepper it drives +stepper.tableau +DOPRI5, Tsit5, the order-condition checks +stepper.controller +PIController: propose, accepts, rejection_factor, +equivalent_norm +stepper.schedule +landing, discover, calibrate, replay +what a backend supplies +_trial_step · _apply_mask · _finish · _begin_plane +· _on_accepted_step + + + +numpy_ref · the oracle +plain NumPy and scipy.fft +one named function per pass +the general representation, the z table included +propagator_mode auto → tabulate +_trial_step · _apply_mask · _finish · _observed_field + + + +native · what auto selects +solprop-native distribution, native/ +C++20 Kokkos + persistent Kokkos-FFT plans +one native call per seven-stage trial +OpenMP on CPUs; CUDA and HIP on GPUs +_trial_step · _apply_mask · _finish · _observed_field +Three things every backend owes beyond the methods: deterministic reductions, the device form of the stage, and the driver's bookkeeping. The error norm is control flow, so +a sum whose association depends on the thread count forks the trajectory. \ No newline at end of file diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 14d39b91..03b01fa5 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -1136,6 +1136,7 @@ class Pass(NamedTuple): ("set_progress", "the observer each attempted step reports to; never an input"), ("set_stop", "the flag polled between trials, or none"), ("attach_monitor", "watch the time window's edges from here on"), + ("edge_memory", "what reading a grid's edge will hold, for an estimate"), ("propagate_to", "the driver's way in, returning a StepSummary"), ("observe", "host arrays: the field at the plane and the step history"), ( diff --git a/docs/guide/setup/running.md b/docs/guide/setup/running.md index 34cfb1b2..5aee0d3e 100644 --- a/docs/guide/setup/running.md +++ b/docs/guide/setup/running.md @@ -548,9 +548,9 @@ instructions, and it refuses a bad input before it is loaded. **The boundary check on a free-space scan.** The default [`boundary_check`](../numerics/boundaries.md) reads a free-space run's transverse edge at the -launch, at every plane and at every read. `native` does it where the state lives, with only numbers -crossing, even on a device. With a requested collar, and on `numpy_ref` and `mlx`, it reads a host copy of the state -instead, and transforms it out of place: on a device that is a whole state's copy at each read. On native +launch, at every plane and at every read, and a requested collar's at every committed step. `native` does it where the +state lives, with only numbers crossing, even on a device. `numpy_ref` and `mlx` read a host copy of the state +instead, and transform it out of place: on a device, a whole state's copy at each read. On native CPU at 256 × 256 × 48, with sixteen steps and two planes at sixteen threads, the check took a point from 1.8 to 2.0 s and from 1.5 to 2.1 volumes above the level before the run. A scan whose window is already settled can still pass `boundary_check=None`. That also turns off the check of the time window's edges, since diff --git a/native/tests/native/test_general_modal.cpp b/native/tests/native/test_general_modal.cpp index e7b0a5e2..0cd7ed43 100644 --- a/native/tests/native/test_general_modal.cpp +++ b/native/tests/native/test_general_modal.cpp @@ -504,6 +504,101 @@ TEST(GeneralModal, TheStateCarriesTheSourceAndItsLedger) { expect_partials_close(second, first); } +// solprop SEP 44: a collar's strip is a windowed transform of a line, padded twice, and +// its current a slope's flux rectified per row. Written out here as a plain DFT and a +// difference, with an identity synthesis, so that each node is its own mode, and with +// one row to a batch and all of them, which only reorders a non-negative sum. +TEST(GeneralModal, AnEdgeLineReadsAPlainTransformAndASlope) { + const auto problem = modal_problem(true, 2, false); + native::GeneralState state(problem); + const auto n = problem.n_omega; + // Rows flowing both ways, so that rectifying a sum would read something else. + std::vector field(kModes * n); + for (std::size_t row = 0; row < n; ++row) { + field[row] = Complex(1.0 + static_cast(row), 0.5); + field[n + row] = Complex(0.25 * static_cast(row), row == 1 ? -1.0 : 1.0); + } + state.set_initial(field, {}); + const auto held = state.field_to_host(); + const std::vector none(4 * kModes, 0.0); + std::vector identity(kModes * kModes, 0.0); + for (std::size_t node = 0; node < kModes; ++node) + identity[node * kModes + node] = 1.0; + state.bind_edge(none, none, none, identity, 0); + + native::EdgeLines lines; + lines.axes = {0}; + lines.counts = {kModes}; + lines.positions = {1, 0}; + lines.windows = {0.5, 0.25}; + lines.inward = {1, 3}; + lines.factors = {2.0}; + lines.measures = {3.0}; + lines.onsets = {0, 0, 1}; + lines.steps = {0.5}; + lines.scale = {1.0, 1.0}; + lines.k_min = 4.0; + + double inward = 0.0; + double windowed = 0.0; + double current = 0.0; + double signed_sum = 0.0; + for (std::size_t row = 0; row < n; ++row) { + const Complex first = held[row]; + const Complex second = held[n + row]; + for (std::size_t bin = 0; bin < 2 * kModes; ++bin) { + const double angle = -2.0 * std::numbers::pi * static_cast(bin) / + static_cast(2 * kModes); + const Complex phase(std::cos(angle), std::sin(angle)); + const Complex value = 0.5 * second + 0.25 * first * phase; + const double power = std::norm(std::complex(value.real(), value.imag())); + windowed += power; + if (bin >= 1 && bin < 3) + inward += power; + } + const Complex slope = (second - first) / 0.5; + const double flux = + (first.real() * slope.imag() - first.imag() * slope.real()) / 4.0; + current += std::max(flux, 0.0); + signed_sum += flux; + } + ASSERT_GT(inward, 0.0); + ASSERT_GT(current, 0.0); + ASSERT_LT(std::max(signed_sum, 0.0), current); + const double tolerance = 64.0 * std::numeric_limits::epsilon() * windowed; + for (const std::size_t batch_bytes : + {2 * kModes * sizeof(Complex), n * 2 * kModes * sizeof(Complex)}) { + lines.batch_bytes = batch_bytes; + state.bind_edge_lines(lines); + const auto numbers = state.edge_reading(false, false, false, true, true); + ASSERT_EQ(numbers.strip.size(), 2u); + EXPECT_NEAR(numbers.strip[0], 2.0 * inward, tolerance); + EXPECT_NEAR(numbers.strip[1], 2.0 * windowed, tolerance); + EXPECT_NEAR(numbers.current, 3.0 * current, tolerance); + } +} + +TEST(GeneralModal, AnEdgeLineThatDoesNotAccountForItsNodesIsRefused) { + const auto problem = modal_problem(true, 2, false); + native::GeneralState state(problem); + native::EdgeLines lines; + lines.axes = {0}; + lines.positions = {0, 1}; + lines.windows = {1.0, 1.0}; + lines.inward = {0, 2}; + lines.factors = {1.0}; + lines.measures = {1.0}; + lines.onsets = {native::EdgeLines::no_onset, 0, 0}; + lines.steps = {1.0}; + lines.batch_bytes = 1024; + lines.counts = {3}; + EXPECT_THROW(state.bind_edge_lines(lines), std::invalid_argument); + lines.counts = {1}; + EXPECT_THROW(state.bind_edge_lines(lines), std::invalid_argument); + lines.counts = {2, 2}; + EXPECT_THROW(state.bind_edge_lines(lines), std::invalid_argument); +} + // solprop SEP 29 G2: the resident Adams history's rows cross to the host and back // losslessly, and a malformed row is refused before anything is copied. TEST(GeneralModal, TheAdamsRowsRoundTripExactly) { diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index e1046f55..ae60d833 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -2028,10 +2028,10 @@ def _edge_clean_columns( ) -> Float[np.ndarray, " n_columns"] | None: """Return each column's launched energy without the input noise, or ``None``. - What the safety ramp is judged on, so a run with no ramp needs none. It is formed - where the load has both parts, which needs no state on the host. Only a resume - that found no record of the monitor has none, and then the state has to cross - once. + What the safety ramp is judged on, so a run with no ramp needs none. It is + formed where the load has both parts, which needs no state on the host. Only a + resume that found no record of the monitor has none, and then the state has to + cross once. Parameters ---------- diff --git a/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py index b616ae1f..88e0bd31 100644 --- a/src/solprop/transverse_monitor.py +++ b/src/solprop/transverse_monitor.py @@ -48,10 +48,14 @@ "GUARD", "NOISE_FACTOR", "READS", + "STRIP_BATCH_BYTES", + "EdgeLine", "EdgeReading", + "EdgeTables", "HostEdgeReduction", "TransverseEdge", "TransverseStatus", + "edge_lines", "transverse_edge_bytes", ] diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index 8a57b631..ef4fe429 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -639,10 +639,11 @@ def test_a_field_grid_reads_as_the_host_does() -> None: budget=1.0, stop_factor=1.0, transverse_budget=1.0 ), ) - for build, space in ( + builders: tuple[tuple[Any, Any], ...] = ( (Simulation.from_radial, RadialGrid(6e-4, 32)), (Simulation.from_free, LineGrid(3e-4, 16, collar_fraction=0)), - ): + ) + for build, space in builders: simulation = build( grid, space, From 9c1d90818f8e6632a31ac37fe8af4d892b98f200 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 19:01:18 +0000 Subject: [PATCH 070/122] Give the one-plane recipe the soliton its test names Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/driver/test_native_march_lifecycle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/driver/test_native_march_lifecycle.py b/tests/driver/test_native_march_lifecycle.py index de5523a3..ab490e35 100644 --- a/tests/driver/test_native_march_lifecycle.py +++ b/tests/driver/test_native_march_lifecycle.py @@ -300,7 +300,7 @@ def test_a_signal_during_a_one_plane_march_stops_it_as_the_native_call_returns( wavelength_limits = ["500 nm", "2 um"] trange = "1 ps" width = "100 fs" -peak_power = "983 W" +peak_power = "1075 W" shape = "sech" n_save = 2 noise = "none" From 3b9037c62a034a0f43072da730fa056e1902ea46 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 19:01:18 +0000 Subject: [PATCH 071/122] Ledger: a native Adams call's return and the stop flag Measured on an Intel Xeon Gold 6240: the poll is 82 ns a return, inside the noise of interleaved pairs on the most return-heavy case, and of `benchmarks ab` against the parent with an A/A control. The returns themselves are unchanged since SEP 39, and cost 0.05 to 0.21 % at the production bound on one-plane windows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 60 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 078a8d2a..4b24d098 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -1192,6 +1192,66 @@ to 70 kernels, most of it launch-bound (`h2_raman_averaged`) and 65 → 4.7 (`radial_filament_256`, 0.1); walls −15 %, −21 %, −4 %, −23 % and −3 %. + +### A native Adams call's return and the stop flag + +A native call returns at a host event or at its bound, 256 attempts or 0.1 s +([SEP 39](seps/archive/sep-0039-native-adams-march.md), Q3), and a run's stop flag is polled +before each call ([#519](https://github.com/LupoLab/solprop/issues/519)). That adds no +return, only a Python call: 56 ns with no flag and 82 ns with a `threading.Event` +(`timeit`). Everything here is on an Intel Xeon Gold 6240 pinned to CPUs of one socket on a +shared machine, load average 4 to 16, GCC 16.2, `-march=cascadelake`, no wisdom file: read +the ratios, not the times. + +**The poll.** Interleaved pairs in one process, 21 rounds, one thread, with a +`threading.Event` as the flag, where returns are most frequent: an N = 8 soliton on 576 +bins, 1 m at `rtol = 1e-8`, whose busy monitor returns at 4780 of its 4783 attempts, 0.90 s. + +| | median | range | +|---|---:|---:| +| poll / no poll | 1.0027 | 0.983–1.058 | +| no poll / no poll | 1.0010 | 0.990–1.020 | + +The difference is inside the control's range; 4780 polls at 82 ns are 0.04 % of the run. + +**Against the parent.** `benchmarks ab`, `--stepper multistep`, `--length 0.2`, nine logical +CPUs, 3 rounds, the same steps and fields to rounding (at most 1.3e-13), median ratio and +range, with an A/A control of the parent against itself: + +| threads | case | candidate / parent | A/A | +|---|---|---:|---:| +| 1 | `gnlse_soliton` | 0.997 [0.957, 1.031] | 1.022 [0.993, 1.025] | +| | `he_rdw_averaged` | 1.025 [0.951, 1.087] | 0.994 [0.887, 1.098] | +| | `argon_blueshift_averaged` | 0.992 [0.817, 1.004] | 1.006 [0.889, 1.011] | +| | `rdw_one_mode` | 1.116 [0.981, 1.355] | 0.995 [0.978, 1.059] | +| 8 | `gnlse_soliton` | 0.990 [0.930, 1.125] | 1.052 [0.885, 1.089] | +| | `he_rdw_averaged` | 1.060 [0.998, 1.263] | 0.982 [0.818, 1.018] | +| | `argon_blueshift_averaged` | 1.025 [1.021, 1.033] | 1.029 [0.995, 1.087] | +| | `rdw_one_mode` | 0.995 [0.869, 1.315] | 1.032 [1.008, 1.065] | +| | geometric mean | 1.024 | 1.014 | + +This machine could not resolve the change. Five rounds of the two highest rows read 0.966 +[0.732, 1.019] (`rdw_one_mode`, 1 thread) and 0.982 [0.820, 1.149] (`he_rdw_averaged`, 8 +threads). + +**What a return costs**, as it has since SEP 39: this change makes none of them. One plane +and no boundary monitor, so nothing but the bound returns the march; a bound of none, the +production bound and a bound of one attempt interleaved in one process, medians of 3 to 5 +rounds of the run's wall time, setup included. One return is the excess of the bound of one +over none, per return: + +| case, threads | bins | attempts | wall, no bound | returns at the bound | one return | their share | +|---|---:|---:|---:|---:|---:|---:| +| N = 2 soliton, 0.3 m, 1 | 512 | 946 | 0.085 s | 4 | 44 µs | 0.21 % | +| N = 8 soliton, 0.1 m, 1 | 576 | 974 | 0.105 s | 4 | 47 µs | 0.18 % | +| the same, interaction form, 1 | 576 | 2046 | 0.186 s | 8 | 29 µs | 0.13 % | +| `gnlse_soliton` (0.3), 1 | 3072 | 285 | 0.143 s | 2 | 63 µs | 0.09 % | +| `he_rdw_averaged` (0.3), 1 | 2561 | 483 | 0.464 s | 4 | 62 µs | 0.05 % | +| `he_rdw_averaged` (0.3), 8 | 2561 | 483 | 0.291 s | 3 | 91 µs | 0.09 % | + +The share is read from the return's cost: comparing the production bound with none directly +reads −2.7 to +1.8 %, which is noise. Where 256 attempts take less than 0.1 s, the share +grows as the grid shrinks, so it passes 0.1 % on grids of about a thousand bins. ### The Adams passes on x86-64 lanes The exponential Adams trial's predict and correct passes on a host block of 32 elements From c8a2f0b13429e52a50e76f1d655af42e7b9db95a Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 19:01:27 +0000 Subject: [PATCH 072/122] Read on-axis intensity's spectra from the far-field slots `on_axis_intensity` summed every column per bin, twice, in a kernel of one work item per bin: 3.5 x 10^8 strided reads at 768 x 768 x 600 on the threads of 600 work items. A separable width also ran it, for an axis fluence nothing read, which is where most of the kernel's share of `beam_radius` went on an Intel Xeon Gold 6240. The far-field pass already forms these sums in team-local bands for `on_axis_spectrum`, so the intensity now transforms that pass's slots on the Cartesian and line kinds. The planar kind's few columns keep their kernel. The native partials now count `on_axis_intensity` as the other backends' do, so `AXIS_SPECTRUM_ONLY` and the `axis_folded` keyword go. The width's magnitude bound takes its square roots apart, so that it can't overflow before the fluence does. Also tests the new kernels' refusals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/native-plane-widths.changed.md | 1 + native/include/solprop_native/far_field.hpp | 8 +++ native/src/far_field.cpp | 22 ++++++ native/src/general.cpp | 77 +++++++++++++-------- native/src/solprop_native/backend.py | 6 +- src/solprop/backends/spatial.py | 23 +----- tests/backends/test_native_far_field.py | 19 ++--- tests/backends/test_native_widths.py | 62 ++++++++++++++++- tests/backends/test_on_axis_statistics.py | 16 +++++ 9 files changed, 170 insertions(+), 64 deletions(-) create mode 100644 changelog.d/native-plane-widths.changed.md diff --git a/changelog.d/native-plane-widths.changed.md b/changelog.d/native-plane-widths.changed.md new file mode 100644 index 00000000..bf514438 --- /dev/null +++ b/changelog.d/native-plane-widths.changed.md @@ -0,0 +1 @@ +On the native backend, `beam_radius` and `fwhm_r` no longer take a time transform on a Cartesian, line or planar grid, and on a Cartesian plane they reduce where the field lives: only a few moments and one value per ring reach the host, not a copy of the fluence at every node. `on_axis_intensity` on a Cartesian or line grid now reads the far-field pass's on-axis sums instead of walking every column per bin. They agree with `numpy_ref` to rounding. A fluence negative by a little more than its rounding budget may now be read as rounding, because the budget's scale is a bound on the reference's, which it reaches when the closure is proportional to the field. `solprop.spatial` gains `FluenceRings`, `fluence_rings` and `ring_fluence_widths`, the parts of `cartesian_fluence_widths` that a backend shares. diff --git a/native/include/solprop_native/far_field.hpp b/native/include/solprop_native/far_field.hpp index 444c07a0..c8713ad7 100644 --- a/native/include/solprop_native/far_field.hpp +++ b/native/include/solprop_native/far_field.hpp @@ -382,6 +382,14 @@ class FarFieldPass { const std::vector& regions, double intensity_factor, const ComplexView2& field, const Inputs& inputs); + /** + * The analytic spectrum of the on-axis amplitude, or of its closure, into the leading + * `n_pol` rows of `spectrum`, from the partials a `run` with `far_field_axis` alone + * left: the bins' sums over the columns, which `spatial_statistics` transforms for + * `on_axis_intensity`. + */ + void axis_spectrum(bool closed, const ComplexView2& spectrum); + /** The bytes of the band partials, the ledger's `far_field_partials` row. */ [[nodiscard]] std::size_t partial_bytes() const noexcept; /** The bytes of the column axes, tables and packed output. */ diff --git a/native/src/far_field.cpp b/native/src/far_field.cpp index 0935c920..3e2666b3 100644 --- a/native/src/far_field.cpp +++ b/native/src/far_field.cpp @@ -231,6 +231,28 @@ std::vector FarFieldPass::run(const std::uint64_t flags, return {host_packed_.data(), host_packed_.data() + packed_size}; } +void FarFieldPass::axis_spectrum(const bool closed, const ComplexView2& spectrum) { + const auto layout = far_field_layout(far_field_axis, 0, 0, n_pol_); + const auto n_bands = far_field_band_count(n_x_, n_y_, layout.n_slots, n_omega_); + const auto partials = partials_; + const auto n = n_omega_; + const auto slots = layout.n_slots; + const auto first = static_cast(layout.axis) + (closed ? 2 * n_pol_ : 0); + const auto n_t = static_cast(n_t_); + const bool envelope = envelope_; + launch::parallel_for( + "solprop_native.far_field.axis_spectrum", Range2(execution_, {0, 0}, {n_pol_, n}), + KOKKOS_LAMBDA(const std::size_t pol, const std::size_t bin) { + double real = 0.0, imag = 0.0; + for (std::size_t band = 0; band < n_bands; ++band) { + real += partials((band * slots + first + 2 * pol) * n + bin); + imag += partials((band * slots + first + 2 * pol + 1) * n + bin); + } + const double scale = (envelope || bin == 0 || bin + 1 == n ? 1.0 : 2.0) / n_t; + spectrum(pol, bin) = scale * Complex(real, imag); + }); +} + void FarFieldPass::accumulate(const FarFieldLayout& layout, const std::size_t n_bands, const std::size_t n_apertures, const std::size_t n_regions, const int exponent, diff --git a/native/src/general.cpp b/native/src/general.cpp index 85a6bafd..2e51e8c4 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -2412,6 +2412,7 @@ class GeneralWorkspace { void separable_time_statistics(std::size_t axis_peak, std::size_t peak, bool fluence, double intensity_factor); void separable_fluence(double intensity_factor); + FarFieldPass::Inputs far_field_inputs() const; void plane_fluence_moments(double (&moments)[plane_moment_slots]); void scale_transform(std::size_t stage, double taper); void synthesise(std::size_t first_node, std::size_t node_count); @@ -13787,26 +13788,35 @@ void GeneralWorkspace::separable_time_statistics(const std::size_t axis_peak, const bool envelope = problem_.family == FieldFamily::envelope; if (axis_peak != absent) { // The axis's closure first, kept in the closure rows, then its field: each the - // coherent on-axis sum of the analytic spectrum (solprop SEP 34). + // coherent on-axis sum of the analytic spectrum (solprop SEP 34). On free space the + // far-field pass forms those sums in its team-local bands. A sum per bin over every + // column, as the slab's few columns take here, is a thread's serial walk across a + // whole state on a device. A plane's on-axis spectrum reads the same slots. + if (far_field_) + static_cast(far_field_->run(far_field_axis, {}, 2, {}, intensity_factor, + current, far_field_inputs())); for (const bool closed : {true, false}) { graph::deep_copy(execution_space_, spectrum, Complex{}); - launch::parallel_for( - "solprop_native.free.statistics_axis_input", - Range2(execution_space_, {0, 0}, {n_pol, n}), - KOKKOS_LAMBDA(const std::size_t pol, const std::size_t index) { - const bool live = active(index) != 0U; - Complex sum{}; - for (std::size_t mode = 0; mode < n_modes; ++mode) { - const Complex value = current(mode * n_pol + pol, index); - sum += axis_weights(mode) * - (closed ? flux.closing(mode, index, live, omega(index)) * value - : value); - } - const double scale = - (envelope || index == 0 || index + 1 == n ? 1.0 : 2.0) / - static_cast(n_t); - spectrum(pol, index) = scale * sum; - }); + if (far_field_) + far_field_->axis_spectrum(closed, spectrum); + else + launch::parallel_for( + "solprop_native.free.statistics_axis_input", + Range2(execution_space_, {0, 0}, {n_pol, n}), + KOKKOS_LAMBDA(const std::size_t pol, const std::size_t index) { + const bool live = active(index) != 0U; + Complex sum{}; + for (std::size_t mode = 0; mode < n_modes; ++mode) { + const Complex value = current(mode * n_pol + pol, index); + sum += axis_weights(mode) * + (closed ? flux.closing(mode, index, live, omega(index)) * value + : value); + } + const double scale = + (envelope || index == 0 || index + 1 == n ? 1.0 : 2.0) / + static_cast(n_t); + spectrum(pol, index) = scale * sum; + }); graph::execute(*statistics_inverse_plan_, statistics_spectrum_, statistics_time_, KokkosFFT::Normalization::none); if (closed) @@ -13978,7 +13988,8 @@ void GeneralWorkspace::separable_fluence(const double intensity_factor) { } } partials(node, 0) = measure * sum; - partials(node, 2) = measure * Kokkos::sqrt(electric_size * magnetic_size); + partials(node, 2) = + measure * Kokkos::sqrt(electric_size) * Kokkos::sqrt(magnetic_size); }); #else // A task takes a run of adjacent nodes and reads each plane's cells of the run in @@ -14009,8 +14020,8 @@ void GeneralWorkspace::separable_fluence(const double intensity_factor) { } for (std::size_t offset = 0; offset < count; ++offset) { partials(first + offset, 0) = measure * sum[offset]; - partials(first + offset, 2) = - measure * Kokkos::sqrt(electric_size[offset] * magnetic_size[offset]); + partials(first + offset, 2) = measure * Kokkos::sqrt(electric_size[offset]) * + Kokkos::sqrt(magnetic_size[offset]); } }); #endif @@ -14244,13 +14255,23 @@ std::vector GeneralWorkspace::aperture_statistics( if (!initialized_ || !far_field_) throw std::logic_error( "far-field statistics need an initial field on a Cartesian or line grid"); - return far_field_->run( - flags, apertures, exponent, regions, intensity_factor, current_, - FarFieldPass::Inputs{spatial_spectral_weights_, spatial_on_axis_, spatial_.square, - spatial_.square_low, spatial_.transverse, - spatial_.transverse_low, spatial_.spectral_rate, - spatial_.user_rate, spatial_.transverse_rate, omega_, - active_mask_, spatial_.evanescent}); + return far_field_->run(flags, apertures, exponent, regions, intensity_factor, + current_, far_field_inputs()); +} + +FarFieldPass::Inputs GeneralWorkspace::far_field_inputs() const { + return FarFieldPass::Inputs{spatial_spectral_weights_, + spatial_on_axis_, + spatial_.square, + spatial_.square_low, + spatial_.transverse, + spatial_.transverse_low, + spatial_.spectral_rate, + spatial_.user_rate, + spatial_.transverse_rate, + omega_, + active_mask_, + spatial_.evanescent}; } std::vector GeneralWorkspace::spatial_statistics(const std::uint64_t flags, diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 1257deea..2b18f680 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -66,7 +66,6 @@ population_statistics, ) from solprop.backends.spatial import ( - AXIS_SPECTRUM_ONLY, FarFieldTables, far_field_partials, fluence_budget, @@ -3715,10 +3714,9 @@ def _native_problem( } if geometry.kind == "free": # solprop SEP 23: the stored axes and the slots of the problem's own - # far-field request, which size the native band partials. The pass folds - # on_axis_spectrum only: on_axis_intensity is spatial_statistics' own. + # far-field request, which size the native band partials. k_x, k_y = geometry.spectral_axes - slots, _ = far_field_partials(problem, axis_folded=AXIS_SPECTRUM_ONLY) + slots, _ = far_field_partials(problem) spatial_data |= { "spatial_k_x": np.ascontiguousarray(k_x, dtype=np.float64), "spatial_k_y": np.ascontiguousarray(k_y, dtype=np.float64), diff --git a/src/solprop/backends/spatial.py b/src/solprop/backends/spatial.py index f3370d28..44b8b398 100644 --- a/src/solprop/backends/spatial.py +++ b/src/solprop/backends/spatial.py @@ -41,7 +41,6 @@ __all__ = [ "AXIS_FOLDED", - "AXIS_SPECTRUM_ONLY", "FAR_FIELD_BAND_BUDGET", "LINE_BLOCK", "FarFieldLayout", @@ -234,14 +233,7 @@ def radial_absorber_matrix(transform: Transform, pad_scale: float) -> np.ndarray """Columns per unit of a line's far-field pass: a plane's unit is a whole row.""" AXIS_FOLDED = frozenset({"on_axis_spectrum", "on_axis_intensity"}) -"""The axis statistics a far-field pass forms from a ``W = 1`` slot, by default.""" - -AXIS_SPECTRUM_ONLY = frozenset({"on_axis_spectrum"}) -"""The axis statistics Kokkos's far-field pass forms from its ``W = 1`` slot. - -Kokkos forms ``on_axis_intensity`` in its own spatial statistics pass, so its slot -count leaves that name out. -""" +"""The axis statistics a far-field pass forms from a ``W = 1`` slot.""" @dataclass(frozen=True) @@ -300,7 +292,6 @@ def far_field_layout( n_apertures: int, n_regions: int, n_pol: int, - axis_folded: Collection[str] = AXIS_FOLDED, ) -> FarFieldLayout | None: """Lay out the slots a compiled far-field pass accumulates for a request. @@ -312,9 +303,6 @@ def far_field_layout( The request's table rows. n_pol : int The state's polarisation components. - axis_folded : collections.abc.Collection of str, optional - The axis statistics that read the ``W = 1`` slot: :data:`AXIS_FOLDED` by - default, :data:`AXIS_SPECTRUM_ONLY` on the native backend. Returns ------- @@ -345,7 +333,7 @@ def far_field_layout( ), ("regions", n_regions if "region_spectrum" in wanted else 0), ("apertures", stride * n_apertures), - ("axis", 4 * n_pol if wanted & set(axis_folded) else 0), + ("axis", 4 * n_pol if wanted & AXIS_FOLDED else 0), ) first: dict[str, int] = {} slots = 0 @@ -402,9 +390,7 @@ def far_field_bands(n_x: int, n_y: int, n_slots: int, n_omega: int) -> np.ndarra return np.minimum(units * unit, n_columns) -def far_field_partials( - problem: KernelProblem, *, axis_folded: Collection[str] = AXIS_FOLDED -) -> tuple[int, int]: +def far_field_partials(problem: KernelProblem) -> tuple[int, int]: """Return the slots and bands a compiled far-field pass holds for a problem. Sized from the problem's own request at construction: its names, tables and @@ -415,8 +401,6 @@ def far_field_partials( ---------- problem : solprop.translate.problem.KernelProblem The translated problem. - axis_folded : collections.abc.Collection of str, optional - The axis statistics the backend's pass forms, as for :func:`far_field_layout`. Returns ------- @@ -434,7 +418,6 @@ def far_field_partials( n_apertures=spec.n_apertures, n_regions=spec.n_regions, n_pol=problem.shape[2], - axis_folded=axis_folded, ) if layout is None: return 0, 0 diff --git a/tests/backends/test_native_far_field.py b/tests/backends/test_native_far_field.py index c770a3c0..99f08128 100644 --- a/tests/backends/test_native_far_field.py +++ b/tests/backends/test_native_far_field.py @@ -41,7 +41,7 @@ from backends.matched import native_thread_fields from bounds import assert_within, gamma from solprop.backends import select -from solprop.backends.spatial import AXIS_SPECTRUM_ONLY, far_field_partials +from solprop.backends.spatial import far_field_partials from solprop.boundaries import SpatialAbsorber, absorber, custom_absorber from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import BackendError @@ -488,23 +488,24 @@ def test_the_native_partials_are_held_to_the_byte(): owner.close() -def test_on_axis_intensity_takes_no_native_slot(): - # Kokkos forms on_axis_intensity in its own spatial pass, so its partials leave the - # W = 1 slot out. +def test_on_axis_intensity_reads_the_native_axis_slots(): + # Its spectra are the W = 1 slot's, so its partials hold them. grid = time_grid("envelope") space = FreeGrid(3e-4, 12, 2.5e-4, 10, collar_fraction=0.1) field = random_field(space, 2, grid) spec = StatsSpec(names=("beam_spectrum", "on_axis_intensity")) problem = problem_of(grid, space, field, spec) - n_slots, n_bands = far_field_partials(problem, axis_folded=AXIS_SPECTRUM_ONLY) - assert n_slots == 1 - # The axis amplitude and its closure, 4 n_pol slots. - assert far_field_partials(problem)[0] == 1 + 4 * 2 + # The beam, then the axis amplitude and its closure, 4 n_pol slots. + n_slots, n_bands = far_field_partials(problem) + assert n_slots == 1 + 4 * 2 owner = select("native", problem, grid=grid) try: + # Held from construction: no statistic allocates them. + held = owner.memory_ledger()["far_field_partials"] + assert held == 8 * n_bands * n_slots * grid.n_omega owner.set_initial(problem.initial.field0, None) values = owner.stats(problem.stats_spec) - assert owner.memory_ledger()["far_field_partials"] == 8 * n_bands * grid.n_omega + assert owner.memory_ledger()["far_field_partials"] == held finally: owner.close() assert values["on_axis_intensity"].shape == (1,) diff --git a/tests/backends/test_native_widths.py b/tests/backends/test_native_widths.py index 6abf818d..d0029843 100644 --- a/tests/backends/test_native_widths.py +++ b/tests/backends/test_native_widths.py @@ -187,12 +187,26 @@ def _check_plane(kind, n_pol, state): grid, problem = _simulation(kind, space, n_pol) assert problem.shape[2] == n_pol field = _state(problem, grid, state) - spec = StatsSpec(names=WIDTHS) + spec = StatsSpec(names=(*WIDTHS, "on_axis_intensity", "peak_intensity")) want = _measure("numpy_ref", grid, problem, field, spec) got = _measure("native", grid, problem, field, spec) x = problem.transform.geometry.axes[0] pixel = float(x[1] - x[0]) - _agree(got, want, _width_budget(grid, problem), pixel, f"{kind} {n_pol} {state}") + what = f"{kind} {n_pol} {state}" + _agree(got, want, _width_budget(grid, problem), pixel, what) + # The axis's coherent sum errs normwise, as a node's synthesis does, so on the + # states that cancel on the axis the scale is the beam's peak, not the axis's value. + # The time transform and the pairing as for the fluence (`_width_budget`), and no + # crossing to interpolate, so without its factor of eight. + peak = float(np.max(want["peak_intensity"])) + for name in ("on_axis_intensity", "peak_intensity"): + assert_within( + got[name], + want[name], + _width_budget(grid, problem) / 8, + scale=peak, + what=f"{what} {name}", + ) @native_only @@ -365,7 +379,7 @@ def test_an_empty_plane_has_no_widths(names): grid, problem = _route("plane") field = np.zeros_like(problem.initial.field0) for backend in ("numpy_ref", "native"): - values = _measure(backend, grid, problem, field, StatsSpec(names=WIDTHS)) + values = _measure(backend, grid, problem, field, StatsSpec(names=names)) for name in WIDTHS: assert np.all(np.isnan(values[name])) @@ -394,6 +408,48 @@ def test_a_negative_lobe_is_refused_on_both(): _measure(backend, grid, problem, field, spec) +@native_only +def test_the_native_trace_needs_a_held_plane_and_real_rings(): + grid, problem = _route("plane") + backend = select("native", problem, grid=grid) + no_rings = np.empty(0) + factor = 0.5 * EPS0 * C + try: + backend.set_initial(problem.initial.field0, None, z=0.0) + state = backend._native_state # pyright: ignore[reportAttributeAccessIssue] + with pytest.raises(RuntimeError, match="follows the call that holds it"): + state.plane_fluence_trace( + 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0, no_rings, no_rings + ) + with pytest.raises(ValueError, match="invalid spatial statistic flags"): + state.spatial_statistics(64, factor, 0.0) + np.ravel(state.spatial_statistics(32, factor, 0.0)) + with pytest.raises(ValueError, match="invalid fluence rings"): + state.plane_fluence_trace( + 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0, no_rings, no_rings + ) + np.ravel(state.spatial_statistics(32, factor, 0.0)) + with pytest.raises(ValueError, match="one-dimensional"): + state.plane_fluence_trace( + 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1, np.ones((1, 8)), np.ones((1, 8)) + ) + finally: + backend.close() + + +@native_only +def test_a_line_has_no_plane_moments(): + grid, problem = _route("line") + backend = select("native", problem, grid=grid) + try: + backend.set_initial(problem.initial.field0, None, z=0.0) + state = backend._native_state # pyright: ignore[reportAttributeAccessIssue] + with pytest.raises(ValueError, match="need a Cartesian plane"): + state.spatial_statistics(32, 0.5 * EPS0 * C, 0.0) + finally: + backend.close() + + def _ring_trace(samples, x, y): """The trace a backend forms: the reference's arithmetic, on the scaled samples.""" diff --git a/tests/backends/test_on_axis_statistics.py b/tests/backends/test_on_axis_statistics.py index 0c6c1b63..c785826b 100644 --- a/tests/backends/test_on_axis_statistics.py +++ b/tests/backends/test_on_axis_statistics.py @@ -423,6 +423,22 @@ def test_the_native_axis_row_leaves_the_state_on_the_device(grid, table, radial_ assert np.all(values["on_axis_peak_ionisation_rate"] > 0) +def test_the_native_row_refuses_a_matrix_that_is_not_one_or_two_rows(grid, table): + pytest.importorskip("solprop_native") + mode = argon() + rule = transverse_quadrature([mode], n_r=5, radial_rule="field_scale") + problem = problem_on(grid, table, [mode], rule) + with closing(select("native", problem, grid=grid, modes=[mode])) as backend: + state = backend._native_state # pyright: ignore[reportAttributeAccessIssue] + with pytest.raises(RuntimeError, match="no initial field"): + state.axis_spectrum(np.ones((1, 1))) + backend.set_initial(problem.initial.field0, None) + with pytest.raises(ValueError, match="at most two rows"): + state.axis_spectrum(np.ones((3, 1))) + with pytest.raises(ValueError, match="two-dimensional"): + state.axis_spectrum(np.ones(1)) + + def test_a_host_backend_s_axis_scan_needs_the_field(grid, table): mode = argon() rule = transverse_quadrature([mode], n_r=5, radial_rule="field_scale") From d57e3def52d80ce91315c043e78c93310c3b5e6d Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 19:04:20 +0000 Subject: [PATCH 073/122] Ledger: say only what the return's table measured Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 4b24d098..67775fb0 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -1250,8 +1250,8 @@ over none, per return: | `he_rdw_averaged` (0.3), 8 | 2561 | 483 | 0.291 s | 3 | 91 µs | 0.09 % | The share is read from the return's cost: comparing the production bound with none directly -reads −2.7 to +1.8 %, which is noise. Where 256 attempts take less than 0.1 s, the share -grows as the grid shrinks, so it passes 0.1 % on grids of about a thousand bins. +reads −2.7 to +1.8 %, which is noise. Where 256 attempts take less than 0.1 s the share +grows as an attempt gets cheaper, and it is above 0.1 % on the two smallest grids here. ### The Adams passes on x86-64 lanes The exponential Adams trial's predict and correct passes on a host block of 32 elements From 17b1a8757f15c43b888674428eeb366fb98d45fb Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 19:04:34 +0000 Subject: [PATCH 074/122] Build the field-grid test's cases by their own constructors Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/backends/test_native_edge_reading.py | 26 ++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index ef4fe429..318f7362 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -639,20 +639,28 @@ def test_a_field_grid_reads_as_the_host_does() -> None: budget=1.0, stop_factor=1.0, transverse_budget=1.0 ), ) - builders: tuple[tuple[Any, Any], ...] = ( - (Simulation.from_radial, RadialGrid(6e-4, 32)), - (Simulation.from_free, LineGrid(3e-4, 16, collar_fraction=0)), - ) - for build, space in builders: - simulation = build( + medium = SpatialMedium("Ar", 1.0) + simulations = [ + Simulation.from_radial( grid, - space, - SpatialMedium("Ar", 1.0), + RadialGrid(6e-4, 32), + medium, [], pulse, settings=settings, noise=None, - ) + ), + Simulation.from_free( + grid, + LineGrid(3e-4, 16, collar_fraction=0), + medium, + [], + pulse, + settings=settings, + noise=None, + ), + ] + for simulation in simulations: problem = simulation.translate() state = _noise_state(problem.shape, np.asarray(problem.grids.active_mask), 79) backend, edge = _attached(simulation, state) From 83adad59ac668f0cfa31666d18cc747b39282727 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 19:10:51 +0000 Subject: [PATCH 075/122] Describe the widths' native route and time its parts The design page and the catalogue say what a width and the axis row now bring across. `python -m benchmarks.device_split` times `fwhm_r`, `on_axis_intensity` and the default spec, and splits a width: its native moments, and what the same azimuthal average takes on the host. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- benchmarks/device_split.py | 32 ++++++++++++++++---- docs/dev/backends.md | 35 +++++++++++++++++----- docs/guide/results/statistics-catalogue.md | 5 ++-- src/solprop/backends/populations.py | 8 ++--- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/benchmarks/device_split.py b/benchmarks/device_split.py index bb6eef8a..04c36d25 100644 --- a/benchmarks/device_split.py +++ b/benchmarks/device_split.py @@ -19,8 +19,9 @@ ``calls``), and writes them to ``FILE`` too when given. ``point`` names each function's calls, own and cumulative seconds, and ``calls`` each crossing's minor page faults. The profiler inflates Python-heavy work, so compare ``walls_s``, which it doesn't run -under. The native backend is required, and a device build is what it's for; on a CPU -build the crossings are plain copies. +under. ``calls`` also splits a width: its native moments, and what the same azimuthal +average takes on the host. The native backend is required, and a device build is what +it's for; on a CPU build the crossings are plain copies. """ from __future__ import annotations @@ -48,8 +49,8 @@ from solprop.nonlinear.responses import KerrResponse from solprop.physdata.glasses import SellmeierGlass from solprop.pulses import GaussianPulse, SpatialPulse -from solprop.spatial import SpatialMedium -from solprop.stats import StatsSpec +from solprop.spatial import SpatialMedium, cartesian_fluence_widths +from solprop.stats import StatsSpec, default_spec from solprop.stepper import FixedSchedule, LowStorageRungeKutta, PIController __all__ = ["main"] @@ -163,13 +164,19 @@ def _calls(backend: Any) -> dict[str, Any]: "energy", "beam_spectrum", "absorbed_power", + "on_axis_intensity", "peak_intensity", "beam_radius", + "fwhm_r", ) } + specs["default"] = default_spec("free") + geometry = backend._problem.transform.geometry + x, y = geometry.axes + state = backend._native_state return { "crossings": { - "field_to_host": _timed(backend._native_state.field_to_host, 3), + "field_to_host": _timed(state.field_to_host, 3), "observe_field": _timed(lambda: backend.observe(field=True), 3), "observe_free": _timed(lambda: backend.observe(field=False), 3), }, @@ -177,6 +184,21 @@ def _calls(backend: Any) -> dict[str, Any]: name: _timed(lambda spec=spec: backend.stats(spec), 5) for name, spec in specs.items() }, + # A width's parts: the fluence and its moments, one native call; and what the + # host's azimuthal average of a Gaussian's fluence takes on this machine. + "widths": { + "moments": _timed( + lambda: state.spatial_statistics(32, 0.5 * EPS0 * C, backend._z), 5 + ), + "host_average": _timed( + lambda: cartesian_fluence_widths( + x, + y, + np.exp(-2 * (x[None, :] ** 2 + y[:, None] ** 2) / 40e-6**2).ravel(), + ), + 3, + ), + }, } diff --git a/docs/dev/backends.md b/docs/dev/backends.md index 1b343251..e0880aab 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -1165,9 +1165,10 @@ column-by-frequency table. The slots (`backends.spatial.far_field_layout`) are t present ones of `[beam, absorbed x 5, regions..., per aperture (collected, Re and Im of the on-axis amplitude per polarisation, then of its closure), on-axis amplitude and closure]`. The last is the `W = 1` slot: `on_axis_spectrum` is formed there, which holds SEP 23's -identity between the two to rounding by construction. `native` forms `on_axis_intensity` in -`spatial_statistics`, not from that slot, so its slot count leaves that name out -(`far_field_partials(problem, axis_folded=AXIS_SPECTRUM_ONLY)`). +identity between the two to rounding by construction. `spatial_statistics` reads `on_axis_intensity`'s +spectra from that slot too (`FarFieldPass::axis_spectrum`) and transforms them, on the Cartesian and +line kinds: a sum per bin over every column would otherwise be a thread's serial walk across a whole +state on a device. The per-element parts mirror the reference's: the radius of `mask_plane_radius` as a protected function (strict on the host; round-to-nearest intrinsics in a device build), so a hard edge @@ -1205,11 +1206,29 @@ spectrum pairs the coherent axis amplitude with its closure; the on-axis intensi and fluence synthesise the closure `G` beside `E` -- on the radial kind through the dense synthesis of a closure time volume (`statistics_weighted_time`), on the separable kinds through a second transverse volume (`flux_closure_transverse`, one state's bytes, the estimate's -`flux_closure_memory`) and the chunk's closure time rows -- and pack each node's signed fluence with -its magnitude `dt sum |E| |G|`, whose largest value fixes the width refusal's normwise budget -(`backends.spatial.fluence_budget`). The closure buffers are allocated at construction when the -problem's own request has a local statistic and, outside any step, when a later request first needs -one. +`flux_closure_memory`) and, for the intensities, the chunk's closure time rows. The largest magnitude +over the nodes fixes the width refusal's normwise budget (`backends.spatial.fluence_budget`). The +closure buffers are allocated at construction when the problem's own request has a local statistic +and, outside any step, when a later request first needs one. + +**A separable fluence takes no time transform.** The reference sums `Re(E conj(G))` over `n_t` +samples; by Parseval's theorem that is a sum over the bins the transverse volumes already hold, +weighted one or four as the energy is (`separable_fluence`). Its magnitude, the reference's +`dt sum_t |E| |G|`, would need the samples, so the budget's scale is the Cauchy-Schwarz bound on it, +`dt sqrt(sum_t |E|^2 sum_t |G|^2)`, formed from the same bins. That is never below the reference's, +and equal where `G` is proportional to `E` at the node, as it is across a beam whose admittance varies +little over its band. Only a negative value between the two budgets would be refused by the reference +and read as rounding here. +The axis's fluence, which only a radial width reads, isn't formed. + +**A Cartesian plane's widths reduce where the field lives** (`plane_fluence_moments`, +`plane_fluence_trace`). The reference forms them on the host from every node's fluence +(`solprop.spatial.cartesian_fluence_widths`): a centroid, a second moment, and an azimuthal average +of the bilinear interpolant on a few thousand angles per ring, about 3.7 million samples at +768 x 768. Native forms each as a kernel over the fluence it holds, so that the moments and one value +per ring cross, and solprop keeps the rule: `solprop.spatial.fluence_rings` fixes the rings, +`backends.spatial.plane_fluence_widths` reads the refusal and the widths. A line's and a planar +fluence are short profiles, and these still cross. ## 4. `native`: the compiled CPU and GPU backend, a second distribution diff --git a/docs/guide/results/statistics-catalogue.md b/docs/guide/results/statistics-catalogue.md index 9a68ba87..2a93199b 100644 --- a/docs/guide/results/statistics-catalogue.md +++ b/docs/guide/results/statistics-catalogue.md @@ -330,9 +330,8 @@ Gauss-Radau node is on the axis. They are opt-in and saved-plane only, and refus name on the spatial routes, a general modal guide, the mode-averaged route, a problem without plasma and the TDSE route (which never evaluates an atom for a statistic). Every backend uses the same host routine for an extra axis scan, or reuses a Radau -axis row from its own node scan. CPU parity is qualified. On a GPU build of the native backend these -statistics require a host copy of the field at each requested save plane; GPU -qualification and a device-only axis scan remain follow-up work +axis row from its own node scan. CPU parity is qualified. A GPU build of the native backend brings +only that row's spectrum to the host; GPU qualification remains follow-up work ([SEP 19](../../dev/seps/archive/sep-0019-field-scale-quadrature.md)). A multimode field's largest density need not lie on the axis, so the node maxima keep their meaning beside them. diff --git a/src/solprop/backends/populations.py b/src/solprop/backends/populations.py index ec6708ad..a538c80a 100644 --- a/src/solprop/backends/populations.py +++ b/src/solprop/backends/populations.py @@ -213,10 +213,10 @@ def axis_populations( The one implementation every backend uses, on the host: the axis is one row per saved plane, against every node of every stage, so its scan costs nothing a backend would compile. A device reads only the row - (:func:`axis_row_populations`). The field is the same as the node fields' -- the same pad - scale, taper and fine grid -- with both Cartesian components of the mode profiles, - as for ``peak_intensity``; when the state carries the components itself, the - profile's magnitude on the axis is the scalar synthesis. The tables, + (:func:`axis_row_populations`). The field is the same as the node fields' -- the + same pad scale, taper and fine grid -- with both Cartesian components of the mode + profiles, as for ``peak_intensity``; when the state carries the components itself, + the profile's magnitude on the axis is the scalar synthesis. The tables, preionisation and running-integral rule are the node scan's. Parameters From cd8d41f58614048724823792712c11a3d34685d4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 19:32:39 +0000 Subject: [PATCH 076/122] Record what a free-space point's setup costs now Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/setup-cost.changed.md | 1 + docs/dev/benchmarks.md | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 changelog.d/setup-cost.changed.md diff --git a/changelog.d/setup-cost.changed.md b/changelog.d/setup-cost.changed.md new file mode 100644 index 00000000..19efbbb9 --- /dev/null +++ b/changelog.d/setup-cost.changed.md @@ -0,0 +1 @@ +Building a large free-space launch is faster, with every bit of the launch as it was: its passes over the beam's elements run in threads, its admittance is formed once, and the transverse residual no longer takes a Python `Fraction` per node. Digesting an array not held in its own order no longer builds a whole volume, so a broadcast input costs a few megabytes. (#493, #347) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 078a8d2a..382c6765 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2493,3 +2493,22 @@ A default radial run of 256 nodes and 8192 frequency bins, which carries input n 83.21 MB of JSON before and 0.04 MB after, and its first hash goes from 2.603 s to 0.077 s. A 64-node grid is about the same before and after, since its arrays sit close to the limit. A run into `MemoryOutput` computes no hash at all. + +## A free-space point's setup + +Building and translating a large free-space launch took longer than the steps of a short point: +the passes over a beam's elements ran on one thread, its admittance ran twice, and the transverse +residual took a Python `Fraction` per node. + +Measured on an Intel Xeon Gold 6240, one socket's 18 cores, 18 threads, on a shared machine, twice each, unprofiled: +a 768 × 768 × 600 envelope launch through a Sellmeier glass with a Kerr response, `e49925d1` against `ece75dd7`. + +| | before (s) | after (s) | +|---|---:|---:| +| `Simulation.from_free` | 169, 179 | 14.9, 14.9 | +| `translate` | 30.3, 27.5 | 7.2, 7.8 | +| `runner` | 3.8, 3.0 | 3.5, 3.3 | + +A launch's bits don't change, which `tests/test_pulses.py` and `tests/test_cartesian_precision.py` hold across +thread counts. The sum that scales the field stays one sum in one order for that reason, so it stays on one thread. + From b77ae789435944272c802360ccf8ddecb449dde9 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 19:40:30 +0000 Subject: [PATCH 077/122] Record the widths' cost, and hold the device's axis and fluence A ledger entry for the width's route: what a width added to `peak_intensity`, the CPU's split, and that no device figure is measured yet. A device's fluence on every separable route and its on-axis intensity are held to the reference by `-m gpu` tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 57 ++++++++++++++++++++++++++++ src/solprop/backends/spatial.py | 3 +- tests/backends/test_native_widths.py | 39 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 078a8d2a..67ca484a 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2170,6 +2170,63 @@ Xeon Gold 6240 (18 cores of one socket, passive threads, three rounds of one rep at one thread and 0.882 at sixteen, `radial_filament_256` 0.999 and 0.994, `line` 0.983 and 0.990, `planar` 0.997 and 0.980; geometric mean 0.975, fields to 4.2e-15, the same work. +### A plane's widths and its axis where the field lives + +`beam_radius` was the costliest statistic on a device by far: about 1.0 s a call on an NVIDIA H200 and 0.9 s on an +AMD Instinct MI325X at 768 x 768 x 600 (`python -m benchmarks.device_split`), against 77 and 86 ms for `peak_intensity` +and about 230 ms for a step. A width ran `peak_intensity`'s kernels and copied 9.4 MiB, so most of that second lay in what +else it did, and it did two things: + +- **A sum per bin over every column.** A separable width formed the axis's fluence, which only a radial width reads, + through the kernel `on_axis_intensity` uses: for each of the 600 bins, twice, one work item walking all 589,824 columns + in turn. It has the shape of the `energy` kernel an earlier entry replaced, a single thread's walk across the plane. +- **The azimuthal average in Python, on one host thread.** About 3.7 million bilinear samples, 0.23 s on an Intel Xeon + Gold 6240. + +On that CPU the two make up about 80 to 90 % of what a width took beyond `peak_intensity` (the table's "before" columns: +the axis's row plus 64 or 240 ms). Both are gone. A width no longer forms the axis's fluence, and `on_axis_intensity`, +which the default spec asks for, reads the same sums from the far-field pass's team-local bands, the slots +`on_axis_spectrum` reads. A separable fluence takes no time transform: by Parseval's theorem it is a sum over the bins +that the transverse volumes already hold. On a Cartesian plane the centroid, the second moment and the rings are +kernels too, so that about 780 values cross at 768 x 768, not 1.18 million. The on-axis plasma statistics read their row +natively, not a copy of the state, and `species_statistics` lost a Python loop over the nodes that took 0.2 s per +species at 768 x 768, with its additions unchanged. + +**None of this is measured on a device.** That these two make up the devices' second is inferred from the CPU's split +and the kernels' shape. `python -m benchmarks.device_split` now reports `on_axis_intensity`, `fwhm_r`, the default spec, a +width's native moments and the same azimuthal average on the host, which will settle it. + +On a CPU: one call, the median of at least three after a warm one, of the launch of that module's Kerr beam, against +`facf2b23`, on an Intel Xeon Gold 6240 shared with other work, in ms: + +| | 768 x 768 x 600, 9 threads, before | after | 384 x 384 x 600, 9 threads, before | after | 192 x 192 x 600, 1 thread, before | after | +|---|---:|---:|---:|---:|---:|---:| +| `energy` | 226 | 208 | 55 | 54 | 114 | 111 | +| `beam_spectrum` | 657 | 600 | 162 | 150 | 307 | 315 | +| `on_axis_spectrum` | 873 | 743 | 193 | 184 | 381 | 388 | +| `absorbed_power` | 741 | 733 | 186 | 181 | 373 | 378 | +| `on_axis_intensity` | 3,224 | 746 | 686 | 194 | 818 | 429 | +| `peak_intensity` | 6,567 | 6,467 | 1,688 | 1,556 | 2,037 | 1,992 | +| `beam_radius` | 10,863 | 3,507 | 2,528 | 788 | 2,861 | 804 | +| `fwhm_r` | 11,119 | 3,532 | 2,501 | 784 | 2,858 | 803 | +| The default spec | 11,341 | 8,437 | 2,606 | 2,061 | 3,448 | 3,040 | + +The two builds' widths of that state agree to 7e-15 at 768 x 768. A whole run moves little, since these take one plane. +`python -m benchmarks ab` against `facf2b23` on the same machine, complete runs, passive threads, one repeat, median and +range over the rounds, the same work and identical fields in every row: + +| case | 1 thread, three rounds | 9 threads | +|---|---:|---:| +| `kerr_beam_192` | 0.969 (0.891–0.988) | 1.004 (0.997–1.019), five rounds | +| `kerr_beam_384` | | 1.015 (0.970–1.193), three rounds | +| `line` | 0.913 (0.879–1.047) | 1.005 (0.948–1.065), five rounds | +| `planar` | 1.022 (0.972–1.028) | 1.030 (0.833–1.084), three rounds | +| `radial_filament_256` | 1.024 (1.000–1.041) | 1.056 (1.014–1.106), three rounds | + +Other work shared the machine during the three-round rows, which is their spread. The five-round rows repeat two of them +when it was quieter, 1.076 and 1.077 before. `planar`'s and `radial_filament_256`'s statistics took only the small change +to their packed output and fluence, and the same comparison before the on-axis change gave 1.003 and 0.999 at 9 threads. + ## Reference tables These tables back the indicative results above. diff --git a/src/solprop/backends/spatial.py b/src/solprop/backends/spatial.py index 44b8b398..b25d319e 100644 --- a/src/solprop/backends/spatial.py +++ b/src/solprop/backends/spatial.py @@ -738,7 +738,8 @@ def fluence_budget( Parameters ---------- magnitude : numpy.ndarray - The fluence magnitude at each node in J/m^2. + The fluence magnitude at each node in J/m^2, or a bound on it from above: a + backend that forms the fluence without a time transform has only the bound. n_terms : int ``n_pol * n_t + n_modes``. diff --git a/tests/backends/test_native_widths.py b/tests/backends/test_native_widths.py index d0029843..3bb6f676 100644 --- a/tests/backends/test_native_widths.py +++ b/tests/backends/test_native_widths.py @@ -312,6 +312,45 @@ def test_a_planar_fluence_is_the_reference_s_at_every_node(monkeypatch): _check_fluence(monkeypatch, "planar", "envelope") +def _check_axis(route): + """One route's on-axis intensity: the far-field slots', or the planar kernel's.""" + grid, problem = _route(route) + field = _field_for(route, grid, problem) + spec = StatsSpec(names=("on_axis_intensity", "peak_intensity")) + want = _measure("numpy_ref", grid, problem, field, spec) + got = _measure("native", grid, problem, field, spec) + # The fluence's budget without the interpolation's factor of eight. + assert_within( + got["on_axis_intensity"], + want["on_axis_intensity"], + _width_budget(grid, problem) / 8, + scale=float(np.max(want["peak_intensity"])), + what=f"{route} axis", + ) + + +@native_only +@pytest.mark.parametrize("route", ["plane", "plane_scalar", "line", "planar"]) +def test_the_on_axis_intensity_is_the_reference_s(route): + _check_axis(route) + + +@pytest.mark.gpu +@native_only +@pytest.mark.parametrize("route", ["plane", "plane_scalar", "line", "planar"]) +def test_a_device_s_on_axis_intensity_is_the_reference_s(route): + _require_device() + _check_axis(route) + + +@pytest.mark.gpu +@native_only +@pytest.mark.parametrize("route", ["plane", "plane_scalar", "line", "planar"]) +def test_a_device_s_fluence_is_the_reference_s_at_every_node(monkeypatch, route): + _require_device() + _check_fluence(monkeypatch, route, "envelope") + + class _NoState: """A native state that refuses to bring its field across.""" From e024b45e1975c34ec610c6323a4e7b4660fd52c7 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 19:49:17 +0000 Subject: [PATCH 078/122] Type the width tests' pulse and geometry Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/backends/test_native_widths.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/backends/test_native_widths.py b/tests/backends/test_native_widths.py index 3bb6f676..69e0be3b 100644 --- a/tests/backends/test_native_widths.py +++ b/tests/backends/test_native_widths.py @@ -73,10 +73,11 @@ def _simulation(kind, space, n_pol, medium=LOSSY, waist=60e-6): integrator=RungeKutta(), stats=StatsSpec(names=WIDTHS), ) - pulse = SpatialPulse( - GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), - waist, - **({"jones": (1, 0.5j)} if n_pol == 2 else {}), + temporal = GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9) + pulse = ( + SpatialPulse(temporal, waist, jones=(1, 0.5j)) + if n_pol == 2 + else SpatialPulse(temporal, waist) ) simulation = Simulation.from_free( grid, space, medium, [], pulse, settings=settings, noise=None @@ -190,7 +191,9 @@ def _check_plane(kind, n_pol, state): spec = StatsSpec(names=(*WIDTHS, "on_axis_intensity", "peak_intensity")) want = _measure("numpy_ref", grid, problem, field, spec) got = _measure("native", grid, problem, field, spec) - x = problem.transform.geometry.axes[0] + geometry = problem.transform.geometry + assert geometry is not None + x = geometry.axes[0] pixel = float(x[1] - x[0]) what = f"{kind} {n_pol} {state}" _agree(got, want, _width_budget(grid, problem), pixel, what) From 5c11e207ce1471598537cd5392d3e002ccf0f306 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:02:16 +0000 Subject: [PATCH 079/122] Close what an independent read of the collar's reading found No number was wrong. Binding a strip's plan now holds the GIL, as FFTW's planner needs against another thread's backend being built. A strip's plan is measured into the ledger's plan rows, a monitor with no collar no longer leaves a strip's buffers held, and one allocation serves every shape, which is the 256 MiB the SEP states rather than twice it. Tests that passed whatever native read now read a beam whose current is above rounding, and what the ramp judges is compared directly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/bindings.cpp | 3 +- native/src/general.cpp | 90 ++++++++++++++++------ native/tests/native/test_general_modal.cpp | 8 ++ src/solprop/transverse_monitor.py | 11 ++- tests/backends/test_native_edge_collar.py | 81 ++++++++++++++----- 5 files changed, 145 insertions(+), 48 deletions(-) diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index 88d97147..ba33d4f4 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -2904,7 +2904,8 @@ NB_MODULE(_core, module_) { lines.k_min = k_min; lines.scale = real_vector(scale); lines.batch_bytes = batch_bytes; - const nb::gil_scoped_release release; + // Holding the GIL: a strip's plan goes through FFTW's planner, which the + // GIL serialises against another thread's backend being built. self.bind_edge_lines(lines); }, "axes"_a.noconvert(), "counts"_a.noconvert(), "positions"_a.noconvert(), diff --git a/native/src/general.cpp b/native/src/general.cpp index ce726815..df61c4b7 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -2198,9 +2198,7 @@ class GeneralWorkspace { for (const auto& line : edge_lines_) total += line.positions.size() * sizeof(std::size_t) + line.window.size() * sizeof(double); - for (const auto& [shape, batch] : edge_batches_) - total += batch.buffer.size() * sizeof(Complex); - return total; + return total + edge_strip_.size() * sizeof(Complex); } // The bytes through this state's crossings since it was built, each way (solprop SEP // 44). @@ -2926,9 +2924,11 @@ class GeneralWorkspace { struct EdgeBatch { ComplexView2 buffer; std::unique_ptr plan; + std::size_t heap_growth{}, device_growth{}; }; std::vector edge_lines_; std::map, EdgeBatch> edge_batches_; + ComplexView edge_strip_; RealView edge_scale_; double edge_k_min_{}; // A large crossing's pinned chunks, once one has been made (`crossing.hpp`). @@ -14826,9 +14826,14 @@ void GeneralWorkspace::bind_edge_lines(const EdgeLines& lines) { require_extent(lines.onsets.size(), 3 * n_lines, "edge line onsets"); require_extent(lines.steps.size(), n_lines, "edge line steps"); require_extent(lines.windows.size(), lines.positions.size(), "edge line windows"); + if (std::accumulate(lines.counts.begin(), lines.counts.end(), std::size_t{0}) != + lines.positions.size()) + throw std::invalid_argument("the edge's lines don't account for their nodes"); const auto planes = problem_.n_pol_state * problem_.n_omega; - edge_lines_.clear(); - edge_k_min_ = lines.k_min; + // A runner's next point reuses the plans of its shapes, and only those stay held. + decltype(edge_batches_) batches; + std::vector bound; + bound.reserve(n_lines); std::size_t offset = 0; for (std::size_t index = 0; index < n_lines; ++index) { EdgeLine line; @@ -14847,40 +14852,68 @@ void GeneralWorkspace::bind_edge_lines(const EdgeLines& lines) { line.before = lines.onsets[3 * index + 1]; line.after = lines.onsets[3 * index + 2]; if (line.nodes != 0) { - const auto begin = lines.positions.begin() + static_cast(offset); - const auto end = begin + static_cast(line.nodes); - if (offset + line.nodes > lines.positions.size()) - throw std::invalid_argument("the edge's lines overrun their nodes"); + const auto first = static_cast(offset); + const auto last = static_cast(offset + line.nodes); line.positions = IndexView("solprop_native.edge.line_nodes", line.nodes); line.window = RealView("solprop_native.edge.line_window", line.nodes); copy_vector(execution_space_, line.positions, - std::vector(begin, end)); + std::vector(lines.positions.begin() + first, + lines.positions.begin() + last)); copy_vector(execution_space_, line.window, - std::vector( - lines.windows.begin() + static_cast(offset), - lines.windows.begin() + - static_cast(offset + line.nodes))); + std::vector(lines.windows.begin() + first, + lines.windows.begin() + last)); offset += line.nodes; const std::size_t count = 2 * line.nodes; line.batch = std::min(line.rows, std::max(1, lines.batch_bytes / (sizeof(Complex) * count))); - auto& batch = edge_batches_[{line.batch, count}]; - if (batch.buffer.extent(0) == 0) { - batch.buffer = - ComplexView2(Kokkos::view_alloc(Kokkos::WithoutInitializing, - std::string("solprop_native.edge.strip")), - line.batch, count); + } + bound.push_back(std::move(line)); + } + // The strips run in turn, so one allocation serves every shape: a positive and a + // negative face's lines commonly differ by a node, and would otherwise hold two. + std::size_t largest = 0; + for (const auto& line : bound) + largest = std::max(largest, line.batch * 2 * line.nodes); + if (edge_strip_.size() != largest) + edge_strip_ = + largest == 0 + ? ComplexView{} + : ComplexView(Kokkos::view_alloc(Kokkos::WithoutInitializing, + std::string("solprop_native.edge.strip")), + largest); + for (const auto& line : bound) { + if (line.nodes == 0) + continue; + const std::size_t count = 2 * line.nodes; + const std::pair shape{line.batch, count}; + if (!batches.contains(shape)) { + EdgeBatch batch; + if (auto kept = edge_batches_.extract(shape); kept) + batch = std::move(kept.mapped()); + // Not tracked: `edge_strip_` owns the memory, and outlives every shape's view. + batch.buffer = ComplexView2(edge_strip_.data(), line.batch, count); + if (!batch.plan) { + // A plan's own memory is measured as the workspace's are, and listed with them. + const auto heap_before = heap_bytes(); + const auto device_before = device_free_bytes(); constexpr int along = -1; batch.plan = std::make_unique(fft_space_for(batch.buffer.size()), batch.buffer, batch.buffer, KokkosFFT::Direction::forward, along); + execution_space_.fence("solprop_native edge strip plan"); + const auto heap_after = heap_bytes(); + const auto device_after = device_free_bytes(); + batch.heap_growth = heap_after > heap_before ? heap_after - heap_before : 0; + batch.device_growth = + device_before > device_after ? device_before - device_after : 0; } + batches.emplace(shape, std::move(batch)); } - edge_lines_.push_back(std::move(line)); } - if (offset != lines.positions.size()) - throw std::invalid_argument("the edge's lines don't account for their nodes"); + edge_lines_ = std::move(bound); + edge_batches_ = std::move(batches); + edge_k_min_ = lines.k_min; if (lines.scale.empty()) { edge_scale_ = {}; } else { @@ -15400,6 +15433,12 @@ std::map GeneralWorkspace::memory_ledger() const { } std::map GeneralWorkspace::base_memory_ledger() const { + std::size_t edge_plan_heap = 0; + std::size_t edge_plan_device = 0; + for (const auto& [shape, batch] : edge_batches_) { + edge_plan_heap += batch.heap_growth; + edge_plan_device += batch.device_growth; + } const auto complex_bytes = sizeof(Complex); const auto real_bytes = sizeof(double); const auto byte_bytes = sizeof(unsigned char); @@ -15667,8 +15706,8 @@ std::map GeneralWorkspace::base_memory_ledger() const {"statistics_outputs", (statistics_total_power_.size() + statistics_on_axis_.size() + statistics_packed_.size()) * real_bytes}, - {"fft_plan_heap_growth", plan_heap_growth_}, - {"fft_plan_device_growth", plan_device_growth_}, + {"fft_plan_heap_growth", plan_heap_growth_ + edge_plan_heap}, + {"fft_plan_device_growth", plan_device_growth_ + edge_plan_device}, }; } @@ -15905,6 +15944,7 @@ void GeneralWorkspace::close() noexcept { occupancy_count_ = 0; crossing_ring_.release(); edge_batches_.clear(); + edge_strip_ = {}; edge_lines_.clear(); edge_scale_ = {}; edge_faces_ = {}; diff --git a/native/tests/native/test_general_modal.cpp b/native/tests/native/test_general_modal.cpp index 0cd7ed43..81c1ef35 100644 --- a/native/tests/native/test_general_modal.cpp +++ b/native/tests/native/test_general_modal.cpp @@ -576,6 +576,14 @@ TEST(GeneralModal, AnEdgeLineReadsAPlainTransformAndASlope) { EXPECT_NEAR(numbers.strip[1], 2.0 * windowed, tolerance); EXPECT_NEAR(numbers.current, 3.0 * current, tolerance); } + // A monitor with no collar after one with: the strip's buffers don't stay held. + const auto with_lines = state.memory_ledger().at("transverse_edge"); + native::EdgeLines none_at_all; + none_at_all.batch_bytes = 1024; + state.bind_edge_lines(none_at_all); + EXPECT_LT(state.memory_ledger().at("transverse_edge"), + with_lines - n * 2 * kModes * sizeof(Complex) + 1); + EXPECT_TRUE(state.edge_reading(false, false, false, true, false).strip.empty()); } TEST(GeneralModal, AnEdgeLineThatDoesNotAccountForItsNodesIsRefused) { diff --git a/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py index 88e0bd31..8e4cd1b6 100644 --- a/src/solprop/transverse_monitor.py +++ b/src/solprop/transverse_monitor.py @@ -118,7 +118,9 @@ class EdgeReading: clip : numpy.ndarray or None ``EdgeTables.clip @ nodes``, for a launch. totals : numpy.ndarray or None - ``EdgeTables.totals @ nodes``, for a launch or a collar's read. + ``EdgeTables.totals @ nodes``, for a launch or a collar's read. A collar's read + uses only the rate, ``[1]``: over the rows a read touches, which hold all of it, + the other three can be a different sum on different backends. columns : numpy.ndarray or None Each spectral column's ``sum |E|^2``, unweighted, shape ``(n_columns,)``; ``None`` when only the current was asked for. @@ -523,13 +525,14 @@ def transverse_edge_bytes( lines = edge_lines(space, design) if isinstance(space, RadialGrid) and lines: total += 8 * space.n_r # the scale of the radial line's values - buffers: set[tuple[int, int]] = set() + # The strips run in turn, on one buffer as large as the largest batch needs. + largest = 0 for line in lines: total += 16 * line.strip.size if line.strip.size: rows = line_rows(space, line, n_pol, n_omega) - buffers.add((strip_batch(line, rows), 2 * line.strip.size)) - return total + sum(16 * rows * count for rows, count in buffers) + largest = max(largest, strip_batch(line, rows) * 2 * line.strip.size) + return total + 16 * largest def _radial_inverse(space: RadialGrid) -> Float[np.ndarray, "n_r n_r"]: diff --git a/tests/backends/test_native_edge_collar.py b/tests/backends/test_native_edge_collar.py index c2228f8c..5c3270a4 100644 --- a/tests/backends/test_native_edge_collar.py +++ b/tests/backends/test_native_edge_collar.py @@ -44,6 +44,7 @@ PIController, RungeKutta, ) +from solprop.transverse_monitor import TransverseEdge pytestmark = pytest.mark.skipif( "native" not in available(), reason="native is not installed" @@ -60,6 +61,20 @@ """Fixed steps over the tiny cases: short enough for the collar's stability limit.""" +INWARD: dict[str, Any] = { + "waist": 60e-6, + "propz": -2.4e-2, + "boundary_check": BoundaryCheck(budget=1.0, stop_factor=1.0, transverse_budget=1.0), +} +"""A converging beam reaching the collar, whose current is inward and above rounding. + +A narrow beam's current is 1e-30 and a comparison of it passes whatever it reads. +""" + +CURRENT_FLOOR = 1e-14 +"""Well above rounding in a share of the launch, and what `INWARD`'s current clears.""" + + def _settings(*, planar: bool = False, **overrides: Any) -> SimulationSettings: """Return settings for a linear run with a requested collar on native.""" keywords: dict[str, Any] = { @@ -222,6 +237,16 @@ def test_native_reads_a_collars_strip_and_current_as_the_host_does( assert host.current > 0.0 assert native.current == pytest.approx(host.current, rel=64 * budget) np.testing.assert_allclose(native.totals, host.totals, rtol=budget) + # A later read uses only the collar's rate, from the rows a read can touch. + backend, edge = _attached(simulation, state) + try: + read = backend._edge_reading(backend._require_state(), edge) + finally: + backend.close() + assert read.totals[1] > 0.0 + assert read.totals[1] == pytest.approx( + edge.reduction.reading(state[0]).totals[1], rel=budget + ) def test_a_plane_wave_reads_its_exact_current() -> None: @@ -398,6 +423,7 @@ def final() -> tuple[bytes, dict[str, Any]]: ) simulation = _case( route, + **INWARD, integrator=march, schedule=schedule, save_planes=[LENGTH], @@ -435,10 +461,15 @@ def final() -> tuple[bytes, dict[str, Any]]: host, host_record = final() assert native == host assert record["reads"] == host_record["reads"] >= STEPS // 2 - # The same numbers by two reducers: their rounding, on a beam far from the edge. - assert record["current"] == pytest.approx( - host_record["current"], rel=1e-8, abs=1e-15 - ) + # The same numbers by two reducers: their rounding. A planar launch can't converge, + # so its current stays at rounding, and only its absolute size is left to hold. + if route == "planar": + assert record["current"] == pytest.approx(host_record["current"], abs=1e-15) + else: + assert host_record["current"] > CURRENT_FLOOR + assert record["current"] == pytest.approx( + host_record["current"], rel=1e-8, abs=0.0 + ) @pytest.mark.parametrize("route", ROUTES) @@ -546,7 +577,9 @@ def build() -> Simulation: schedule = FixedSchedule.uniform( 0.0, LENGTH, LENGTH / STEPS, save_planes=[LENGTH / 2, LENGTH] ) - return _case("radial", integrator=march, schedule=schedule, controller=None) + return _case( + "radial", **INWARD, integrator=march, schedule=schedule, controller=None + ) with warnings.catch_warnings(): warnings.simplefilter("ignore", BoundaryWarning) @@ -560,12 +593,14 @@ def build() -> Simulation: expected = whole.outcome.execution["boundary_monitor"]["transverse"] found = cast("Any", resumed).execution["boundary_monitor"]["transverse"] assert found["launch_energy"] == pytest.approx(expected["launch_energy"], rel=1e-12) - # Field rounding, carried through a share of a share: nothing's restarted. - assert expected["current"] > 0.0 - assert found["current"] == pytest.approx(expected["current"], rel=1e-8) - assert found["strip_max"] == pytest.approx(expected["strip_max"], rel=1e-8) + # Field rounding, carried through a share of a share: nothing's restarted. Each is + # well above rounding, so that one read as zero would fail here. + for key in ("current", "strip_max"): + assert expected[key] > CURRENT_FLOOR, key + assert found[key] == pytest.approx(expected[key], rel=1e-8, abs=0.0), key + assert expected["current_baseline"] > CURRENT_FLOOR assert found["current_baseline"] == pytest.approx( - expected["current_baseline"], rel=1e-12 + expected["current_baseline"], rel=1e-12, abs=0.0 ) @@ -648,12 +683,8 @@ def test_a_collar_run_matches_numpy_ref_on_a_matched_schedule( def run(backend: str) -> tuple[np.ndarray, dict[str, Any]]: simulation = _case( route, - waist=60e-6, - propz=-2.4e-2, + **INWARD, backend=backend, - boundary_check=BoundaryCheck( - budget=1.0, stop_factor=1.0, transverse_budget=1.0 - ), integrator=march, schedule=FixedSchedule.uniform( 0.0, LENGTH, LENGTH / steps, save_planes=[LENGTH / 2, LENGTH] @@ -675,8 +706,8 @@ def run(backend: str) -> tuple[np.ndarray, dict[str, Any]]: # A planar launch can't converge, so its current stays at rounding: what's left to # hold there is its absolute size. if route != "planar": - assert reference["current"] > 1e-14 - assert reference["spatial_removed"] > 1e-14 + assert reference["current"] > CURRENT_FLOOR + assert reference["spatial_removed"] > CURRENT_FLOOR for key in ("current", "spatial_removed", "strip_max"): assert native[key] == pytest.approx(reference[key], rel=1e-8, abs=1e-15), key @@ -694,7 +725,17 @@ def test_a_supplied_unmasked_draw_judges_as_the_host_does_with_no_transfer( route: str, monkeypatch: pytest.MonkeyPatch ) -> None: # The ramp judges the launch without its noise, which is where the load has both - # parts. A draw that's been masked would hide the mask's own part of that. + # parts. A masked draw would hide the mask's own part of that. The record + # can't show it unless the stop's reached, so this looks at what's judged. + judge = TransverseEdge._judge_ramp_band + judged: list[np.ndarray] = [] + + def seen(self: Any, columns: np.ndarray, *args: Any, **kwargs: Any) -> None: + judged.append(np.array(columns)) + judge(self, columns, *args, **kwargs) + + monkeypatch.setattr(TransverseEdge, "_judge_ramp_band", seen) + def launch() -> tuple[dict[str, Any], int]: simulation = _case(route, noise="input") problem = simulation.translate() @@ -730,6 +771,10 @@ def launch() -> tuple[dict[str, Any], int]: for key in ("launch_energy", "noise_allowance", "noise_share_in_ramp"): assert native[key] == pytest.approx(host[key], rel=1e-11, abs=1e-30), key assert native["noise_share_in_ramp"] > 0.0 + # A column's own rounding is that of adding the draw and taking it away again. + assert len(judged) == 2 + assert np.max(judged[0]) > 0.0 + np.testing.assert_allclose(judged[0], judged[1], rtol=1e-10, atol=1e-30) def _host_clean_columns(self: Any, edge: Any) -> np.ndarray | None: From 0212bdf0dc0e333699fa803fecf35233af04f30a Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:19:52 +0000 Subject: [PATCH 080/122] Split a free-space envelope's collar in place on the state The temporal split collar copied each chunk of the state's columns into a coarse buffer, transformed it, and copied it back: 106 chunks of 5 kernels a step at 768 x 768 x 600. The factor depends on time alone, so the pair now runs in place on the state's own rows, a block at a time, its normalisation with the factor. A device keeps the 106 blocks here, without the two copies, and no coarse buffers are held. Part of #361. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/backends.md | 7 +- native/src/general.cpp | 111 ++++++++++++--- tests/backends/test_free_split_rows.py | 181 +++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 19 deletions(-) create mode 100644 tests/backends/test_free_split_rows.py diff --git a/docs/dev/backends.md b/docs/dev/backends.md index 1b343251..95be0af5 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -706,7 +706,8 @@ rows (§4), none-normalised, on a persistent plan made once per state at construction: the modal pair (complex on the envelope grid, real on the field grid); the doubled-grid Raman pairs, with their kernel's plan and the plans of the last, partial chunk at its actual width; the coarse pair of the split-step temporal -absorber and the forward plan of ADR-025's in-stage absorber term, whose buffers are the +absorber (on a free-space envelope, an in-place pair on the state's own rows, with no buffer +of its own) and the forward plan of ADR-025's in-stage absorber term, whose buffers are the run's own because the stage's finish still reads the polarisation's spectrum when the term is formed; the held and envelope currents' streams; the general route's family (§3.8); and the transverse plans of the free and planar kinds. A stage allocates nothing. @@ -1418,7 +1419,9 @@ analytic map synthesises its retained spectrum there too (the gather and the pad per element, the chunk's padded rows zeroed first): the product covers `n_omega` complex columns rather than `n_to`, which halves the binary64 work that bounds a radial trial. On free space the split-step collar acts on the stored transverse-spectrum columns (every build), and a device forms its factor once a step per -sample. A free-space envelope whose material response is the Kerr term alone, with one +sample. A free-space envelope's collar transforms those columns in place, a block of rows at +a time, through no coarse buffer; the transform's normalisation goes with the factor. A +free-space envelope whose material response is the Kerr term alone, with one polarisation, on a time grid the spectrum fills (`n_t = n_to = n_omega`, the crop the identity) takes its stage core on a device through one rank-3 transform of the scratch volume each way, forming the polarisation in place between them, instead of the chunks' diff --git a/native/src/general.cpp b/native/src/general.cpp index 17d3e5c7..fb5a4209 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -2512,8 +2512,10 @@ class GeneralWorkspace { void form_split_factor(double dz); void split_columns(double dz, std::size_t active_rows = 0, double parseval = 0.0, std::size_t first_column = 0); + void split_state_rows(double dz, const ComplexView2& state); void add_split_removal(double dz, std::size_t active_rows, double parseval, - std::size_t first_column); + std::size_t first_column, const ComplexView2& complex_time, + double power_scale); void separable_to_time(const TransverseChunk& chunk); void separable_to_frequency(const TransverseChunk& chunk); void separable_split_step(double dz, const ComplexView2& values); @@ -2849,6 +2851,11 @@ class GeneralWorkspace { std::unique_ptr absorber_real_plan_; std::unique_ptr absorber_complex_plan_; std::unique_ptr coarse_inverse_real_plan_; + // A free-space envelope's split collar transforms the state's own rows in place, this + // many at a time, and holds no coarse buffers (`split_state_rows`). + std::size_t split_rows_{}; + std::unique_ptr split_inverse_plan_, split_forward_plan_; + std::unique_ptr split_tail_inverse_plan_, split_tail_forward_plan_; std::unique_ptr coarse_inverse_complex_plan_; std::unique_ptr coarse_forward_real_plan_; std::unique_ptr coarse_forward_complex_plan_; @@ -4120,14 +4127,19 @@ void GeneralWorkspace::allocate() { #if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) split_factor_ = RealView("solprop_native.absorber.split_factor", problem_.n_t); #endif - coarse_spectrum_ = - ComplexView2("solprop_native.absorber.coarse_spectrum", temporal_columns, n); - if (problem_.family == FieldFamily::envelope) - coarse_time_complex_ = ComplexView2("solprop_native.absorber.coarse_time_complex", - temporal_columns, problem_.n_t); - else - coarse_time_real_ = RealView2("solprop_native.absorber.coarse_time_real", - temporal_columns, problem_.n_t); + if (problem_.free_geometry() && problem_.family == FieldFamily::envelope) { + split_rows_ = temporal_columns; + } else { + coarse_spectrum_ = + ComplexView2("solprop_native.absorber.coarse_spectrum", temporal_columns, n); + if (problem_.family == FieldFamily::envelope) + coarse_time_complex_ = + ComplexView2("solprop_native.absorber.coarse_time_complex", + temporal_columns, problem_.n_t); + else + coarse_time_real_ = RealView2("solprop_native.absorber.coarse_time_real", + temporal_columns, problem_.n_t); + } } if (problem_.general_modal) { general_ = std::make_unique(problem_); @@ -4628,6 +4640,30 @@ void GeneralWorkspace::plan() { } if (!problem_.split_step_absorber) return; + if (split_rows_ != 0) { + // In place on the state's rows; a later execution may name another row block or the + // other of the state's two views, of the same extents. + const auto& state = problem_.low_storage ? current_ : advanced_; + const auto plan_rows = [&](const std::size_t count, + const KokkosFFT::Direction direction) { + const ComplexView2 rows = + Kokkos::subview(state, std::make_pair(std::size_t{0}, count), Kokkos::ALL()); + const auto& space = fft_space_for(rows.size()); +#if !defined(KOKKOS_ENABLE_CUDA) && !defined(KOKKOS_ENABLE_HIP) + // Seeding may overwrite the rows; nothing is loaded yet. + seed_row_fft_wisdom(space, rows, rows, direction); + graph::deep_copy(execution_space_, rows, Complex{}); +#endif + return std::make_unique(space, rows, rows, direction, axis); + }; + split_inverse_plan_ = plan_rows(split_rows_, KokkosFFT::Direction::backward); + split_forward_plan_ = plan_rows(split_rows_, KokkosFFT::Direction::forward); + if (const auto tail = problem_.n_columns() % split_rows_; tail != 0) { + split_tail_inverse_plan_ = plan_rows(tail, KokkosFFT::Direction::backward); + split_tail_forward_plan_ = plan_rows(tail, KokkosFFT::Direction::forward); + } + return; + } if (problem_.family == FieldFamily::envelope) { coarse_inverse_complex_plan_ = std::make_unique( fft_space_for(coarse_time_complex_.size()), coarse_spectrum_, @@ -14369,14 +14405,15 @@ namespace { template void split_apply(const char* label, const ExecutionSpace& execution, const TimeView& time, const RealView& rate, const RealView& factors, - const double dz, const std::size_t rows, const std::size_t samples) { + const double dz, const std::size_t rows, const std::size_t samples, + const double scale = 1.0) { #if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) static_cast(rate); static_cast(dz); launch::parallel_for( label, Range2(execution, {0, 0}, {rows, samples}), KOKKOS_LAMBDA(const std::size_t row, const std::size_t index) { - time(row, index) *= factors(index); + time(row, index) *= scale * factors(index); }); #else static_cast(factors); @@ -14396,6 +14433,8 @@ void split_apply(const char* label, const ExecutionSpace& execution, for (std::size_t sample = 0; sample < count; ++sample) factor[sample] = -0.5 * rate(first + sample) * dz; vmath::exp(factor, factor, count); + for (std::size_t sample = 0; sample < count; ++sample) + factor[sample] *= scale; for (std::size_t row = row_first; row < row_last; ++row) for (std::size_t sample = 0; sample < count; ++sample) time(row, first + sample) *= factor[sample]; @@ -14417,7 +14456,8 @@ void GeneralWorkspace::split_columns(const double dz, std::size_t active_rows, graph::execute(*coarse_inverse_complex_plan_, coarse_spectrum_, coarse_time_complex_, KokkosFFT::Normalization::none); if (monitoring_split()) - add_split_removal(dz, active_rows, parseval, first_column); + add_split_removal(dz, active_rows, parseval, first_column, coarse_time_complex_, + 1.0); split_apply("solprop_native.absorber.split_apply_complex", execution_space_, coarse_time_complex_, absorber_coarse_, split_factor_, dz, active_rows, problem_.n_t); @@ -14433,7 +14473,7 @@ void GeneralWorkspace::split_columns(const double dz, std::size_t active_rows, graph::execute(*coarse_inverse_real_plan_, coarse_spectrum_, coarse_time_real_, KokkosFFT::Normalization::none); if (monitoring_split()) - add_split_removal(dz, active_rows, parseval, first_column); + add_split_removal(dz, active_rows, parseval, first_column, {}, 1.0); split_apply("solprop_native.absorber.split_apply_real", execution_space_, coarse_time_real_, absorber_coarse_, split_factor_, dz, active_rows, problem_.n_t); @@ -14445,6 +14485,32 @@ void GeneralWorkspace::split_columns(const double dz, std::size_t active_rows, } } +// Free space's complex rows: the split's factor depends on time alone, so it acts alike +// on every column of any transverse basis, and the temporal pair runs in place on the +// state's own rows, a block of them at a time. Nothing is copied to a coarse buffer and +// back, the transform's normalisation goes with the factor, and the band's mask is the +// one pass after the blocks. Equal to the reference's split in real space up to the +// transforms' rounding, at any block size. +void GeneralWorkspace::split_state_rows(const double dz, const ComplexView2& state) { + const double inverse_time = 1.0 / static_cast(problem_.n_t); + const auto columns = problem_.n_columns(); + for (std::size_t first = 0; first < columns; first += split_rows_) { + const auto rows = std::min(split_rows_, columns - first); + const ComplexView2 block = + Kokkos::subview(state, std::make_pair(first, first + rows), Kokkos::ALL()); + const bool tail = rows != split_rows_; + graph::execute(tail ? *split_tail_inverse_plan_ : *split_inverse_plan_, block, + block, KokkosFFT::Normalization::none); + if (monitoring_split()) + add_split_removal(dz, rows, 0.0, first, block, inverse_time * inverse_time); + split_apply("solprop_native.absorber.split_apply_complex", execution_space_, block, + absorber_coarse_, split_factor_, dz, rows, problem_.n_t, inverse_time); + graph::execute(tail ? *split_tail_forward_plan_ : *split_forward_plan_, block, + block, KokkosFFT::Normalization::none); + } + mask(state); +} + // What one split takes of the power it finds in the coarse time rows, `sum P (1 - // exp(-alpha h))` with the boundary monitor's column weights, added to the run's // accumulator in the memory space (solprop SEP 21, ADR-057): the monitor's reading @@ -14454,10 +14520,13 @@ void GeneralWorkspace::split_columns(const double dz, std::size_t active_rows, // monitor judges the occupancies on: the symmetric splitting's state at the step's // end. A separable route's rows are real-space rows, measured by `parseval`; zero // means the rows are the stored columns from `first_column` on, weighted by their -// quadrature. +// quadrature. `complex_time` holds a complex route's rows, whose power `power_scale` +// brings to the state's measure: the state's own rows carry no transform normalisation. void GeneralWorkspace::add_split_removal(const double dz, const std::size_t active_rows, const double parseval, - const std::size_t first_column) { + const std::size_t first_column, + const ComplexView2& complex_time, + const double power_scale) { const auto rate = absorber_coarse_; const auto share = monitor_share_; const auto half = monitor_half_; @@ -14475,13 +14544,13 @@ void GeneralWorkspace::add_split_removal(const double dz, const std::size_t acti const auto guards = monitor_weights_; const auto samples = Range2(execution_space_, {0, 0}, {active_rows, problem_.n_t}); if (problem_.family == FieldFamily::envelope) { - const auto time = coarse_time_complex_; + const auto time = complex_time; launch::parallel_reduce( "solprop_native.monitor.split_removal_complex", samples, KOKKOS_LAMBDA(const std::size_t row, const std::size_t index, SplitSums& update) { const double power = (stored ? weights(first_column + row) : parseval) * - squared_abs(time(row, index)); + (power_scale * squared_abs(time(row, index))); update.values[0] += power * share(index); const double found = half(index) * power; update.values[1] += found; @@ -14761,6 +14830,10 @@ void GeneralWorkspace::bind_occupancy(const std::vector& buckets, // y rows of slab amplitudes) between the two transverse transforms. void GeneralWorkspace::separable_split_step(const double dz, const ComplexView2& advanced) { + if (split_rows_ != 0) { + split_state_rows(dz, advanced); + return; + } if (problem_.free_geometry()) { // The split's factor depends on time alone, so it acts alike on every column of // any transverse basis (solprop's reference measures its removal on the stored @@ -15358,6 +15431,10 @@ void GeneralWorkspace::close() noexcept { absorber_real_plan_.reset(); absorber_complex_plan_.reset(); coarse_inverse_real_plan_.reset(); + split_inverse_plan_.reset(); + split_forward_plan_.reset(); + split_tail_inverse_plan_.reset(); + split_tail_forward_plan_.reset(); coarse_inverse_complex_plan_.reset(); coarse_forward_real_plan_.reset(); coarse_forward_complex_plan_.reset(); diff --git a/tests/backends/test_free_split_rows.py b/tests/backends/test_free_split_rows.py new file mode 100644 index 00000000..2064fc50 --- /dev/null +++ b/tests/backends/test_free_split_rows.py @@ -0,0 +1,181 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A Cartesian envelope's split collar in place on the state, at any block of rows. + +The factor depends on time alone, so native transforms the state's own rows, a block at +a time, and holds no coarse buffers. The block is a memory and launch choice: at any +size each march takes the reference's steps to its fields and its monitor record, within +a rounding budget fixed before the comparison. +""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +import numpy as np +import pytest + +from backends.low_storage_cases import parity_bound +from bounds import assert_within, device_backend, ladder_bound +from capability_routes import LENGTH, STEP +from monitor_cases import MARCHES, STEPS, as_driver, watched +from solprop.driver import Simulation, SimulationSettings +from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, TimeGrid +from solprop.monitor import BoundaryCheck +from solprop.nonlinear.responses import KerrResponse +from solprop.physdata import gamma3 +from solprop.pulses import GaussianPulse, SpatialPulse +from solprop.spatial import SpatialMedium +from solprop.stepper import FixedSchedule, PIController +from solprop.translate.problem import KernelProblem + +pytest.importorskip("solprop_native") + +BLOCKS = [1, 5, None] +"""Node chunks: one row at a time, one that leaves a shorter last block (192 columns +hold no multiple of 5), and the default, every row at once.""" + +BLOCK_IDS = ["one row", "with a tail", "every row"] + +MONITORED = ("raw_removed_fraction", "removed_fraction", "collar_max", "guard_high_max") +"""What the monitor reads of the split: its own removal and what it found.""" + + +def _beam(grid: TimeGrid, march: object | None = None) -> Simulation: + """Return a Kerr beam on a 12 x 16 plane under the split collar. + + The route's case of ``tests/capability_routes.py``, on the given grid; with a march, + on a fixed schedule, and the monitor relaxed: :func:`monitor_cases.watched` launches + a copy near the window's edge on purpose. + """ + settings = SimulationSettings( + controller=PIController(rtol=1e-6, atol=1e-12), + save_planes=[LENGTH], + dz_max=STEP, + dz_init=STEP, + backend="numpy_ref", + spatial_absorber=False, + absorber_scheme="split_step", + boundary_check=BoundaryCheck(budget=1.0, stop_factor=1.0), + **( + {} + if march is None + else { + "integrator": march, + "schedule": FixedSchedule.uniform( + 0.0, LENGTH, LENGTH / STEPS, save_planes=[LENGTH] + ), + } + ), + ) + return Simulation.from_free( + grid, + FreeGrid(3e-4, 12, 4e-4, 16, collar_fraction=0), + SpatialMedium("Ar", 1.0), + [KerrResponse(gamma3("Ar", wavelength=800e-9), thg=False)], + SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 80e-6), + settings=settings, + ) + + +ENVELOPE = EnvelopeGrid.from_limits( + LENGTH, 800e-9, (600e-9, 1.2e-6), 150e-15, thg=False +) + + +def _blocked(simulation: Simulation, chunk: int | None) -> KernelProblem: + """Return the simulation's problem at a node chunk, or at its own.""" + problem = simulation.translate() + if chunk is None: + return problem + return dataclasses.replace( + problem, transform=dataclasses.replace(problem.transform, node_chunk=chunk) + ) + + +def _marched( + simulation: Simulation, name: str, problem: KernelProblem +) -> tuple[np.ndarray, dict[str, Any], dict[str, int]]: + """Return the state, the monitor's record and the ledger after the fixed march.""" + backend = watched(simulation, name, problem) + try: + backend.propagate_to(LENGTH) + driver = as_driver(backend) + monitor = driver._monitor + assert monitor is not None + field = np.array(driver._observed_field(driver._require_state())) + return field, monitor.summary(), backend.memory_ledger() + finally: + backend.close() + + +@pytest.fixture(scope="module", params=sorted(MARCHES)) +def reference(request: pytest.FixtureRequest) -> tuple[str, np.ndarray, dict[str, Any]]: + """Return a march's name and the reference's state and record on the beam.""" + simulation = _beam(ENVELOPE, MARCHES[request.param]) + field, record, _ = _marched(simulation, "numpy_ref", simulation.translate()) + return request.param, field, record + + +def _check_block( + reference: tuple[str, np.ndarray, dict[str, Any]], chunk: int | None +) -> None: + """Compare the native march at a node chunk with the reference's.""" + march, expected, theirs = reference + simulation = _beam(ENVELOPE, MARCHES[march]) + got, ours, ledger = _marched(simulation, "native", _blocked(simulation, chunk)) + # The low-storage bound of four evaluations a step, scaled to the seven of the + # longest tableau; the split's pair and product are less than one evaluation's, and + # its factor's size is inside the bound's margin. A device build adds its + # transforms' declared envelope, eight round trips a step at most. A reading sums + # squares of the same samples, at twice the state's relative rounding. + problem = simulation.translate() + bound = ladder_bound( + "native", + 7 / 4 * parity_bound(problem, transverse=problem.transform.n_nodes), + simulation.grid.n_t, + 8 * STEPS, + ) + assert_within(got, expected, bound, what=f"{march} at node chunk {chunk}") + assert theirs["raw_removed_fraction"] > 1e-6 + for name in MONITORED: + assert ours[name] == pytest.approx(theirs[name], rel=2 * bound), name + # The rows are the state's own: no spectrum or time buffer of the split's. + assert ledger["split_absorber_spectrum"] == 0 + assert ledger["split_absorber_time"] == 0 + + +@pytest.mark.parametrize("chunk", BLOCKS, ids=BLOCK_IDS) +def test_a_block_of_any_size_takes_the_reference_s_steps( + reference: tuple[str, np.ndarray, dict[str, Any]], chunk: int | None +) -> None: + _check_block(reference, chunk) + + +@pytest.mark.gpu +@pytest.mark.parametrize("chunk", BLOCKS, ids=BLOCK_IDS) +def test_a_device_block_of_any_size_takes_the_reference_s_steps( + reference: tuple[str, np.ndarray, dict[str, Any]], chunk: int | None +) -> None: + if device_backend("native") == "none": + pytest.skip("the installed solprop-native build has no accelerator backend") + _check_block(reference, chunk) + + +def test_real_rows_keep_their_buffers() -> None: + # A real time row is not a complex spectrum's row: a field grid takes the chunked + # pair through the coarse buffers still. + grid = FieldGrid.from_limits(LENGTH, 800e-9, (600e-9, 1.2e-6), 150e-15) + simulation = _beam(grid) + problem = simulation.translate() + assert problem.grid_kind == "field" + backend = watched(simulation, "native", problem) + try: + ledger = backend.memory_ledger() + finally: + backend.close() + assert ledger["split_absorber_spectrum"] > 0 + assert ledger["split_absorber_time"] > 0 From 96548daa5cbe8aa3eac919a5aa03dd921fea1bbf Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:22:08 +0000 Subject: [PATCH 081/122] Cover or drop the lines coverage found MemoryOutput's copy for a field outside its block had no caller: every field is written in its row first. A real spectrum's conversion and an empty state's check get a test each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/output/memory.py | 6 ++---- tests/backends/test_backend_protocol.py | 6 ++++++ tests/test_fourier.py | 12 ++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/solprop/output/memory.py b/src/solprop/output/memory.py index 7025e5ea..1da7a87c 100644 --- a/src/solprop/output/memory.py +++ b/src/solprop/output/memory.py @@ -343,15 +343,13 @@ def _append( plane : SavedPlane The plane, whose field is still in the internal convention. stored : numpy.ndarray or None - That field converted to the optics convention, normally its row of the - block already; ``None`` on a field-free plane, which stores none. + That field converted to the optics convention, in its row of the block + (:meth:`_stored_field`); ``None`` on a field-free plane, which stores none. """ self._make_room(plane) index = self._n_saved self._z[index] = plane.z if stored is not None: - if stored.base is not self._fields: - self._fields[self._n_fields] = stored if self._noise_phase is not None: self._noise_phase[self._n_fields] = plane.noise_phase self._n_fields += 1 diff --git a/tests/backends/test_backend_protocol.py b/tests/backends/test_backend_protocol.py index ef4cc0f1..4ff3be96 100644 --- a/tests/backends/test_backend_protocol.py +++ b/tests/backends/test_backend_protocol.py @@ -14,6 +14,7 @@ Observation, StepStats, StepSummary, + protocol, ) from solprop.exceptions import BackendError from solprop.log import NullReporter @@ -375,3 +376,8 @@ def set_initial(self, field, noise): return None assert not isinstance(Partial(), Backend) + + +@pytest.mark.parametrize("shape", [(0, 4), (2, 0)]) +def test_an_empty_state_is_finite(shape: tuple[int, int]) -> None: + assert protocol._all_finite(np.empty(shape, dtype=np.complex128)) diff --git a/tests/test_fourier.py b/tests/test_fourier.py index 64f81186..0b774482 100644 --- a/tests/test_fourier.py +++ b/tests/test_fourier.py @@ -86,6 +86,18 @@ def test_the_spectrum_helpers_add_the_alternating_sign_to_the_conjugation(kind): ) +@pytest.mark.parametrize("kind", KINDS) +def test_a_real_spectrum_converts_without_writing_on_itself(kind): + # A real array can't hold the origin's shift, so the product needs its own room. + spectrum = make_spectrum(kind, 16, seed=7).real + original = spectrum.copy() + sign = np.array([(-1.0) ** k for k in range(spectrum.size)]) + assert np.array_equal( + fourier.spectrum_to_optics(spectrum, kind=kind), sign * original + ) + assert np.array_equal(spectrum, original) + + @pytest.mark.parametrize("kind", KINDS) def test_a_spectrum_converted_into_room_is_the_same_bits(kind): spectrum = make_spectrum(kind, 16, seed=5) From fb31e983e9aebf18deefd11dfdfc4b5538075dc8 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:23:21 +0000 Subject: [PATCH 082/122] WIP: phase 3's report, and what coverage and the docs build found The record's numbers follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/seps/sep-0044-native-edge-read.md | 48 +++++++++++++++++++++- src/solprop/transverse_monitor.py | 25 +++++------ tests/backends/test_native_edge_collar.py | 41 ++++++++++++++++++ tests/driver/test_estimate.py | 8 ++++ 4 files changed, 107 insertions(+), 15 deletions(-) diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index e40d07b6..59af5beb 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -15,7 +15,7 @@ | **Requires** | [SEP 40](archive/sep-0040-transverse-absorbing-boundaries.md) | | **Supersedes** | — | | **Superseded-by** | — | -| **Implementation** | Claude Opus 5.5, on `feature/sep-0044-native-edge`: phases 1 and 2 done; phase 3 next | +| **Implementation** | Claude Opus 5.5, on `feature/sep-0044-native-edge`: phases 1, 2 and 3 done; device qualification next | | **Manual pages** | — | ## Part I — Proposal @@ -453,6 +453,52 @@ The prototype §6 measured, carried onto this branch. It stacks on [#518](https: **Open.** None for the maintainer. +### Phase 3 — 2026-11-04 + +A requested collar's strip and current are read natively, so a field-free collar run brings no state to the host. It stacks as phases 1 and 2 do. + +**What landed.** `EdgeLine` and `edge_lines`, one description of each line that both reductions read, so they differ only in their arithmetic. `bind_edge_lines`, and in `edge_reading` the strip and the current on one load of the transverse scratch, through two kernels, `edge_strip_line` and `edge_current_line`, which are one code on every build. A padded transform runs in batches of planes under `STRIP_BATCH_BYTES`. The current is read at every commit and accumulated in Python, so the host step stays one. `estimate` now counts a collar's edge. + +**Deviations from Part I.** + +- §6 allocates the strip's buffer at a collar's first read. It's at the bind, since a launch reads at once and the ledger row is then right from attach. +- The whole runs are in `tests/backends/test_native_edge_collar.py`, not `test_transverse_backends.py`, whose module also runs without native. +- There's no device-shaped kernel, so §7's rule about one has nothing to hold: the kernels that run on a device are the ones the CPU tests call. The GTest, `AnEdgeLineReadsAPlainTransformAndASlope`, holds them to a plain DFT and a difference written in the test. +- §6's memory is per shape in effect: a positive and a negative face's lines commonly differ by a node, so the first version held two buffers, twice the stated cap. One allocation now serves every shape, which is what §6 states. +- A strip plan's own memory is measured and joins the `fft_plan_*` rows, not `transverse_edge`. So the formula `estimate` uses doesn't hold it, as it doesn't hold any other plan's. + +**Found on the way.** + +- A launch with an input draw and no ramp brought a state to the host, for clean columns nothing reads. `test_a_launch_with_noise_judges_as_the_host_does_with_no_transfer` found it. Those runs now cross nothing. +- A fresh agent, given the code and not my conclusions, read it for faults and found no wrong number. It did find a race, since binding a strip's plan released the GIL that serialises FFTW's planner, and a test that couldn't fail: the resumed-collar test's beam had a current of 2e-30 against `pytest.approx`'s default absolute tolerance, so it passed with #512 put back. Both are fixed. The tests comparing a current now use a converging beam, whose current clears `1e-14`, with no absolute tolerance. A planar launch can't converge, so its current is held only to an absolute size. + +**Verification, on CPU.** __CPU_VERIFIED__ + +Seven deliberate breaks of the collar's modules, each caught: `resume` doing nothing (3 tests), `resume` zeroing the current (3), clean columns zeroed (3) or left noisy (4), and a change of one part in a million to the current (35), the strip (14) or the collar's rate (9). + +§7's list, now run: DC, Nyquist and inactive bins; `n_to != n_t` radially, with a Kerr response on a field grid, on every route with padding; a planar `p != 0`; the smallest node chunk each route allows; an almost-zero current; mixed inward and outward frequencies; field grids at the reading level; input, modified and no noise, with and without a ramp, and a supplied unmasked draw; launch-only interruptions, a resume with and without a monitor's record, an interruption between reads and a snapshot's resume on all three marches; and no interference on Runge-Kutta, split and in-stage, both Adams formulations and low-storage with and without its stage register, which a test holds to what each case takes. `numpy_ref`'s binary32 path has its existing test. Exact parity holds only on the matched-schedule witnesses, against `numpy_ref`, on all four routes and two marches. + +**On a device.** __DEVICE__ Neither the NVIDIA H200 nor the AMD Instinct MI325X has run this, so §7's gates remain unmeasured; that's phase 4, which needs a new approval for rented cards. + +**Cost.** One collar's read, on 384 x 384 x 600 with $f = 1$, on one state, field-free, native against the host's reduction of a host copy: + +| | Step | Current, each commit | Read with its strip and regions | +|---|---:|---:|---:| +| Intel Xeon Gold 6240, 18 cores, 18 threads | 2.97 s | 116 ms against 7.36 s | 0.94 s against 41.9 s | +| NVIDIA A40, 16 host threads | 0.70 s | 36 ms against 1.54 s | 204 ms against 7.98 s | + +At each commit the current is then about 4 to 5 % of a step, where it was more than a step. A read with its strip is about 30 % of a step, at each of the run's few reads. Radially, on 256 nodes and 600 bins at 18 threads: the current 0.06 ms against 5.7 ms, a read 8.0 ms against 19.4 ms. + +SEP 40's four-way control, `python -m benchmarks.edge_overhead`, native at 18 threads, 5 rounds, default check against no ramp and no reads, with no collar: `line` +15.8 % (its calls rise from 196 to 224, the read ceiling's), `planar` +7.6 %, `radial_filament_256` +4.1 %, `kerr_beam_192` +8.3 %. These match the prototype's within their spread. Against `main`: __AB__ + +**Memory.** __MEMORY__ + +**A runner's points.** §6 suggested sharing the tables across points. At 512 x 512 x 600 a point's monitor takes 21 ms and its bind 16 ms, against a step of 1.76 s, so I left it. + +**Not yet done.** Phase 4's device qualification; the rest of §8's documents and the final ledger entry, phase 5. + +**Open.** Whether to approve a rental for phase 4. + ## Part III — History - 2026-11-04: Drafted. Tracking issue: [#514](https://github.com/LupoLab/solprop/issues/514). diff --git a/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py index 8e4cd1b6..c5c6b03f 100644 --- a/src/solprop/transverse_monitor.py +++ b/src/solprop/transverse_monitor.py @@ -444,7 +444,7 @@ def edge_lines( """The most a batch of a strip's padded transforms holds on the state's memory space.""" -def line_rows( +def _line_rows( space: RadialGrid | FreeGrid | PlanarGrid, line: EdgeLine, n_pol: int, n_omega: int ) -> int: """Return how many rows of values a line carries. @@ -472,7 +472,7 @@ def line_rows( return planes * (space.n_y if line.axis == "x" else space.n_x) -def strip_batch(line: EdgeLine, rows: int) -> int: +def _strip_batch(line: EdgeLine, rows: int) -> int: """Return how many of a line's rows one batch of its padded transform takes. Parameters @@ -530,8 +530,8 @@ def transverse_edge_bytes( for line in lines: total += 16 * line.strip.size if line.strip.size: - rows = line_rows(space, line, n_pol, n_omega) - largest = max(largest, strip_batch(line, rows) * 2 * line.strip.size) + rows = _line_rows(space, line, n_pol, n_omega) + largest = max(largest, _strip_batch(line, rows) * 2 * line.strip.size) return total + 16 * largest @@ -886,7 +886,6 @@ class TransverseEdge: _current_last: tuple[float, float] | None = field(default=None, init=False) _current_baseline: float = field(default=0.0, init=False) _returned_peak: float = field(default=0.0, init=False) - _read_rows: np.ndarray | None = field(default=None, init=False) _kappa_order: np.ndarray = field(init=False) tables: EdgeTables = field(init=False) reduction: HostEdgeReduction = field(init=False) @@ -950,15 +949,13 @@ def _rows_read(self) -> np.ndarray: numpy.ndarray A mask over the nodes. """ - if self._read_rows is None: - rows = np.zeros(self._weights.size, dtype=bool) - for face in self._faces: - rows |= face.edge - rate = self._node_rate() - if rate is not None: - rows |= rate > 0.0 - self._read_rows = rows - return self._read_rows + rows = np.zeros(self._weights.size, dtype=bool) + for face in self._faces: + rows |= face.edge + rate = self._node_rate() + if rate is not None: + rows |= rate > 0.0 + return rows def _node_rate(self) -> Float[np.ndarray, " n_nodes"] | None: """Return a requested collar's power rate on the nodes (or rows), 1/m. diff --git a/tests/backends/test_native_edge_collar.py b/tests/backends/test_native_edge_collar.py index 5c3270a4..1a975039 100644 --- a/tests/backends/test_native_edge_collar.py +++ b/tests/backends/test_native_edge_collar.py @@ -812,3 +812,44 @@ def test_a_resume_with_no_monitor_record_crosses_once() -> None: assert backend._native_state.crossed_bytes()[0] == state.nbytes finally: backend.close() + + +def test_a_strip_of_too_few_nodes_reads_nothing_on_either_side( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # One line too short to read a direction beside the real ones: it holds no buffer and + # its pair is zero, in the same place in the reading. + simulation = _case("line") + problem = simulation.translate() + state = _noise(problem, 101) + lines = transverse_monitor.edge_lines + + def with_an_empty_line(*args: Any) -> tuple[Any, ...]: + found = lines(*args) + short = np.array([1.0, 2.0, 3.0]) + return (*found, transverse_monitor._edge_line("x", short, 1.0, (0.0, 4.0), 2.0)) + + monkeypatch.setattr(transverse_monitor, "edge_lines", with_an_empty_line) + backend, edge = _attached(simulation, state) + try: + read = backend._edge_reading(backend._require_state(), edge, full=True) + held = edge.reduction.reading(state[0], full=True) + _, _, n_pol, n_omega = problem.shape + assert backend.memory_ledger()["transverse_edge"] == ( + transverse_monitor.transverse_edge_bytes( + edge.space, edge.design, n_pol, n_omega + ) + ) + finally: + backend.close() + assert read.strip.shape == held.strip.shape + assert read.strip[-1].tolist() == held.strip[-1].tolist() == [0.0, 0.0] + assert np.all(read.strip[:-1, 1] > 0.0) + + +def test_a_strip_too_short_for_any_inward_bin_reads_only_its_window() -> None: + # Direction needs |q| >= 16 / s. Four nodes span three spacings, so that's beyond + # Nyquist's pi / h, and the strip has a window and no inward range. + line = transverse_monitor._edge_line("x", np.arange(8.0), 1.0, (2.0, 5.0), 6.0) + assert line.strip.size == 4 + assert line.inward == (0, 0) diff --git a/tests/driver/test_estimate.py b/tests/driver/test_estimate.py index fbbe3bbc..f6081ccf 100644 --- a/tests/driver/test_estimate.py +++ b/tests/driver/test_estimate.py @@ -52,6 +52,14 @@ def test_the_estimate_reports_what_the_backend_holds_and_costs(backend): assert estimate.total_calls is None +def test_an_estimate_adds_nothing_for_an_edge_read_on_the_host(): + # `numpy_ref` reads a copy it has anyway, so the check holds no more memory. + simulation = _built("line", "numpy_ref") + checked = simulation.estimate().memory + simulation.settings = dataclasses.replace(simulation.settings, boundary_check=None) + assert simulation.estimate().memory == checked + + def test_the_output_size_counts_only_the_retained_fields(): simulation = _built("soliton", "numpy_ref") every = simulation.estimate().output_bytes From ae82a2a9b9a30ffd52300945df8a377dff37a82b Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:28:44 +0000 Subject: [PATCH 083/122] Take a device's split collar a whole state at a time The block of rows the in-place collar transforms holds no buffer, so it costs launches and little else: cuFFT's work area at 768 x 768 x 600 grows by 20 MiB, beside the 102 MiB of coarse buffers the last commit dropped. A device build now takes every row at once, 14 kernels and 10 transforms a step where it took 119 and 220 at the node chunk. The adapter chooses: translation imports no device runtime and records only whether the caller stated a node_budget, which wins. A CPU keeps the node chunk, where FFTW's threaded plan of a whole state was 29 % slower. This commit alone changes a device run's default plan memory, so it can be dropped on its own. Part of #361. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/kernel-contract.md | 1 + docs/guide/performance/tuning.md | 3 +- native/include/solprop_native/core.hpp | 5 ++ native/src/bindings.cpp | 7 +++ native/src/general.cpp | 9 +++- native/src/solprop_native/_core.pyi | 2 + native/src/solprop_native/backend.py | 18 +++++++ src/solprop/driver/simulation.py | 3 +- src/solprop/translate/modal.py | 5 ++ src/solprop/translate/problem.py | 8 ++- tests/backends/test_free_split_rows.py | 73 +++++++++++++++++++++++++- 11 files changed, 128 insertions(+), 6 deletions(-) diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md index 73aed4e4..f9304eb5 100644 --- a/docs/dev/kernel-contract.md +++ b/docs/dev/kernel-contract.md @@ -599,6 +599,7 @@ sections and refuse a `"cf4"` section by name | `projection_coarse` | same | the embedded coarse-rule projection for the error estimate | | `taper_scale` | `ZProfile` table or `None` | the scalar rescaling of synthesis/projection along z for scale-invariant modes | | `node_chunk` | int | nodes per chunk for the response pass | +| `chunk_budgeted` | bool | whether the caller's own `node_budget` set `node_chunk`: without one, a device takes a free-space envelope's split collar a whole state at a time, the one block it holds no buffer for | | `aeff` | `ZProfile` table | effective area for mode-averaged normalisation (`kind == "mode_averaged"`), a diagnostic otherwise | | `geometry` | `SpatialGeometry` or `None` | the spatial leaf below; absent for modal transforms | | `node_weights` | `float64 (n_nodes,)` or `None` | physical transverse weights in m² at z=0 for resolved modal populations; divide by taper scale squared at later planes; mode-averaged runs use `aeff` and spatial runs use geometry weights | diff --git a/docs/guide/performance/tuning.md b/docs/guide/performance/tuning.md index 17c896a9..d28202cb 100644 --- a/docs/guide/performance/tuning.md +++ b/docs/guide/performance/tuning.md @@ -104,7 +104,8 @@ plane's columns in node chunks, each paying a fixed cost for its launches, its t transforms and its gather and scatter. Their default budget is therefore 1 GiB, against 64 MiB on modal routes; `SimulationSettings.node_budget` sets it explicitly. On a laptop short of memory a smaller budget saves memory at some speed; on a large device a larger -one can still help. +one can still help. An envelope's split collar transforms the state's own rows in place +and holds no buffer for them, so a device takes them all at once unless a budget is set. **Where a free-space step goes.** On the native backend the stages of a free-space evaluation are profiling regions (`solprop_native.free.*`) that any Kokkos Tools timer diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index eb0dc470..791d4f3a 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -456,6 +456,9 @@ struct Problem { std::size_t n_pol_transform{1}; std::size_t n_nodes{1}; std::size_t node_chunk{1}; + // The temporal rows a free-space envelope's split collar transforms at a time, in + // place on the state; zero takes the node chunk's. It holds no buffer of its own. + std::size_t split_rows{}; std::size_t n_stages{}; // solprop SEP 24 phase 3 (kernel schema 32): the low-storage RK4IP march. With it no // Runge-Kutta view is allocated (stages, accumulators, accept buffer, residual, @@ -907,6 +910,8 @@ class GeneralState { [[nodiscard]] std::map memory_ledger() const; [[nodiscard]] CpuSizing execution_sizing() const; [[nodiscard]] double planning_seconds() const noexcept; + /** The rows a free-space envelope's split collar transforms at a time, or zero. */ + [[nodiscard]] std::size_t split_rows() const noexcept; [[nodiscard]] double raman_planning_seconds() const noexcept; [[nodiscard]] std::string raman_fft_provider() const; [[nodiscard]] std::map raman_fft_tasks() const; diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index 4d8a92e5..bcab00d0 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -192,6 +192,8 @@ native::Problem problem_from(const nb::dict& values) { result.n_pol_transform = nb::cast(values["n_pol_transform"]); result.n_nodes = nb::cast(values["n_nodes"]); result.node_chunk = nb::cast(values["node_chunk"]); + if (values.contains("split_rows")) + result.split_rows = nb::cast(values["split_rows"]); } } catch (const nb::cast_error&) { throw std::invalid_argument("problem dimensions must be integers"); @@ -1537,6 +1539,10 @@ class BoundState { require_open(); return general_state_->planning_seconds(); } + std::size_t split_rows() const { + require_open(); + return general_state_->split_rows(); + } double raman_planning_seconds() const { require_open(); return general_state_->raman_planning_seconds(); @@ -2895,6 +2901,7 @@ NB_MODULE(_core, module_) { .def_prop_ro("execution_sizing", &BoundState::execution_sizing) .def_prop_ro("raman_fft_tasks", [](BoundState& self) { return self.raman_fft_tasks(); }) + .def_prop_ro("split_rows", [](BoundState& self) { return self.split_rows(); }) .def_prop_ro("raman_planning_seconds", [](BoundState& self) { return self.raman_planning_seconds(); }) .def_prop_ro("raman_kernel_transforms", diff --git a/native/src/general.cpp b/native/src/general.cpp index fb5a4209..8bebf6a8 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -934,6 +934,9 @@ void require_general_problem(const Problem& problem) { throw std::invalid_argument("at most two effective node components are supported"); if (problem.node_chunk == 0 || problem.node_chunk > problem.n_nodes) throw std::invalid_argument("node_chunk must lie in [1, n_nodes]"); + if (problem.split_rows > + checked_product(problem.n_modes, problem.n_pol_state, "split collar columns")) + throw std::invalid_argument("split_rows must not exceed the field's columns"); if (problem.n_stages == 0) throw std::invalid_argument("the tableau must contain a stage"); const auto columns = @@ -2093,6 +2096,7 @@ class GeneralWorkspace { } void set_initial(std::span field, std::span noise); + [[nodiscard]] std::size_t split_rows() const noexcept { return split_rows_; } std::vector trial_step(double dz, const TrialScalars& scalars); void prepare_trial(double dz, const TrialScalars& scalars); std::vector adams_source(const std::vector& input, @@ -4128,7 +4132,7 @@ void GeneralWorkspace::allocate() { split_factor_ = RealView("solprop_native.absorber.split_factor", problem_.n_t); #endif if (problem_.free_geometry() && problem_.family == FieldFamily::envelope) { - split_rows_ = temporal_columns; + split_rows_ = problem_.split_rows != 0 ? problem_.split_rows : temporal_columns; } else { coarse_spectrum_ = ComplexView2("solprop_native.absorber.coarse_spectrum", temporal_columns, n); @@ -15688,6 +15692,9 @@ void GeneralState::adopt(const std::vector& field, const double dz) { const CpuTeamScope team(workspace_->cpu_team()); workspace_->adopt(field, dz); } +std::size_t GeneralState::split_rows() const noexcept { + return workspace_->split_rows(); +} double GeneralState::low_storage_step(const double dz, const TrialScalars& scalars, const bool rebuild_table) { const CpuTeamScope team(workspace_->cpu_team()); diff --git a/native/src/solprop_native/_core.pyi b/native/src/solprop_native/_core.pyi index 59e33d4a..290175fb 100644 --- a/native/src/solprop_native/_core.pyi +++ b/native/src/solprop_native/_core.pyi @@ -83,6 +83,8 @@ class State: material: dict[str, npt.NDArray[np.float64] | int], ) -> npt.NDArray[np.complex128]: ... def adopt(self, field: npt.NDArray[np.complex128], dz: float) -> None: ... + @property + def split_rows(self) -> int: ... def low_storage_step( self, dz: float, diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 4de12081..5c0ffb5e 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -2401,6 +2401,21 @@ def _on_device() -> bool: return _build_facts().info["device_backend"] != "none" +def _split_rows(problem: KernelProblem, *, device: bool) -> int: + """Return the temporal rows a free-space envelope's split collar takes at a time. + + The collar transforms the state's own rows in place, so a block holds no buffer of + its own: it sets the launches, three per block, and the plans' work area, which is + small (``docs/dev/benchmarks.md``, the split collar in place). A device therefore + takes every row at once. A caller's own `node_budget` wins (solprop #361). A CPU + keeps the node chunk: FFTW's threaded plan of a whole state was much the slower. + """ + transform = problem.transform + if device and not transform.chunk_budgeted: + return problem.shape[1] * problem.shape[2] + return min(transform.node_chunk, transform.n_nodes) * problem.shape[2] + + def _low_storage_payload(problem: KernelProblem, *, device: bool) -> dict[str, object]: """Return the native problem's low-storage rows (solprop kernel schema 32). @@ -3717,6 +3732,9 @@ def _native_problem( "n_pol_transform": problem.transform.n_pol, "n_nodes": problem.transform.n_nodes, "node_chunk": node_chunk, + "split_rows": 0 + if geometry is None or geometry.kind != "free" + else _split_rows(problem, device=_on_device()), "family": family, "nonlinear": kerr is not None, "modified_noise": problem.initial.noise_model == "modified", diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 3d4561f0..f8c25984 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -681,7 +681,8 @@ class SimulationSettings(Displayed): (``docs/dev/architecture.md`` section 13). ``None``, the default, takes 1 GiB on a free-space, line or planar grid and 64 MiB otherwise (:data:`~solprop.translate.modal.DEFAULT_SPATIAL_NODE_BUDGET`, - :data:`~solprop.translate.modal.DEFAULT_NODE_BUDGET`). + :data:`~solprop.translate.modal.DEFAULT_NODE_BUDGET`). A stated budget also + holds a device's in-place split collar on a free-space envelope to the chunk. stats : solprop.stats.StatsSpec or None Which statistics to collect at every saved plane; ``None`` collects none. polarisation_taper : bool diff --git a/src/solprop/translate/modal.py b/src/solprop/translate/modal.py index 0af17451..360aa4ba 100644 --- a/src/solprop/translate/modal.py +++ b/src/solprop/translate/modal.py @@ -124,6 +124,10 @@ class Transform: cross-section does not change. node_chunk : int Nodes the response pass handles at a time, at least 1 and at most ``n_nodes``. + chunk_budgeted : bool + Whether the caller's own ``node_budget`` set `node_chunk`. Without one a backend + may take a larger block where it holds no buffer of its own for it, as a device + does for a free-space envelope's split collar (:func:`assemble`, solprop #361). aeff : solprop.translate.chebyshev.PiecewiseChebyshev Effective area in m^2 as a table along z. It is the normalisation itself when ``kind == 'mode_averaged'`` and a diagnostic otherwise. @@ -185,6 +189,7 @@ class Transform: node_weights: Float[np.ndarray, " n_weights"] | None = None modal_tables: ModalTransformTables | None = None kind: TransformKind = field(kw_only=True) + chunk_budgeted: bool = field(default=False, kw_only=True) def __post_init__(self) -> None: """Adopt read-only copies and check the shapes the annotations cannot. diff --git a/src/solprop/translate/problem.py b/src/solprop/translate/problem.py index ca25bf22..294b1b53 100644 --- a/src/solprop/translate/problem.py +++ b/src/solprop/translate/problem.py @@ -6416,6 +6416,7 @@ def assemble( # noqa: PLR0913, PLR0915 - one keyword per physics object of a ru >>> problem.flags.single_mode, problem.linear.n_intervals (True, 1) """ + budgeted = node_budget is not None if node_budget is None: node_budget = ( DEFAULT_SPATIAL_NODE_BUDGET @@ -6611,8 +6612,11 @@ def assemble( # noqa: PLR0913, PLR0915 - one keyword per physics object of a ru grid.n_to, node_budget, ) - transform = _balanced_chunk( - _held_current_chunk(transform, compiled, n_pol, grid.n_to, node_budget) + transform = replace( + _balanced_chunk( + _held_current_chunk(transform, compiled, n_pol, grid.n_to, node_budget) + ), + chunk_budgeted=budgeted, ) initial = _power_initial( Initial( diff --git a/tests/backends/test_free_split_rows.py b/tests/backends/test_free_split_rows.py index 2064fc50..8de22ee3 100644 --- a/tests/backends/test_free_split_rows.py +++ b/tests/backends/test_free_split_rows.py @@ -32,7 +32,7 @@ from solprop.stepper import FixedSchedule, PIController from solprop.translate.problem import KernelProblem -pytest.importorskip("solprop_native") +native_backend = pytest.importorskip("solprop_native.backend") BLOCKS = [1, 5, None] """Node chunks: one row at a time, one that leaves a shorter last block (192 columns @@ -165,6 +165,77 @@ def test_a_device_block_of_any_size_takes_the_reference_s_steps( _check_block(reference, chunk) +def _device_shaped(monkeypatch: pytest.MonkeyPatch) -> None: + """Have the adapter size the split's block as a device build does, on any build.""" + sized = native_backend._split_rows + monkeypatch.setattr( + native_backend, + "_split_rows", + lambda problem, *, device: sized(problem, device=True), + ) + + +def test_a_device_takes_every_row_at_once( + reference: tuple[str, np.ndarray, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # A device's choice, run on any build: its plan, its one block and the reference's + # numbers, though the node chunk would have made it 39. + _device_shaped(monkeypatch) + simulation = _beam(ENVELOPE, MARCHES[reference[0]]) + problem = _blocked(simulation, 5) + backend = watched(simulation, "native", problem) + try: + assert backend._native_state.split_rows == problem.shape[1] * problem.shape[2] + finally: + backend.close() + _check_block(reference, 5) + + +def test_a_stated_budget_keeps_its_chunk_on_a_device() -> None: + # An explicit node_budget wins (#361): the block stays the node chunk it set, and + # the unset budget is told apart from one equal to the default. + default = _beam(ENVELOPE).translate() + columns = default.shape[1] * default.shape[2] + assert not default.transform.chunk_budgeted + assert native_backend._split_rows(default, device=True) == columns + assert native_backend._split_rows(default, device=False) == columns + + simulation = _beam(ENVELOPE) + column_bytes = 20 * 16 * default.shape[2] * ENVELOPE.n_to + simulation.settings = dataclasses.replace( + simulation.settings, node_budget=5 * column_bytes + ) + stated = simulation.translate() + assert stated.transform.chunk_budgeted + assert stated.transform.node_chunk == 5 + assert native_backend._split_rows(stated, device=True) == 5 * default.shape[2] + assert native_backend._split_rows(stated, device=False) == 5 * default.shape[2] + + +def test_the_cpu_keeps_the_node_chunk_it_was_given() -> None: + simulation = _beam(ENVELOPE) + problem = _blocked(simulation, 5) + assert native_backend._split_rows(problem, device=False) == 5 * problem.shape[2] + backend = watched(simulation, "native", problem) + try: + assert backend._native_state.split_rows == 5 * problem.shape[2] + finally: + backend.close() + + +def test_a_block_past_the_field_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + simulation = _beam(ENVELOPE) + problem = simulation.translate() + monkeypatch.setattr( + native_backend, + "_split_rows", + lambda problem, *, device: problem.shape[1] * problem.shape[2] + 1, + ) + with pytest.raises(ValueError, match="split_rows"): + watched(simulation, "native", problem) + + def test_real_rows_keep_their_buffers() -> None: # A real time row is not a complex spectrum's row: a field grid takes the chunked # pair through the coarse buffers still. From 755139755366d14cd92237a47cd75d4c9ca8399f Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:34:49 +0000 Subject: [PATCH 084/122] Report SEP 44's phase 3, with its measurements Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 26 ++++++++++++++++++++++ docs/dev/seps/sep-0044-native-edge-read.md | 10 ++++----- tests/backends/test_native_edge_collar.py | 2 +- tests/driver/test_estimate.py | 2 +- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 5827c9bb..d942067d 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -847,6 +847,32 @@ on the aperture against a bare wall at 8 R: Most of a collar's cost is its design check at translation. That check also raised the process's peak memory from 0.45 to 0.68 GB. The SEP's phase 3 report has the full tables. +### The native read of the transverse edge + +[SEP 44](seps/sep-0044-native-edge-read.md) reads the edge on the state's own memory, with only numbers returning. Phase 3 holds the measurements and what's still unmeasured, namely the NVIDIA H200 and the AMD Instinct MI325X. + +Against itself, SEP 40's four-way control, `python -m benchmarks.edge_overhead`, native at 18 threads of an Intel Xeon Gold 6240, 5 rounds, default check against no ramp and no reads: + +| case | neither (s) | default (s) | added | reads | +|---|---:|---:|---:|---:| +| `line` | 0.227 | 0.263 | +15.8 % | 23 | +| `planar` | 0.126 | 0.136 | +7.6 % | 23 | +| `radial_filament_256` | 1.335 | 1.389 | +4.1 % | 21 | +| `kerr_beam_192` | 1.837 | 1.989 | +8.3 % | 4 | + +`line` takes 224 calls where it took 196, which is the read ceiling on the step and not a read's cost. SEP 40 section 7.7's 2 % limit is retired: the cost is to be as small as possible, and reported. + +Against the code before it, `python -m benchmarks ab`, `e49925d1` to this stack, 3 rounds, passive threads, 18 cores available, the same work and fields within 4.2e-15: + +| case | 1 thread | 16 threads | +|---|---:|---:| +| `kerr_beam_192` | 0.903 | 0.483 | +| `radial_filament_256` | 0.987 | 0.796 | +| `line` | 0.996 | 0.938 | +| `planar` | 0.994 | 0.835 | + +The geometric mean is 0.85. A requested collar's read, at 384 x 384 x 600 on those 18 cores, one state: the current at each commit 116 ms against the host's 7.36 s, and a read with its strip 0.94 s against 41.9 s, against a step of 2.97 s. On an NVIDIA A40 with 16 host threads, 36 ms against 1.54 s, and 204 ms against 7.98 s, against 0.70 s. + ### The transverse edge against 0.1.0 [#453](https://github.com/LupoLab/solprop/issues/453) found the tracking set's `kerr_beam` diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index 59af5beb..ea672bd4 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -472,13 +472,13 @@ A requested collar's strip and current are read natively, so a field-free collar - A launch with an input draw and no ramp brought a state to the host, for clean columns nothing reads. `test_a_launch_with_noise_judges_as_the_host_does_with_no_transfer` found it. Those runs now cross nothing. - A fresh agent, given the code and not my conclusions, read it for faults and found no wrong number. It did find a race, since binding a strip's plan released the GIL that serialises FFTW's planner, and a test that couldn't fail: the resumed-collar test's beam had a current of 2e-30 against `pytest.approx`'s default absolute tolerance, so it passed with #512 put back. Both are fixed. The tests comparing a current now use a converging beam, whose current clears `1e-14`, with no absolute tolerance. A planar launch can't converge, so its current is held only to an absolute size. -**Verification, on CPU.** __CPU_VERIFIED__ +**Verification, on CPU.** With #508, #516, #517 and #518 merged in: the edge's, collar's, transverse, split-crossing, monitor, driver, protocol, estimate, SEP, figure and problem modules 1181 passed, 6 skipped; native's `tests/` 555 passed, 7 skipped; the native GoogleTests pass, now with a plain DFT as an oracle; `ruff`, `ruff format` and `basedpyright` clean. Seven deliberate breaks of the collar's modules, each caught: `resume` doing nothing (3 tests), `resume` zeroing the current (3), clean columns zeroed (3) or left noisy (4), and a change of one part in a million to the current (35), the strip (14) or the collar's rate (9). -§7's list, now run: DC, Nyquist and inactive bins; `n_to != n_t` radially, with a Kerr response on a field grid, on every route with padding; a planar `p != 0`; the smallest node chunk each route allows; an almost-zero current; mixed inward and outward frequencies; field grids at the reading level; input, modified and no noise, with and without a ramp, and a supplied unmasked draw; launch-only interruptions, a resume with and without a monitor's record, an interruption between reads and a snapshot's resume on all three marches; and no interference on Runge-Kutta, split and in-stage, both Adams formulations and low-storage with and without its stage register, which a test holds to what each case takes. `numpy_ref`'s binary32 path has its existing test. Exact parity holds only on the matched-schedule witnesses, against `numpy_ref`, on all four routes and two marches. +§7's list, now run: DC, Nyquist and inactive bins; `n_to != n_t` radially, with a Kerr response on a field grid, on every route with padding; a planar `p != 0`; a small node chunk on each route; an almost-zero current; mixed inward and outward frequencies; field grids at the reading level; input, modified and no noise, with and without a ramp, and a supplied unmasked draw; launch-only interruptions, a resume with and without a monitor's record, an interruption between reads and a snapshot's resume on all three marches; and no interference on Runge-Kutta, split and in-stage, both Adams formulations and low-storage with and without its stage register, which a test holds to what each case takes. `numpy_ref`'s binary32 path has its existing test. Exact parity holds only on the matched-schedule witnesses, against `numpy_ref`, on all four routes and two marches. -**On a device.** __DEVICE__ Neither the NVIDIA H200 nor the AMD Instinct MI325X has run this, so §7's gates remain unmeasured; that's phase 4, which needs a new approval for rented cards. +**On a device.** On an NVIDIA A40 with a CUDA build, at `5c11e207`: `test_native_edge_reading.py`, `test_native_edge_collar.py` and `test_transverse_backends.py` 157 passed, 3 skipped; `test_transverse_reads.py` and `test_transverse_monitor.py` 46 passed; `pytest -m gpu tests/backends/test_native_gpu.py` 4 passed. `python -m benchmarks.device_split 384` there: a point at 5.0 and 5.5 s with the check off and 5.9 and 6.5 s with it on, about x1.18, at 51 ms a read. A HIP wheel for `AMD_GFX942` under ROCm 7.2.4 compiles at that commit, and hasn't run. Neither the NVIDIA H200 nor the AMD Instinct MI325X has run this, so §7's gates remain unmeasured; that's phase 4, which needs a new approval for rented cards. **Cost.** One collar's read, on 384 x 384 x 600 with $f = 1$, on one state, field-free, native against the host's reduction of a host copy: @@ -489,9 +489,9 @@ Seven deliberate breaks of the collar's modules, each caught: `resume` doing not At each commit the current is then about 4 to 5 % of a step, where it was more than a step. A read with its strip is about 30 % of a step, at each of the run's few reads. Radially, on 256 nodes and 600 bins at 18 threads: the current 0.06 ms against 5.7 ms, a read 8.0 ms against 19.4 ms. -SEP 40's four-way control, `python -m benchmarks.edge_overhead`, native at 18 threads, 5 rounds, default check against no ramp and no reads, with no collar: `line` +15.8 % (its calls rise from 196 to 224, the read ceiling's), `planar` +7.6 %, `radial_filament_256` +4.1 %, `kerr_beam_192` +8.3 %. These match the prototype's within their spread. Against `main`: __AB__ +SEP 40's four-way control, `python -m benchmarks.edge_overhead`, native at 18 threads, 5 rounds, default check against no ramp and no reads, with no collar: `line` +15.8 % (its calls rise from 196 to 224, the read ceiling's), `planar` +7.6 %, `radial_filament_256` +4.1 %, `kerr_beam_192` +8.3 %. These match the prototype's within their spread. Against `main`: `python -m benchmarks ab`, at the head of phase 3's numerics, against `e49925d1`, whose difference also holds the stack below this branch: 3 rounds, passive threads, the same work and fields within 4.2e-15 in every row. At 1 and 16 threads, `kerr_beam_192` x0.90 and x0.48, `radial_filament_256` x0.99 and x0.80, `line` x1.00 and x0.94, `planar` x0.99 and x0.83, geometric mean 0.85. -**Memory.** __MEMORY__ +**Memory.** `transverse_edge`, read from a bound backend at 384 x 384 x 600 with $f = 1$: 284 MB, 20 % of the state, 256 MiB of it the strip's one buffer. The formula gives 330 MB at 768 x 768 x 600, 6 % of the state. The strip's two plans took 4 MiB on the NVIDIA A40 and 0.2 MB on its host, in the plan rows. A test holds the row to the formula on every route, including a line with too few nodes to read. **A runner's points.** §6 suggested sharing the tables across points. At 512 x 512 x 600 a point's monitor takes 21 ms and its bind 16 ms, against a step of 1.76 s, so I left it. diff --git a/tests/backends/test_native_edge_collar.py b/tests/backends/test_native_edge_collar.py index 1a975039..a8e5840a 100644 --- a/tests/backends/test_native_edge_collar.py +++ b/tests/backends/test_native_edge_collar.py @@ -817,7 +817,7 @@ def test_a_resume_with_no_monitor_record_crosses_once() -> None: def test_a_strip_of_too_few_nodes_reads_nothing_on_either_side( monkeypatch: pytest.MonkeyPatch, ) -> None: - # One line too short to read a direction beside the real ones: it holds no buffer and + # A line too short to read a direction beside the real ones: it holds no buffer and # its pair is zero, in the same place in the reading. simulation = _case("line") problem = simulation.translate() diff --git a/tests/driver/test_estimate.py b/tests/driver/test_estimate.py index f6081ccf..f474eca3 100644 --- a/tests/driver/test_estimate.py +++ b/tests/driver/test_estimate.py @@ -54,7 +54,7 @@ def test_the_estimate_reports_what_the_backend_holds_and_costs(backend): def test_an_estimate_adds_nothing_for_an_edge_read_on_the_host(): # `numpy_ref` reads a copy it has anyway, so the check holds no more memory. - simulation = _built("line", "numpy_ref") + simulation = _built("cartesian_beam", "numpy_ref") checked = simulation.estimate().memory simulation.settings = dataclasses.replace(simulation.settings, boundary_check=None) assert simulation.estimate().memory == checked From 73b6e1f6c9c97ce0b6285f1a23bf7365412f25bf Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:36:06 +0000 Subject: [PATCH 085/122] Hold a host read's estimate on a tiny route Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/driver/test_estimate.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/driver/test_estimate.py b/tests/driver/test_estimate.py index f474eca3..17d4ced0 100644 --- a/tests/driver/test_estimate.py +++ b/tests/driver/test_estimate.py @@ -10,10 +10,12 @@ import pytest +from capability_routes import ROUTES from solprop.backends.registry import available from solprop.driver import Simulation from solprop.driver import estimate as estimate_module from solprop.exceptions import MaterialRangeWarning, SolpropWarning +from solprop.monitor import BoundaryCheck from solprop.recipe import Recipe from solprop.stepper import FixedSchedule, LowStorageRungeKutta @@ -54,7 +56,12 @@ def test_the_estimate_reports_what_the_backend_holds_and_costs(backend): def test_an_estimate_adds_nothing_for_an_edge_read_on_the_host(): # `numpy_ref` reads a copy it has anyway, so the check holds no more memory. - simulation = _built("cartesian_beam", "numpy_ref") + simulation = ROUTES["line"](None) + simulation.settings = dataclasses.replace( + simulation.settings, + backend="numpy_ref", + boundary_check=BoundaryCheck(budget=1.0, stop_factor=1.0), + ) checked = simulation.estimate().memory simulation.settings = dataclasses.replace(simulation.settings, boundary_check=None) assert simulation.estimate().memory == checked From 308a0b776967da1990ad85b9eb69dc525c0aa573 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:40:50 +0000 Subject: [PATCH 086/122] Lighten a device's low-storage passes A flow's magnitude, exp(+-0), is exactly one on a lossless bin, so a binary64 exp per element goes where the operator's real part vanishes; the second pass, which applies no flow, forms no exponent; and the first input crosses to the scratch through tiles of team scratch rather than a stride a plane long. On an NVIDIA A40 at 768 x 768 x 600 the fold goes 450 -> 407 ms and the input 42.5 -> 24.9 ms a step, the arithmetic unchanged: the fields of runs with no collar are bitwise. The adapter sets the tiled input, so a CPU test runs it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/include/solprop_native/core.hpp | 3 ++ native/src/bindings.cpp | 2 ++ native/src/general.cpp | 49 +++++++++++++++++--------- native/src/solprop_native/backend.py | 4 ++- tests/backends/test_free_split_rows.py | 17 +++------ 5 files changed, 45 insertions(+), 30 deletions(-) diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index 791d4f3a..dc0327d8 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -469,6 +469,9 @@ struct Problem { bool low_storage{}; bool low_storage_stage_register{true}; std::size_t low_storage_flows{}; + // Whether a separable route's first input crosses to the transverse scratch through + // tiles of team scratch, as a device's does. + bool low_storage_tiled_input{}; std::size_t n_phi_terms{}; std::size_t n_prefactor_terms{}; // solprop SEP 29 G1: Phi or the prefactor is the general z table. Its terms are then diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index bcab00d0..00793199 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -210,6 +210,8 @@ native::Problem problem_from(const nb::dict& values) { result.low_storage_stage_register = nb::cast(values["low_storage_stage_register"]); result.low_storage_flows = nb::cast(values["low_storage_flows"]); + result.low_storage_tiled_input = + nb::cast(values["low_storage_tiled_input"]); } } catch (const nb::cast_error&) { throw std::invalid_argument("problem family and flags have wrong types"); diff --git a/native/src/general.cpp b/native/src/general.cpp index 8bebf6a8..61a289a0 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -378,7 +378,6 @@ struct SpatialElement { Complex linear, prefactor; }; -#if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) // A device crossing through a 32-by-32 tile of team scratch (SEP 28): the tile is read // with adjacent threads along the side that is contiguous for `read` (rows when // `read_rows`, else bins) and written with adjacent threads along the other, so both @@ -388,7 +387,7 @@ void tiled_crossing(const char* label, const ExecutionSpace& execution, const std::size_t rows, const std::size_t width, const bool read_rows, const Read& read, const Write& write) { constexpr int tile = 32; - constexpr int depth = 8; + const int depth = std::min(8, execution.concurrency()); using Policy = Kokkos::TeamPolicy; using Member = Policy::member_type; using Shared = Kokkos::View dict[str, o The march's own allocation: its registers in place of the Runge-Kutta views, the stage register elided where solprop's estimate elides it, and the flows solprop's estimate counts -- except that a device build recomputes under ``"auto"``, where - the estimate's CPU count over-states it by the table. + the estimate's CPU count over-states it by the table. A device's input crosses the + state's layout through tiles (``docs/dev/benchmarks.md``); a CPU's has its own. """ if not isinstance(problem.stepper.integrator, LowStorageRungeKutta): return {} @@ -2433,6 +2434,7 @@ def _low_storage_payload(problem: KernelProblem, *, device: bool) -> dict[str, o "low_storage": True, "low_storage_stage_register": low_storage_stage_register(problem, "native"), "low_storage_flows": flows, + "low_storage_tiled_input": device, } diff --git a/tests/backends/test_free_split_rows.py b/tests/backends/test_free_split_rows.py index 8de22ee3..371b0eca 100644 --- a/tests/backends/test_free_split_rows.py +++ b/tests/backends/test_free_split_rows.py @@ -165,23 +165,14 @@ def test_a_device_block_of_any_size_takes_the_reference_s_steps( _check_block(reference, chunk) -def _device_shaped(monkeypatch: pytest.MonkeyPatch) -> None: - """Have the adapter size the split's block as a device build does, on any build.""" - sized = native_backend._split_rows - monkeypatch.setattr( - native_backend, - "_split_rows", - lambda problem, *, device: sized(problem, device=True), - ) - - def test_a_device_takes_every_row_at_once( reference: tuple[str, np.ndarray, dict[str, Any]], monkeypatch: pytest.MonkeyPatch, ) -> None: - # A device's choice, run on any build: its plan, its one block and the reference's - # numbers, though the node chunk would have made it 39. - _device_shaped(monkeypatch) + # A device's choices, run on any build: one block where the node chunk would have + # made 39, flows recomputed under "auto", and its input through tiles of team + # scratch, at the reference's numbers. + monkeypatch.setattr(native_backend, "_on_device", lambda: True) simulation = _beam(ENVELOPE, MARCHES[reference[0]]) problem = _blocked(simulation, 5) backend = watched(simulation, "native", problem) From aa67d1d97fcf466c58a89fb2bf46fe92d4bb6d29 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:51:19 +0000 Subject: [PATCH 087/122] Hold what a fresh read found the widths' tests leaving open A plane's refusal bound now has a test, a rotated mode reaches the on-axis row's y component, and a planar fluence is checked on a padded field grid. No number moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/spatial.py | 5 +++ tests/backends/test_native_widths.py | 39 +++++++++++++++++++++-- tests/backends/test_on_axis_statistics.py | 20 ++++++++++-- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/solprop/spatial.py b/src/solprop/spatial.py index 4c801076..c95a9d3e 100644 --- a/src/solprop/spatial.py +++ b/src/solprop/spatial.py @@ -3193,6 +3193,11 @@ def ring_fluence_widths( tuple of float The first 1/e^2 radius and the FWHM in m, as :func:`radial_fluence_widths` defines them, with the first ring as the axis. + + Raises + ------ + GridError + With fewer than three radii, too few to find a width from. """ _, radius_e2, width = radial_fluence_widths( radii[1:], averaged[1:], np.ones(radii.size - 1), float(averaged[0]) diff --git a/tests/backends/test_native_widths.py b/tests/backends/test_native_widths.py index 69e0be3b..449abdc1 100644 --- a/tests/backends/test_native_widths.py +++ b/tests/backends/test_native_widths.py @@ -244,7 +244,10 @@ def _route(route, kind="envelope"): if route == "line": return _simulation(kind, LineGrid(4e-4, 48), 2) assert route == "planar" - grid, _, problem = planar_case("kerr", steps=0) + # The field grid's time is padded, which a slab's fluence has to scale away. + grid, _, problem = planar_case( + "kerr" if kind == "envelope" else "field-kerr", steps=0 + ) return grid, dataclasses.replace(problem, stats_spec=StatsSpec(names=WIDTHS)) @@ -309,10 +312,40 @@ def test_a_separable_fluence_is_the_reference_s_at_every_node(monkeypatch, route @native_only -def test_a_planar_fluence_is_the_reference_s_at_every_node(monkeypatch): +@pytest.mark.parametrize("kind", ["envelope", "field"]) +def test_a_planar_fluence_is_the_reference_s_at_every_node(monkeypatch, kind): # The hybrid kind's guide has real dispersion across its slab orders, so its bound # need not be nearly tight. It is only held to bound the reference's from above. - _check_fluence(monkeypatch, "planar", "envelope") + _check_fluence(monkeypatch, "planar", kind) + + +@native_only +@pytest.mark.parametrize("kind", ["envelope", "field"]) +@pytest.mark.parametrize("route", ["plane", "plane_scalar"]) +def test_a_plane_s_moments_bound_the_refusal_as_its_fluence_does(route, kind): + # What a plane's refusal scales by is in its moments, and its width comes out the + # same whatever that is unless a negative lobe is near it: flag 16's value is held to + # the reference's, so flag 32's is held to that. + grid, problem = _route(route, kind) + field = _field_for(route, grid, problem) + backend = select("native", problem, grid=grid) + try: + backend.set_initial(field, None, z=0.0) + state = backend._native_state # pyright: ignore[reportAttributeAccessIssue] + fluence = np.ravel(state.spatial_statistics(16, 0.5 * EPS0 * C, 0.0)) + moments = np.ravel(state.spatial_statistics(32, 0.5 * EPS0 * C, 0.0)) + finally: + backend.close() + n_nodes = problem.transform.n_nodes + largest = fluence[n_nodes + 1] + assert largest > 0.0 + assert moments[2] == pytest.approx(largest, rel=1e-12, abs=0.0) + # And the fluence's own largest value and most negative one, the refusal's input. + assert moments[3] == pytest.approx(np.max(fluence[:n_nodes]), rel=1e-12, abs=0.0) + assert moments[0] == pytest.approx( + np.min(fluence[:n_nodes]), rel=1e-12, abs=1e-12 * largest + ) + assert moments[1] == np.argmin(fluence[:n_nodes]) def _check_axis(route): diff --git a/tests/backends/test_on_axis_statistics.py b/tests/backends/test_on_axis_statistics.py index c785826b..bc16baf1 100644 --- a/tests/backends/test_on_axis_statistics.py +++ b/tests/backends/test_on_axis_statistics.py @@ -14,6 +14,7 @@ them by name. """ +import math import warnings from contextlib import closing from typing import cast @@ -62,8 +63,8 @@ def table(): return build_rate_table(adk_oracle(Gas.Ar), (1e9, 2e11)) -def argon(m=1): - return MarcatiliMode(125e-6, "Ar", 1.0, m=m, cladding=1.45, loss=False) +def argon(m=1, phi=0.0): + return MarcatiliMode(125e-6, "Ar", 1.0, m=m, cladding=1.45, loss=False, phi=phi) def problem_on(grid, table, modes, rule, start=None): @@ -334,6 +335,12 @@ def _axis_state(grid, name): return [argon()], built, 1.0 if name == "two_polarisations": return [argon()], np.concatenate([built, 0.3j * built], axis=1), 1.0 + if name == "rotated": + # A mode with field on the axis's y row, which `phi = 0` leaves at zero, so that + # a row dropping y would still read the same. + modes = [argon(phi=math.pi / 4)] + assert np.all(np.abs(_axis_row(modes)) > 0.0) + return modes, built, 1.0 if name == "one_bin": # All the energy in the peak bin: a tone of the pulse's peak amplitude. tone = np.zeros_like(built) @@ -350,7 +357,14 @@ def _axis_state(grid, name): return modes, np.concatenate([10 * built, 10 * second], axis=0), 19.0 -AXIS_STATES = ("one_mode", "two_polarisations", "one_bin", "two_modes", "cancelling") +AXIS_STATES = ( + "one_mode", + "rotated", + "two_polarisations", + "one_bin", + "two_modes", + "cancelling", +) def _check_axis_row(grid, table, name): From a2f824abe5ac816e90a8353bb173b75d992f330e Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:51:41 +0000 Subject: [PATCH 088/122] Wrap a comment in the widths' tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/backends/test_native_widths.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/backends/test_native_widths.py b/tests/backends/test_native_widths.py index 449abdc1..da86cde8 100644 --- a/tests/backends/test_native_widths.py +++ b/tests/backends/test_native_widths.py @@ -324,8 +324,8 @@ def test_a_planar_fluence_is_the_reference_s_at_every_node(monkeypatch, kind): @pytest.mark.parametrize("route", ["plane", "plane_scalar"]) def test_a_plane_s_moments_bound_the_refusal_as_its_fluence_does(route, kind): # What a plane's refusal scales by is in its moments, and its width comes out the - # same whatever that is unless a negative lobe is near it: flag 16's value is held to - # the reference's, so flag 32's is held to that. + # same whatever that is unless a negative lobe is near it: flag 16's value is held + # to the reference's, so flag 32's is held to that. grid, problem = _route(route, kind) field = _field_for(route, grid, problem) backend = select("native", problem, grid=grid) From b0bcb2fea72ed4c590671c942de983fb52d99bad Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:53:16 +0000 Subject: [PATCH 089/122] Say where the edge is read in the output guide and the kernel contract Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/kernel-contract.md | 9 +++++++++ docs/guide/results/output.md | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md index 73aed4e4..baa6ec3b 100644 --- a/docs/dev/kernel-contract.md +++ b/docs/dev/kernel-contract.md @@ -228,6 +228,15 @@ too. The test that compares this document with the dataclass walks the tree by t in both directions; a second test checks the flags and counts named in the `flags` row of this section against the fields of `Flags`. +**The transverse edge.** `StepDriver._edge_reading(state, edge, *, faces, current, full)` +returns an `EdgeReading` ([SEP 44](seps/sep-0044-native-edge-read.md)): region sums, each column's unweighted energy, each +line's `(inward, windowed)` strip and the summed rectified current, never the field. The default forms them with +`HostEdgeReduction` on a host copy. An override takes the edge's `EdgeTables` and `EdgeLine`s, whose weights, strides, +windows and onsets say all there is to know about the geometry, so a backend holds no face, mask or judgement of its own. It +reads the state at a committed position, so it may use scratch that a step writes before reading, and it must leave the march +as it was. `StepDriver.edge_memory(space)` says what binding holds, from the grid alone, for `estimate`: the default holds +nothing beyond the state, and native's is `transverse_edge_bytes`, equal to its `transverse_edge` ledger row. + **Source reservations in the shared Adams march.** The protected hooks `StepDriver._reserve_source_calls(count)` and `StepDriver._release_source_calls()` bound the source evaluations of a trial. Before starting a trial, the shared march diff --git a/docs/guide/results/output.md b/docs/guide/results/output.md index 2d882ba3..ea6027f2 100644 --- a/docs/guide/results/output.md +++ b/docs/guide/results/output.md @@ -187,9 +187,9 @@ rather than a copy of it, and nothing writes to that again, so a handler that reduces each plane to a few numbers and drops it costs one field volume on the host at that plane, the one the observation already crossed with, and a handler may equally keep `plane.field` for as long as it likes. A `SavedPlane` you construct yourself copies what it -is given. On a free-space run the boundary check reads the transverse edge on the host as -well, which [costs more](../setup/running.md#many-inputs-one-backend-the-runner) than the -plane does. +is given. On a free-space run the boundary check reads the transverse edge at that plane too: +`native` does it where the state lives, and `numpy_ref` and `mlx` on their host copy, which +[costs more](../setup/running.md#many-inputs-one-backend-the-runner) than the plane does. The handler converts the field on the way out, because the file is where the internal FFT convention meets the user's optics convention: stored spectra go through From 4cc6af0bf2b323633dfafb194e8d4cd59480650d Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:54:53 +0000 Subject: [PATCH 090/122] Give a launch's block workers their docstrings Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/boundaries.py | 13 +++++++++- src/solprop/pulses.py | 53 ++++++++++++++++++++++++++++++++++----- src/solprop/spatial.py | 7 ++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/solprop/boundaries.py b/src/solprop/boundaries.py index 8c447b8e..a67ad33b 100644 --- a/src/solprop/boundaries.py +++ b/src/solprop/boundaries.py @@ -1091,7 +1091,18 @@ def occupancy( weights = None if columns is None else np.asarray(columns, dtype=np.float64) def block(rows: slice) -> Float[np.ndarray, " n_buckets"]: - """Return a block of modes' energy per bucket.""" + """Return a block of modes' energy per bucket. + + Parameters + ---------- + rows : slice + The block of modes. + + Returns + ------- + numpy.ndarray + The block's energy in each bucket. + """ energy = np.einsum("bmpw,bmpw->mw", re[:, rows], re[:, rows]) + np.einsum( "bmpw,bmpw->mw", im[:, rows], im[:, rows] ) diff --git a/src/solprop/pulses.py b/src/solprop/pulses.py index 33847785..bae51d87 100644 --- a/src/solprop/pulses.py +++ b/src/solprop/pulses.py @@ -1149,7 +1149,18 @@ def _hard_masked( outside = band[None, :] & ~live def shares(rows: slice) -> tuple[float, float]: - """Return a block's norm in the columns that don't propagate, and in all.""" + """Return a block's norm in the columns that don't propagate, and in all. + + Parameters + ---------- + rows : slice + The block of the first axis. + + Returns + ------- + tuple of float + The block's two sums. + """ norm = weights[rows, None] * np.sum(np.abs(field[rows]) ** 2, axis=1) return float(np.sum(norm[outside[rows]])), float(np.sum(norm[:, band])) @@ -1169,7 +1180,13 @@ def shares(rows: slice) -> tuple[float, float]: raise PulseError(msg) def mask(rows: slice) -> None: - """Zero one block's dead bins.""" + """Zero one block's dead bins. + + Parameters + ---------- + rows : slice + The block of the first axis. + """ field[rows] *= live[rows, None, :] parallel_rows(field.shape[0], field.nbytes, mask) @@ -1200,7 +1217,13 @@ def _separable_field( field = np.empty((spectrum.size, jones.size, n_omega), dtype=np.complex128) def work(rows: slice) -> None: - """Form one block, whose first touch is then this thread's.""" + """Form one block, whose first touch is then this thread's. + + Parameters + ---------- + rows : slice + The block of the first axis. + """ field[rows] = spectrum[rows, None, None] * jones[None, :, None] * temporal parallel_rows(spectrum.size, field.nbytes, work) @@ -1219,7 +1242,13 @@ def _add_rows(total: np.ndarray, part: np.ndarray) -> None: """ def work(rows: slice) -> None: - """Add one block.""" + """Add one block. + + Parameters + ---------- + rows : slice + The block of the first axis. + """ total[rows] += part[rows] parallel_rows(total.shape[0], total.nbytes, work) @@ -1308,7 +1337,13 @@ def _normalising_factor( terms = np.empty((beam.shape[0], beam.shape[2])) def work(rows: slice) -> None: - """Form one block of the flux's terms.""" + """Form one block of the flux's terms. + + Parameters + ---------- + rows : slice + The block of the first axis. + """ terms[rows] = ( plane.weights[rows, None] * energy_weights @@ -1375,7 +1410,13 @@ def _normalised( factor = _normalising_factor(pulse, temporal, field, plane, grid) def scale(rows: slice) -> None: - """Scale one block.""" + """Scale one block. + + Parameters + ---------- + rows : slice + The block of the first axis. + """ field[rows] *= factor parallel_rows(field.shape[0], field.nbytes, scale) diff --git a/src/solprop/spatial.py b/src/solprop/spatial.py index b849c12c..c50e2a5d 100644 --- a/src/solprop/spatial.py +++ b/src/solprop/spatial.py @@ -921,6 +921,13 @@ def whole_admittance(self) -> Complex[np.ndarray, "n_modes n_omega"]: ) def work(rows: slice) -> None: + """Form one block of the chunks' admittance. + + Parameters + ---------- + rows : slice + The block of chunks. + """ for columns in chunks[rows]: admittance[columns] = self.admittance(columns) From 3fa3778d89c998219ebaa2017d61b00d1453e2df Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 20:59:53 +0000 Subject: [PATCH 091/122] Stop an Adams trial forming what it does not read A Cartesian route's source evaluation accumulated its stage into the two Runge-Kutta accumulators, which an Adams march never reads: 3.4 of 199 ms a trial at 256 x 256 x 600 on an NVIDIA A40. The interaction formulation's fused trial copied the state aside and back around its source, where the exponential formulation already swaps: 0.978 to 0.993 on the four cases timed, fields bitwise. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/general.cpp | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/native/src/general.cpp b/native/src/general.cpp index 61a289a0..f43276a1 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -2307,7 +2307,8 @@ class GeneralWorkspace { void plan(); void reuse_fsal(); void evaluate_stage(std::size_t stage, double dz); - void evaluate_stage_source(std::size_t stage, double dz); + // `accumulate` false: an Adams trial reads the stage row alone. + void evaluate_stage_source(std::size_t stage, double dz, bool accumulate = true); bool modal_core(std::size_t stage); void general_core(std::size_t stage); StageTerms stage_terms(std::size_t stage) const; @@ -2349,7 +2350,7 @@ class GeneralWorkspace { void flux_at(double z); void require_flux_closure(); void fill_statistics_closure(); - void evaluate_separable_stage(std::size_t stage, double dz); + void evaluate_separable_stage(std::size_t stage, double dz, bool accumulate); void seed_free_wisdom(); void seed_modal_wisdom(); void seed_stream_wisdom(); @@ -6071,7 +6072,7 @@ void GeneralWorkspace::adams_evaluate_source(const TrialScalars& scalars) { prepare_raman_stage(0, scalars.raman_rates.data(), problem_.n_raman_components > 1 ? scalars.raman_densities.data() : nullptr); - evaluate_stage_source(0, 0.0); + evaluate_stage_source(0, 0.0, false); stages_ready_ = true; pending_fsal_ = false; has_recycled_ = false; @@ -6261,19 +6262,20 @@ GeneralWorkspace::adams_fused_trial(const double dz, const AdamsWeights& weights const auto policy = Range2(execution_space_, {0, 0}, {columns, n}); const auto store = adams_store_; const auto current = current_; - const auto saved = adams_saved_; + const auto predicted = adams_saved_; // The predictor in the anchor's frame, a_n + dz sum_t w_t g_t, is the field the - // stage-zero source evaluates; the accepted state is kept aside meanwhile. - graph::deep_copy(execution_space_, saved, current); + // stage-zero source evaluates; the accepted state is swapped aside meanwhile. launch::parallel_for( "solprop_native.adams.predict", policy, KOKKOS_LAMBDA(const std::size_t column, const std::size_t index) { Complex total{}; for (std::size_t t = 0; t < weights.order; ++t) total += weights.predictor[t] * store(weights.slot[t], column, index); - current(column, index) = saved(column, index) + dz * total; + predicted(column, index) = current(column, index) + dz * total; }); - adams_evaluate(scalars); + std::swap(current_, adams_saved_); + adams_evaluate_source(scalars); + std::swap(current_, adams_saved_); const auto stages = stages_; const auto source = adams_source_; const auto propagator = adams_propagator_; @@ -9056,7 +9058,8 @@ void GeneralWorkspace::separable_finish_transform(const std::size_t stage, // back onto the rows in time) or the in-stage absorber on the rows, and back; the // forward transverse transform and each column's prefactor and propagator. void GeneralWorkspace::evaluate_separable_stage(const std::size_t stage, - const double dz) { + const double dz, + const bool accumulate) { auto* const dispersive = separable_dispersive_.get(); const bool nonlinear = problem_.nonlinear || problem_.plasma || problem_.raman || dispersive != nullptr; @@ -9081,6 +9084,8 @@ void GeneralWorkspace::evaluate_separable_stage(const std::size_t stage, separable_core(stage, material); separable_finish_transform(stage, material); } + if (!accumulate) + return; const double weight = problem_.solution_weights[stage]; const double error_weight = problem_.error_weights[stage]; cartesian_for( @@ -9605,11 +9610,12 @@ void GeneralWorkspace::evaluate_stage(const std::size_t stage, const double dz) problem_.error_weights[stage], stages_, solution_, error_); } -void GeneralWorkspace::evaluate_stage_source(const std::size_t stage, const double dz) { +void GeneralWorkspace::evaluate_stage_source(const std::size_t stage, const double dz, + const bool accumulate) { if (!defer_quadrature_) coarse_pending_ = false; if (problem_.separable_geometry()) { - evaluate_separable_stage(stage, dz); + evaluate_separable_stage(stage, dz, accumulate); return; } stage_entry(stage, dz); From a0421787b345ccbf309b79ec35df5280c0e4fbd2 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 21:02:25 +0000 Subject: [PATCH 092/122] Take a device's accept pass in one reduction, through a pinned mirror The accept was an update pass and three reductions on a device against one pass on the host: both now take the host's, so the device's arithmetic is the one a CPU test runs (#103). A Runge-Kutta trial's partials took a fresh host mirror per trial; they cross through one persistent pinned mirror instead, in the ledger's trial_norms row. On an NVIDIA A40 the accept kernel takes 11 to 18 % less and the crossing 50 % less: 26.4 -> 23.4 us and 16.8 -> 8.8 us a trial on he_rdw_averaged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/general.cpp | 118 ++++++++++++++--------------------------- 1 file changed, 39 insertions(+), 79 deletions(-) diff --git a/native/src/general.cpp b/native/src/general.cpp index f43276a1..07048f9d 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -47,7 +47,7 @@ #include #endif -namespace solprop_native::host { +namespace solprop_native { /** The three squared norms an accepted step reports, as one reduction value. */ struct NormSums { double old_sum = 0.0; @@ -60,10 +60,10 @@ struct NormSums { return *this; } }; -} // namespace solprop_native::host +} // namespace solprop_native -template <> struct Kokkos::reduction_identity { - KOKKOS_FUNCTION static solprop_native::host::NormSums sum() { return {}; } +template <> struct Kokkos::reduction_identity { + KOKKOS_FUNCTION static solprop_native::NormSums sum() { return {}; } }; namespace solprop_native { @@ -1426,18 +1426,24 @@ KOKKOS_INLINE_FUNCTION void slot_range(const std::size_t n, const std::size_t sl end = Kokkos::min(n, first + width); } -// The update and its three binary64 norms. On a device each column is `slots` teams, -// each over its own bins, writing its partial sums at offset `slot` from the column's -// partial pointers; the host reduces them. +// The update and its three binary64 norms in one pass. On the host one team per column, +// its threads splitting the bins when the columns are fewer than the threads; on a +// device `slots` teams per column, each over its own bins, writing its partial sums at +// offset `slot` from the column's partial pointers. The sums associate as the teams +// split them (solprop SEP 25), and the host reduces them. template void accept_update(const ExecutionSpace& execution, const std::size_t count, const std::size_t n, const double dz, const Columns& columns, const char* label, const std::size_t slots) { -#if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) using ReductionPolicy = Kokkos::TeamPolicy; using ReductionMember = ReductionPolicy::member_type; +#if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) + const int team_size = SOLPROP_NATIVE_DEVICE_REDUCTION_SIZE; +#else + const int team_size = host_reduction_team_size(execution, count, n); +#endif const ReductionPolicy reduction_policy(execution, static_cast(count * slots), - SOLPROP_NATIVE_DEVICE_REDUCTION_SIZE); + team_size); launch::parallel_for( label, reduction_policy, KOKKOS_LAMBDA(const ReductionMember& team) { const auto league = static_cast(team.league_rank()); @@ -1447,71 +1453,12 @@ void accept_update(const ExecutionSpace& execution, const std::size_t count, std::size_t end = 0; slot_range(n, slots, slot, first, end); const auto values = columns(column); - Kokkos::parallel_for(Kokkos::TeamThreadRange(team, static_cast(first), - static_cast(end)), - [&](const int index_value) { - const auto index = static_cast(index_value); - const Complex candidate = - values.whole[index] * (values.carried[index] + - dz * values.solution[index]); - values.advanced[index] = candidate; - values.residual[index] = - values.whole[index] * (dz * values.error[index]); - }); - team.team_barrier(); - double old_sum = 0.0; - double new_sum = 0.0; - double error_sum = 0.0; - Kokkos::parallel_reduce( - Kokkos::TeamThreadRange(team, static_cast(first), - static_cast(end)), - [&](const int index_value, double& update) { - const auto index = static_cast(index_value); - if (values.in_norm[index] != 0U) - update += squared_abs(values.current[index]); - }, - old_sum); + NormSums sums; Kokkos::parallel_reduce( Kokkos::TeamThreadRange(team, static_cast(first), static_cast(end)), - [&](const int index_value, double& update) { + [&](const int index_value, NormSums& update) { const auto index = static_cast(index_value); - if (values.in_norm[index] != 0U) - update += squared_abs(values.advanced[index]); - }, - new_sum); - Kokkos::parallel_reduce( - Kokkos::TeamThreadRange(team, static_cast(first), - static_cast(end)), - [&](const int index_value, double& update) { - const auto index = static_cast(index_value); - if (values.in_norm[index] != 0U) - update += squared_abs(values.residual[index]); - }, - error_sum); - Kokkos::single(Kokkos::PerTeam(team), [&]() { - values.old_partial[slot] = old_sum; - values.new_partial[slot] = new_sum; - values.error_partial[slot] = error_sum; - }); - }); -#else - // One team per column, its threads splitting the bins when the columns are fewer - // than the threads, and the update and the three norms in one pass; the sums - // associate as the team splits them (solprop SEP 25). The host keeps one slot. - static_cast(slots); - using ReductionPolicy = Kokkos::TeamPolicy; - using ReductionMember = ReductionPolicy::member_type; - const ReductionPolicy reduction_policy(execution, static_cast(count), - host_reduction_team_size(execution, count, n)); - launch::parallel_for( - label, reduction_policy, KOKKOS_LAMBDA(const ReductionMember& team) { - const auto column = static_cast(team.league_rank()); - const auto values = columns(column); - host::NormSums sums; - Kokkos::parallel_reduce( - Kokkos::TeamThreadRange(team, n), - [&](const std::size_t index, host::NormSums& update) { const Complex candidate = values.whole[index] * (values.carried[index] + dz * values.solution[index]); @@ -1525,14 +1472,13 @@ void accept_update(const ExecutionSpace& execution, const std::size_t count, update.new_sum += squared_abs(candidate); update.error_sum += squared_abs(one_residual); }, - Kokkos::Sum(sums)); - Kokkos::single(Kokkos::PerTeam(team), [&] { - *values.old_partial = sums.old_sum; - *values.new_partial = sums.new_sum; - *values.error_partial = sums.error_sum; + Kokkos::Sum(sums)); + Kokkos::single(Kokkos::PerTeam(team), [&]() { + values.old_partial[slot] = sums.old_sum; + values.new_partial[slot] = sums.new_sum; + values.error_partial[slot] = sums.error_sum; }); }); -#endif } // The bins a host work item of the stage's entry kernel takes at a time: every bin's @@ -2799,6 +2745,11 @@ class GeneralWorkspace { ComplexView2 adams_source_, adams_propagator_, adams_exponent_, adams_saved_; ComplexView2 adams_held_, adams_dense_start_, adams_dense_end_, adams_dense_exponent_; Kokkos::View adams_divisor_; + // A Runge-Kutta trial's norm partials cross through this mirror, pinned on a device + // (the view itself on a host build), so that no trial takes a fresh host buffer. The + // copy is asynchronous: it is read after the trial's fence. + decltype(Kokkos::create_mirror_view(Kokkos::SharedHostPinnedSpace{}, + std::declval())) pinned_partials_; // A trial's squared norms, which its reduction writes and the host reads: in pinned // host memory on a device, which the reduction's last block writes directly, so that // no copy from the device follows it before the trial's one fence. @@ -3504,6 +3455,9 @@ void GeneralWorkspace::allocate() { RealView2("solprop_native.transform.quadrature_partials", 2, columns); quadrature_scores_ = RealView("solprop_native.transform.quadrature_scores", 2); Kokkos::deep_copy(quadrature_scores_, Kokkos::Experimental::quiet_NaN_v); + if (!problem_.low_storage) + pinned_partials_ = + Kokkos::create_mirror_view(Kokkos::SharedHostPinnedSpace{}, partials_); if (problem_.weighted_controller) { weighted_scale_ = RealView("solprop_native.step.weighted_scale", n); weighted_partials_ = @@ -8804,8 +8758,9 @@ std::vector GeneralWorkspace::collect_trial() { // it is not the next step's first stage; solprop's reference takes no FSAL there. pending_fsal_ = problem_.first_same_as_last && !problem_.split_step_absorber && !flow_; + graph::deep_copy(execution_space_, pinned_partials_, partials_); execution_space_.fence("solprop_native trial boundary"); - const auto host = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, partials_); + const auto& host = pinned_partials_; // Every column's slots, in order: the host's reduction is the caller's sum of a row. const auto slots = host.extent(1); std::vector result(3 * slots); @@ -15298,8 +15253,12 @@ std::map GeneralWorkspace::base_memory_ledger() const stage_staging_.empty() || stage_staging_.front().data() == stage_scalars_.data() ? 0 : stage_staging_.size() * stage_scalars_.size() * real_bytes}, - // An Adams trial's norms, read back in pinned host memory on a device. - {"trial_norms", adams_norms_.size() * real_bytes}, + // An Adams trial's norms, read back in pinned host memory on a device, and a + // Runge-Kutta trial's mirror of its partials (the view itself on a host build). + {"trial_norms", + (adams_norms_.size() + + (pinned_partials_.data() == partials_.data() ? 0 : pinned_partials_.size())) * + real_bytes}, {"crop_index", crop_index_.size() * index_bytes}, {"masks", (active_mask_.size() + norm_mask_.size()) * byte_bytes}, {"windows", (omega_window_.size() + time_window_.size()) * real_bytes}, @@ -15579,6 +15538,7 @@ void GeneralWorkspace::close() noexcept { chunk_projection_stacked_ = {}; tail_projection_stacked_ = {}; partials_ = {}; + pinned_partials_ = {}; quadrature_partials_ = {}; weighted_scale_ = {}; weighted_partials_ = {}; From 45ebe8c2ef44d876bf9225d5536b57450c9ab0bd Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 21:23:33 +0000 Subject: [PATCH 093/122] Keep one agent's work in few pull requests Every pull request and push runs CI's whole matrix. Stacking stays for building on another agent's open pull request. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- AGENTS.md | 6 +++++- docs/dev/seps/sep-0000-process.md | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 37c999d9..49520b41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,7 +216,7 @@ The long form is [SEP 0](docs/dev/seps/sep-0000-process.md); the index is starts and marked ready when the last commit sets `Final`; there are no per-phase pull requests. CI runs on every push to it and need not be green while it is a draft; a push that needs no run has `[skip ci]` in its head commit's message. The branch may start on - top of an open pull request's branch when pull requests are stacked behind CI. Within + top of another agent's open pull request, when the SEP needs that work first. Within each phase: `numpy_ref` and native CPU in `native/`, in either order (ADR-068), with tests and the manual page; `mlx` only where the SEP says so (the backend rules, below). A backend without the feature refuses it by name, never falls back silently; `Final` @@ -479,6 +479,10 @@ The long form is [`testing.md`](docs/dev/testing.md). commits rather than one large one. Stage deliberately with `git add `. - Changes reach `main` as pull requests with green CI, or by a local merge when the maintainers ask for one. +- **Few pull requests, few pushes.** CI runs its whole matrix on every push to every pull + request, so keep one agent's work in one pull request, or a few, rather than one per + step. Stack only on another agent's open pull request, when your work needs it before it + lands. Commit as often as you like and push in batches. - **Before opening an issue or a pull request,** read [`CONTRIBUTING.md`](CONTRIBUTING.md): search the issues, the pull requests and the SEPs, link what is related, and follow its sections and labels. diff --git a/docs/dev/seps/sep-0000-process.md b/docs/dev/seps/sep-0000-process.md index 8e47206f..2bad1d1f 100644 --- a/docs/dev/seps/sep-0000-process.md +++ b/docs/dev/seps/sep-0000-process.md @@ -211,7 +211,7 @@ supersedes it. A return to `Draft` moves its roadmap listing back to the draft s An implementer picks up an accepted SEP with a commit that sets the status to `In progress` and records, in the **Implementation** field, who is implementing it and the branch. Every phase is made on that one branch, `feature/sep-NNNN-short-slug`, which may -start on top of an open pull request's branch when pull requests are stacked behind CI. +start on top of another agent's open pull request, when the SEP needs that work first. Push that commit and open the SEP's one pull request from it, as a draft titled `WIP: SEP N …` and linked from the tracking issue, so that CI can run during development and the work can be followed. Each phase is: @@ -377,3 +377,6 @@ that edits this document and adds a line to its history. implementation starts, not when the SEP is set `Final`, so that CI can run during development and the work can be followed; it is marked ready at `Final` (maintainer's decision, already the practice on SEP 40 and SEP 41). +- 2026-11-04: A SEP's branch stacks only on another agent's open pull request, and one agent's + work isn't divided across a stack, since every pull request and push runs CI's whole + matrix (maintainer's decision). From dc30f00f6f99394b652cfac09937eab01b3d8244 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 21:25:33 +0000 Subject: [PATCH 094/122] Hold the accept's new norm and a small flow loss to their own values Mutation found two gaps. The accept's new norm could read the old state, since the score's scale took the larger of two norms that nearly agreed. A flow's magnitude could be taken as one on any small real part, since no recomputed case carried a small loss. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/backends/test_native_error_norm.py | 25 +++++++++++++ tests/backends/test_native_low_storage.py | 45 ++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/tests/backends/test_native_error_norm.py b/tests/backends/test_native_error_norm.py index 4b90f129..bb3826fd 100644 --- a/tests/backends/test_native_error_norm.py +++ b/tests/backends/test_native_error_norm.py @@ -144,6 +144,31 @@ def test_a_whole_trial_scores_as_the_reference_s_trial_does( assert abs(score - wanted) <= STEP_RELATIVE / rtol +def test_each_norm_is_read_from_its_own_state(capillary_problem, field_grid): + """The accept's sums: the state before the step, and the state after it.""" + problem = _problem(capillary_problem) + dz = 1e-2 + backend = native.NativeBackend(problem, grid=field_grid) + try: + backend.set_initial(problem.initial.field0, None) + partials = np.asarray(backend._prepare_trial(0.0, dz)) + old, new = float(partials[0].sum()), float(partials[1].sum()) + backend._finish(backend._native_state, dz) + after = backend.observe().require_field() + finally: + backend.close() + mask = problem.grids.norm_mask + before = problem.initial.field0 + wanted_old = float(np.sum(np.abs(before[..., mask]) ** 2)) + wanted_new = float(np.sum(np.abs(after[..., mask]) ** 2)) + # Two sums of the same samples in other orders, and the band mask between the + # accept and `after`, which only the norm's bins pass. + bound = 4 * higham_gamma(before[..., mask].size) + assert abs(wanted_new - wanted_old) > 8 * bound * wanted_old, "the step must show" + assert abs(old - wanted_old) <= bound * wanted_old + assert abs(new - wanted_new) <= bound * wanted_new + + def _weights(n_omega): return np.linspace(0.5, 2.0, n_omega) diff --git a/tests/backends/test_native_low_storage.py b/tests/backends/test_native_low_storage.py index 25942ca1..6c740645 100644 --- a/tests/backends/test_native_low_storage.py +++ b/tests/backends/test_native_low_storage.py @@ -50,7 +50,7 @@ low_storage_stage_register, ) from solprop.backends.numpy_ref.backend import _general_maps -from solprop.boundaries import absorber +from solprop.boundaries import Absorber, absorber from solprop.constants import C from solprop.driver import Simulation from solprop.exceptions import BackendError, GeneralModalBasisWarning @@ -62,7 +62,9 @@ from solprop.output.memory import MemoryOutput from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium +from solprop.stepper import PIController from solprop.translate.ionisation import build_rate_table +from solprop.translate.problem import assemble # The fixtures here launch noise-free into time windows kept tight for speed, which # hold more than a millionth of the energy in their guards or collar: the boundary @@ -304,6 +306,47 @@ def test_a_cartesian_kerr_beam_agrees(mode, collar, noisy): assert_parity(problem, grid, noise=noise, transverse=problem.transform.n_nodes) +def test_a_recomputed_flow_keeps_a_small_loss(): + """Only an exact zero's magnitude is one: a real part past it takes its exponential. + + A loss of a thousandth of a neper per metre puts about 6e-8 in each flow's exponent, + well above the parity bound and far below any bin a coarser test would notice. + """ + length = 1e-3 + grid = EnvelopeGrid.from_limits( + length, 800e-9, (600e-9, 1.2e-6), 150e-15, thg=False + ) + space = FreeGrid(3e-4, 9, 4e-4, 12, collar_fraction=0) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-7), 80e-6 + ) + schedule = uniform(length, 4) + problem = assemble( + grid, + None, + [KerrResponse(1e-22, thg=False)], + pulse.build(grid, space, medium=SpatialMedium()), + spatial_grid=space, + medium=SpatialMedium(), + controller=PIController(rtol=1e-8, atol=1e-14), + save_planes=[length], + dz_max=schedule.max_step, + dz_init=schedule.max_step, + schedule=schedule, + integrator=MARCH, + propagator_mode="recompute", + absorber=Absorber( + np.full(grid.n_omega, 1e-3), + np.zeros(grid.n_t), + np.zeros(grid.n_to), + grid.active_mask, + 5 * length, + ), + absorber_scheme="nonlinear", + ) + assert_parity(problem, grid, transverse=problem.transform.n_nodes) + + LINE_FAMILIES = ("field_kerr", "envelope_raman", "field_plasma", "field_kerr_collar") From fe9cc3d912769bd3e9fc6e1720d2d771a1596680 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 21:43:07 +0000 Subject: [PATCH 095/122] Name a test's pair list in full, for nvcc A CUDA build with its tests stopped here: nvcc can't deduce a list whose later members are bare braces. Found by the new compile job. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/tests/native/test_aperture.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/native/tests/native/test_aperture.cpp b/native/tests/native/test_aperture.cpp index b65c7be3..a518b184 100644 --- a/native/tests/native/test_aperture.cpp +++ b/native/tests/native/test_aperture.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -521,11 +522,13 @@ TEST(FarFieldPass, TheShapesAreTheirFormulas) { // The offset is X - x, so x >= X reads across <= 0, whichever side is asked for. EXPECT_EQ(native::far_field_offset(2.0, -3.0, 1.0), -5.0); const auto plane = outline(0.0, 0.0, 1.0, 1.0, 0.0); - for (const auto& [across, kept] : {std::pair{-1.0, 1.0}, - {-0.0, 1.0}, - {0.0, 1.0}, - {std::nextafter(0.0, 1.0), 0.0}, - {1.0, 0.0}}) { + // Named in full: nvcc can't deduce a list whose later members are bare braces. + for (const auto& [across, kept] : + std::initializer_list>{{-1.0, 1.0}, + {-0.0, 1.0}, + {0.0, 1.0}, + {std::nextafter(0.0, 1.0), 0.0}, + {1.0, 0.0}}) { SCOPED_TRACE(across); EXPECT_EQ(native::far_field_shaped_weight(across, 9.0, plane, native::far_field_half_plane_x_ge, 0), From cb2cb0681b8245887bb0bcb7dab401b21d3cbba4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 22:12:36 +0000 Subject: [PATCH 096/122] Keep a host's fold inlined beside the lossless branch The branch in propagator_exp took the tabulated low-storage fold's kernel past GCC's inlining limit on a host, though a tabulated fold never takes it: every pass 40 % slower, and kerr_beam_384 1.056 at nine threads on an Intel Xeon Gold 6240. Out of line on a host, where the library's exp was a call already, the passes are back to the merge base's and the step gains the collar's copies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/general.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/native/src/general.cpp b/native/src/general.cpp index 07048f9d..704a0236 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -691,12 +691,21 @@ KOKKOS_INLINE_FUNCTION void cosine_sine(const double theta, double& cosine, #endif } +// A flow's magnitude: on a lossless bin `exp(+-0)`, exactly one, taken without a +// binary64 `exp` per element. Out of line on a host, whose call to its library was out +// of line already: inlined there, this branch took the tabulated fold's whole kernel +// past the compiler's inlining limit, 40 % slower (solprop's docs/dev/benchmarks.md). +#if defined(__GNUC__) && !defined(KOKKOS_ENABLE_CUDA) && !defined(KOKKOS_ENABLE_HIP) +[[gnu::noinline]] +#endif +KOKKOS_INLINE_FUNCTION double flow_magnitude(const double real) { + return real == 0.0 ? 1.0 : Kokkos::exp(real); +} + // A stage or step propagator `exp(argument)`: `exponential` with the cosine and sine // from `cosine_sine`, so it is `exponential` to the bit on the host. KOKKOS_INLINE_FUNCTION Complex propagator_exp(const Complex argument) { - // A lossless bin's magnitude, `exp(+-0)`, is exactly one: a binary64 `exp` per - // element saved where the operator's real part vanishes. - const double magnitude = argument.real() == 0.0 ? 1.0 : Kokkos::exp(argument.real()); + const double magnitude = flow_magnitude(argument.real()); double cosine = 0.0; double sine = 0.0; cosine_sine(argument.imag(), cosine, sine); From d922206b655cba8772cb9cabca8132c8b91c44ff Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 22:17:28 +0000 Subject: [PATCH 097/122] Keep this work's new names private, as the maintainer decided Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/conf.py | 6 ++ docs/dev/backends.md | 12 +-- docs/dev/kernel-contract.md | 6 +- docs/dev/seps/sep-0044-native-edge-read.md | 2 + native/src/solprop_native/backend.py | 16 ++-- src/solprop/backends/driver.py | 8 +- src/solprop/backends/spatial.py | 8 +- src/solprop/spatial.py | 21 ++--- src/solprop/transverse_monitor.py | 105 ++++++++++----------- tests/backends/test_driver.py | 2 +- tests/backends/test_native_edge_collar.py | 10 +- tests/backends/test_native_edge_reading.py | 6 +- tests/backends/test_native_widths.py | 10 +- tests/test_transverse_reads.py | 4 +- 14 files changed, 107 insertions(+), 109 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4e3291ec..9360744f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -155,6 +155,12 @@ # which the shared stage and Adams functions take as a keyword and StepDriver # carries as a class attribute; private for the same reason. ("py:class", "solprop._helpers.Helpers"), + # Private to solprop and solprop-native, released together, and named in the public + # `TransverseEdge`'s signatures. + ("py:class", "solprop.transverse_monitor._EdgeLine"), + ("py:class", "solprop.transverse_monitor._EdgeReading"), + ("py:class", "solprop.transverse_monitor._EdgeTables"), + ("py:class", "solprop.transverse_monitor._HostEdgeReduction"), ("py:class", "Helpers"), # MLX publishes no inventory Sphinx can resolve (the mlx backend, SEP 33). ("py:class", "mlx.core.array"), diff --git a/docs/dev/backends.md b/docs/dev/backends.md index 8bb37ee7..b0aa6b44 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -489,14 +489,14 @@ as one; its end-of-plane log is retired, and the boundary monitor's transverse e **The transverse edge is read where the state lives** ([SEP 40](seps/archive/sep-0040-transverse-absorbing-boundaries.md), [SEP 44](seps/sep-0044-native-edge-read.md)). A backend whose `StepDriver.reads_transverse_edge` is set hands the monitor's -`TransverseEdge` an `EdgeReading` from `StepDriver._edge_reading` at the edge's reads, at saved planes, and with a +`TransverseEdge` an `_EdgeReading` from `StepDriver._edge_reading` at the edge's reads, at saved planes, and with a requested collar at every committed step. The reading is numbers and never the field: region sums, each -column's energy and any collar's strip and current. The default forms them with `HostEdgeReduction` on a host copy, +column's energy and any collar's strip and current. The default forms them with `_HostEdgeReduction` on a host copy, the reference, which `numpy_ref` and `mlx` use. `native` forms them on its own state against -the edge's `EdgeTables`, four regions' weights on the nodes, so it holds no face, mask or judgement, and a +the edge's `_EdgeTables`, four regions' weights on the nodes, so it holds no face, mask or judgement, and a field-free run crosses no state (`tests/backends/test_native_edge_reading.py`). A requested collar's lines are described -once, as `edge_lines`, for both reductions (`tests/backends/test_native_edge_collar.py`): native takes each strip's padded -transform in batches of planes under `STRIP_BATCH_BYTES`, on buffers listed in the `transverse_edge` ledger row, and the +once, as `_edge_lines`, for both reductions (`tests/backends/test_native_edge_collar.py`): native takes each strip's padded +transform in batches of planes under `_STRIP_BATCH_BYTES`, on buffers listed in the `transverse_edge` ledger row, and the current from the onset's three nodes, rectified per row, all on one load of the transverse scratch. The current is still accumulated in Python. Everything else, the weights, the spacing, the ledgers and the judgement, is one code on every backend. The read spacing is a ceiling on the step, and @@ -1231,7 +1231,7 @@ The axis's fluence, which only a radial width reads, isn't formed. (`solprop.spatial.cartesian_fluence_widths`): a centroid, a second moment, and an azimuthal average of the bilinear interpolant on a few thousand angles per ring, about 3.7 million samples at 768 x 768. Native forms each as a kernel over the fluence it holds, so that the moments and one value -per ring cross, and solprop keeps the rule: `solprop.spatial.fluence_rings` fixes the rings, +per ring cross, and solprop keeps the rule: `solprop.spatial._fluence_rings` fixes the rings, `backends.spatial.plane_fluence_widths` reads the refusal and the widths. A line's and a planar fluence are short profiles, and these still cross. diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md index 3727c8d7..c01604d9 100644 --- a/docs/dev/kernel-contract.md +++ b/docs/dev/kernel-contract.md @@ -229,13 +229,13 @@ in both directions; a second test checks the flags and counts named in the `flag against the fields of `Flags`. **The transverse edge.** `StepDriver._edge_reading(state, edge, *, faces, current, full)` -returns an `EdgeReading` ([SEP 44](seps/sep-0044-native-edge-read.md)): region sums, each column's unweighted energy, each +returns an `_EdgeReading` ([SEP 44](seps/sep-0044-native-edge-read.md)): region sums, each column's unweighted energy, each line's `(inward, windowed)` strip and the summed rectified current, never the field. The default forms them with -`HostEdgeReduction` on a host copy. An override takes the edge's `EdgeTables` and `EdgeLine`s, whose weights, strides, +`_HostEdgeReduction` on a host copy. An override takes the edge's `_EdgeTables` and `_EdgeLine`s, whose weights, strides, windows and onsets say all there is to know about the geometry, so a backend holds no face, mask or judgement of its own. It reads the state at a committed position, so it may use scratch that a step writes before reading, and it must leave the march as it was. `StepDriver.edge_memory(space)` says what binding holds, from the grid alone, for `estimate`: the default holds -nothing beyond the state, and native's is `transverse_edge_bytes`, equal to its `transverse_edge` ledger row. +nothing beyond the state, and native's is `_transverse_edge_bytes`, equal to its `transverse_edge` ledger row. **Source reservations in the shared Adams march.** The protected hooks `StepDriver._reserve_source_calls(count)` and `StepDriver._release_source_calls()` diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index ea672bd4..10c1a7dd 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -495,6 +495,8 @@ SEP 40's four-way control, `python -m benchmarks.edge_overhead`, native at 18 th **A runner's points.** §6 suggested sharing the tables across points. At 512 x 512 x 600 a point's monitor takes 21 ms and its bind 16 ms, against a step of 1.76 s, so I left it. +**Names.** By the maintainer's decision, the edge's new names are private, with a leading underscore, since `solprop` and `solprop-native` are released together: `_EdgeReading`, `_EdgeTables`, `_EdgeLine`, `_HostEdgeReduction`, `_edge_lines`, `_transverse_edge_bytes` and `_STRIP_BATCH_BYTES`. Part I and the reports above use their earlier spellings. + **Not yet done.** Phase 4's device qualification; the rest of §8's documents and the final ledger entry, phase 5. **Open.** Whether to approve a rental for phase 4. diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 1a7552fc..6baaa470 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -144,9 +144,9 @@ ) from solprop.translate.tdse import TDSEPurpose from solprop.transverse_monitor import ( - EdgeReading, TransverseEdge, - transverse_edge_bytes, + _EdgeReading, + _transverse_edge_bytes, ) from . import ( @@ -1178,7 +1178,7 @@ def _bind_edge_lines(self, edge: TransverseEdge) -> None: np.sqrt(np.asarray(space.r, dtype=np.float64)) if lines and isinstance(space, RadialGrid) else np.empty(0, dtype=np.float64), - transverse_monitor.STRIP_BATCH_BYTES, + transverse_monitor._STRIP_BATCH_BYTES, ) def edge_memory(self, space: object) -> int: @@ -1195,7 +1195,7 @@ def edge_memory(self, space: object) -> int: The bytes of a `transverse_edge` ledger row, from the grid alone. """ _, _, n_pol, n_omega = self._problem.shape - return transverse_edge_bytes( + return _transverse_edge_bytes( cast("Any", space), self._problem.transverse, n_pol, n_omega ) @@ -1207,7 +1207,7 @@ def _edge_reading( faces: bool = True, current: bool = False, full: bool = False, - ) -> EdgeReading: + ) -> _EdgeReading: """Read the state for the transverse edge where it lives (solprop SEP 44). Parameters @@ -1225,7 +1225,7 @@ def _edge_reading( Returns ------- - solprop.transverse_monitor.EdgeReading + solprop.transverse_monitor._EdgeReading The reading, of which only numbers crossed. A requested collar still reduces a host copy of the state. """ @@ -1237,13 +1237,13 @@ def _edge_reading( ) rate_value = float(rate) if current else None if not faces: - return EdgeReading(current=rate_value) + return _EdgeReading(current=rate_value) values = np.asarray(sums).reshape(-1) if full: clip, totals = values[4:8], values[8:12] else: clip, totals = None, values[4:8] if collar else None - return EdgeReading( + return _EdgeReading( faces=values[:4], clip=clip, totals=totals, diff --git a/src/solprop/backends/driver.py b/src/solprop/backends/driver.py index 7007a1f6..8beb79ee 100644 --- a/src/solprop/backends/driver.py +++ b/src/solprop/backends/driver.py @@ -77,7 +77,7 @@ terms, ) from solprop.translate.tdse import TDSEPurpose -from solprop.transverse_monitor import EdgeReading, TransverseEdge +from solprop.transverse_monitor import TransverseEdge, _EdgeReading if TYPE_CHECKING: from solprop.backends.low_storage import LowStorageMarch @@ -1992,11 +1992,11 @@ def _edge_reading( faces: bool = True, current: bool = False, full: bool = False, - ) -> EdgeReading: + ) -> _EdgeReading: """Return what the transverse edge asks of a state (SEP 44). The default reduces a host copy of the field, the reference - (:class:`~solprop.transverse_monitor.HostEdgeReduction`); a backend whose state + (``_HostEdgeReduction``); a backend whose state lives elsewhere reduces it there and returns the numbers, without a copy of it. Parameters @@ -2014,7 +2014,7 @@ def _edge_reading( Returns ------- - solprop.transverse_monitor.EdgeReading + solprop.transverse_monitor._EdgeReading The reading. """ return edge.reduction.reading( diff --git a/src/solprop/backends/spatial.py b/src/solprop/backends/spatial.py index b25d319e..d347aebd 100644 --- a/src/solprop/backends/spatial.py +++ b/src/solprop/backends/spatial.py @@ -21,11 +21,11 @@ FAR_FIELD_STATISTICS, OPERATOR_STATISTICS, ApertureTables, + _fluence_rings, + _ring_fluence_widths, aperture_statistics, cartesian_fluence_widths, - fluence_rings, radial_fluence_widths, - ring_fluence_widths, ) from solprop.stats import PROFILE_STATISTICS, StatsSpec, analytic_field, profile_value from solprop.translate.modal import Transform @@ -861,7 +861,7 @@ def plane_fluence_widths( extent = math.hypot( max(center_x - x[0], x[-1] - center_x), max(center_y - y[0], y[-1] - center_y) ) - rings = fluence_rings(x, y, center_x, center_y) + rings = _fluence_rings(x, y, center_x, center_y) empty = np.empty(0) packed = trace( center_x - float(x[0]), @@ -881,7 +881,7 @@ def plane_fluence_widths( raise GridError(msg) if rings is None: return second, math.nan, math.nan - return second, *ring_fluence_widths(rings.radii, values[1:]) + return second, *_ring_fluence_widths(rings.radii, values[1:]) def _negative_fluence(value: float, node: int) -> BackendError: diff --git a/src/solprop/spatial.py b/src/solprop/spatial.py index 822b7f46..8b372ac3 100644 --- a/src/solprop/spatial.py +++ b/src/solprop/spatial.py @@ -54,7 +54,6 @@ "ApertureShape", "ApertureTables", "AxialFactors", - "FluenceRings", "FluxSide", "SpatialDispersion", "SpatialFlux", @@ -70,7 +69,6 @@ "check_aperture_shape", "critical_power", "edge_profile", - "fluence_rings", "fwhm_r", "mask_plane_offsets", "mask_plane_radius", @@ -85,7 +83,6 @@ "power_trace", "quadrature_half_width", "radial_fluence_widths", - "ring_fluence_widths", "spatial_energy", "spatial_energy_spectrum", "spatial_fluence", @@ -3186,7 +3183,7 @@ def cartesian_fluence_widths( if not math.isfinite(second): msg = "Cartesian fluence moments are not representable" raise GridError(msg) - rings = fluence_rings(x, y, center_x, center_y) + rings = _fluence_rings(x, y, center_x, center_y) if rings is None: return second, math.nan, math.nan averaged = np.empty(rings.radii.size) @@ -3204,11 +3201,11 @@ def cartesian_fluence_widths( (1 - fx) * samples[iy, ix] + fx * samples[iy, ix + 1] ) + fy * ((1 - fx) * samples[iy + 1, ix] + fx * samples[iy + 1, ix + 1]) averaged[index] = np.mean(interpolated) - return second, *ring_fluence_widths(rings.radii, averaged) + return second, *_ring_fluence_widths(rings.radii, averaged) @dataclass(frozen=True) -class FluenceRings: +class _FluenceRings: """The rings on which a Cartesian fluence is averaged about its centroid. Attributes @@ -3224,12 +3221,12 @@ class FluenceRings: sines: Float[np.ndarray, " n_angles"] -def fluence_rings( +def _fluence_rings( x: Float[np.ndarray, " n_x"], y: Float[np.ndarray, " n_y"], center_x: float, center_y: float, -) -> FluenceRings | None: +) -> _FluenceRings | None: """Return the rings of :func:`cartesian_fluence_widths` about a centroid. Half the finest pixel spacing in radius, out to the largest circle about the @@ -3245,7 +3242,7 @@ def fluence_rings( Returns ------- - FluenceRings or None + _FluenceRings or None The rings, or ``None`` when the centroid lies within two half-spacings of an edge, where the threshold widths are undefined. """ @@ -3260,10 +3257,10 @@ def fluence_rings( largest_circle = min(x[-1] - x[0], y[-1] - y[0]) / 2 n_angles = max(8, math.ceil(2 * np.pi * largest_circle / spacing)) angles = 2 * np.pi * np.arange(n_angles) / n_angles - return FluenceRings(radii, np.cos(angles), np.sin(angles)) + return _FluenceRings(radii, np.cos(angles), np.sin(angles)) -def ring_fluence_widths( +def _ring_fluence_widths( radii: Float[np.ndarray, " n_radii"], averaged: Float[np.ndarray, " n_radii"] ) -> tuple[float, float]: """Return the threshold widths of a fluence averaged on its rings. @@ -3271,7 +3268,7 @@ def ring_fluence_widths( Parameters ---------- radii : numpy.ndarray - The rings' radii in m, the first zero (:func:`fluence_rings`). + The rings' radii in m, the first zero (``_fluence_rings``). averaged : numpy.ndarray The nonnegative fluence averaged on each ring. diff --git a/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py index c5c6b03f..2acc10b2 100644 --- a/src/solprop/transverse_monitor.py +++ b/src/solprop/transverse_monitor.py @@ -19,7 +19,7 @@ Every run reports a ``transverse_status``; only ``clear`` is a clean verdict. A backend reduces its committed spectral state where the state lives and hands the edge -an :class:`EdgeReading`, numbers and never the field. :class:`HostEdgeReduction` is the +an ``_EdgeReading``, numbers and never the field. ``_HostEdgeReduction`` is the reference, on a host copy of the state, and everything else here is the same on every backend: the faces, the weights, the spacing, the ledgers and the judgement (SEP 44). A backend that does not read the edge leaves the run ``unwatched``, which it says before @@ -48,15 +48,8 @@ "GUARD", "NOISE_FACTOR", "READS", - "STRIP_BATCH_BYTES", - "EdgeLine", - "EdgeReading", - "EdgeTables", - "HostEdgeReduction", "TransverseEdge", "TransverseStatus", - "edge_lines", - "transverse_edge_bytes", ] logger = logging.getLogger(__name__) @@ -79,7 +72,7 @@ @dataclass(frozen=True, eq=False) -class EdgeTables: +class _EdgeTables: """The weights a state's node energies are summed with, region by region. Each table holds four regions' weights on the nodes (or rows), so that a backend @@ -107,18 +100,18 @@ class EdgeTables: @dataclass(frozen=True, eq=False) -class EdgeReading: +class _EdgeReading: """What a backend reads of its state for the edge: numbers, never the field. Attributes ---------- faces : numpy.ndarray or None - ``EdgeTables.faces @ nodes``, shape ``(4,)``; ``None`` when only the current was - asked for. + ``_EdgeTables.faces @ nodes``, shape ``(4,)``; ``None`` when only the current + was asked for. clip : numpy.ndarray or None - ``EdgeTables.clip @ nodes``, for a launch. + ``_EdgeTables.clip @ nodes``, for a launch. totals : numpy.ndarray or None - ``EdgeTables.totals @ nodes``, for a launch or a collar's read. A collar's read + ``_EdgeTables.totals @ nodes``, for a launch or a collar's read. A collar's read uses only the rate, ``[1]``: over the rows a read touches, which hold all of it, the other three can be a different sum on different backends. columns : numpy.ndarray or None @@ -274,7 +267,7 @@ def _guard_width(space: RadialGrid | FreeGrid | PlanarGrid) -> float: @dataclass(frozen=True, eq=False) -class EdgeLine: +class _EdgeLine: """A face's outward normal, as a requested collar's reductions walk it. One description for the host's reduction and native's, so that they differ only in @@ -341,7 +334,7 @@ def _edge_line( measure: float, bounds: tuple[float, float], size: float, -) -> EdgeLine: +) -> _EdgeLine: """Return one line's description, from its outward coordinate. Parameters @@ -359,7 +352,7 @@ def _edge_line( Returns ------- - solprop.transverse_monitor.EdgeLine + solprop.transverse_monitor._EdgeLine The description. """ a, b = bounds @@ -391,12 +384,12 @@ def _edge_line( before, after = int(order[position - 1]), int(order[position + 1]) onset = (node, before, after) onset_step = float(s[after] - s[before]) - return EdgeLine(axis, measure, strip, window, spacing, inward, onset, onset_step) + return _EdgeLine(axis, measure, strip, window, spacing, inward, onset, onset_step) -def edge_lines( +def _edge_lines( space: RadialGrid | FreeGrid | PlanarGrid, design: SpatialAbsorberDesign | None -) -> tuple[EdgeLine, ...]: +) -> tuple[_EdgeLine, ...]: """Return the lines a requested collar's strip and current run along. Parameters @@ -408,7 +401,7 @@ def edge_lines( Returns ------- - tuple of solprop.transverse_monitor.EdgeLine + tuple of solprop.transverse_monitor._EdgeLine One per direction along each axis; none without a collar. """ if design is None or not design.has_collar: @@ -440,12 +433,12 @@ def edge_lines( return tuple(lines) -STRIP_BATCH_BYTES = 2**28 +_STRIP_BATCH_BYTES = 2**28 """The most a batch of a strip's padded transforms holds on the state's memory space.""" def _line_rows( - space: RadialGrid | FreeGrid | PlanarGrid, line: EdgeLine, n_pol: int, n_omega: int + space: RadialGrid | FreeGrid | PlanarGrid, line: _EdgeLine, n_pol: int, n_omega: int ) -> int: """Return how many rows of values a line carries. @@ -453,7 +446,7 @@ def _line_rows( ---------- space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid The run's grid. - line : solprop.transverse_monitor.EdgeLine + line : solprop.transverse_monitor._EdgeLine The line. n_pol, n_omega : int The state's polarisations and bins. @@ -472,12 +465,12 @@ def _line_rows( return planes * (space.n_y if line.axis == "x" else space.n_x) -def _strip_batch(line: EdgeLine, rows: int) -> int: +def _strip_batch(line: _EdgeLine, rows: int) -> int: """Return how many of a line's rows one batch of its padded transform takes. Parameters ---------- - line : solprop.transverse_monitor.EdgeLine + line : solprop.transverse_monitor._EdgeLine The line, with nodes in its strip. rows : int Its rows in all. @@ -485,12 +478,12 @@ def _strip_batch(line: EdgeLine, rows: int) -> int: Returns ------- int - The rows in a batch, under :data:`STRIP_BATCH_BYTES`. + The rows in a batch, under ``_STRIP_BATCH_BYTES``. """ - return min(rows, max(1, STRIP_BATCH_BYTES // (16 * 2 * line.strip.size))) + return min(rows, max(1, _STRIP_BATCH_BYTES // (16 * 2 * line.strip.size))) -def transverse_edge_bytes( +def _transverse_edge_bytes( space: RadialGrid | FreeGrid | PlanarGrid, design: SpatialAbsorberDesign | None, n_pol: int, @@ -522,7 +515,7 @@ def transverse_edge_bytes( else: nodes = space.n_y if isinstance(space, PlanarGrid) else space.n_y * space.n_x total = 8 * (12 * nodes + columns) - lines = edge_lines(space, design) + lines = _edge_lines(space, design) if isinstance(space, RadialGrid) and lines: total += 8 * space.n_r # the scale of the radial line's values # The strips run in turn, on one buffer as large as the largest batch needs. @@ -555,7 +548,7 @@ def _radial_inverse(space: RadialGrid) -> Float[np.ndarray, "n_r n_r"]: @dataclass -class HostEdgeReduction: +class _HostEdgeReduction: """The reference reductions of the edge, on a host copy of a state (SEP 44). A backend whose state lives elsewhere forms the same numbers there instead @@ -565,17 +558,17 @@ class HostEdgeReduction: ---------- space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid The run's grid. - tables : solprop.transverse_monitor.EdgeTables + tables : solprop.transverse_monitor._EdgeTables What a state's node energies are summed with. - lines : tuple of solprop.transverse_monitor.EdgeLine + lines : tuple of solprop.transverse_monitor._EdgeLine A requested collar's lines; none without one. k_min : float The smallest wavenumber over the requested band, rad/m. """ space: RadialGrid | FreeGrid | PlanarGrid - tables: EdgeTables - lines: tuple[EdgeLine, ...] + tables: _EdgeTables + lines: tuple[_EdgeLine, ...] k_min: float _inverse: np.ndarray | None = field(default=None, init=False) # The host's transverse transforms take the process's thread budget (ADR-052). @@ -588,7 +581,7 @@ def reading( faces: bool = True, current: bool = False, full: bool = False, - ) -> EdgeReading: + ) -> _EdgeReading: """Return what the edge asked of a state. Parameters @@ -604,15 +597,15 @@ def reading( Returns ------- - solprop.transverse_monitor.EdgeReading + solprop.transverse_monitor._EdgeReading The reading. """ rate = self.current(state) if current else None if not faces: - return EdgeReading(current=rate) + return _EdgeReading(current=rate) nodes = self.node_power(state, full=full) collar = bool(self.lines) - return EdgeReading( + return _EdgeReading( faces=self.tables.faces @ nodes, clip=self.tables.clip @ nodes if full else None, totals=self.tables.totals @ nodes if full or collar else None, @@ -887,8 +880,8 @@ class TransverseEdge: _current_baseline: float = field(default=0.0, init=False) _returned_peak: float = field(default=0.0, init=False) _kappa_order: np.ndarray = field(init=False) - tables: EdgeTables = field(init=False) - reduction: HostEdgeReduction = field(init=False) + tables: _EdgeTables = field(init=False) + reduction: _HostEdgeReduction = field(init=False) def __post_init__(self) -> None: """Build the faces, the node weights and the column wavenumbers.""" @@ -910,16 +903,16 @@ def __post_init__(self) -> None: self._peak_raw = [0.0] * len(self._faces) self._baseline = [0.0] * len(self._faces) self.tables = self._edge_tables() - self.reduction = HostEdgeReduction( - space, self.tables, edge_lines(space, self.design), self.k_min + self.reduction = _HostEdgeReduction( + space, self.tables, _edge_lines(space, self.design), self.k_min ) - def _edge_tables(self) -> EdgeTables: + def _edge_tables(self) -> _EdgeTables: """Return the weights a state's node energies are summed with. Returns ------- - solprop.transverse_monitor.EdgeTables + solprop.transverse_monitor._EdgeTables The tables, over the grid's nodes (or rows). """ weights = self._weights @@ -939,7 +932,7 @@ def _edge_tables(self) -> EdgeTables: if isinstance(self.space, RadialGrid): outside = np.asarray(self.space.r) > _requested_size(self.space) totals[2] = weights * outside - return EdgeTables(faces, clip, totals, self._rows_read()) + return _EdgeTables(faces, clip, totals, self._rows_read()) def _rows_read(self) -> np.ndarray: """Return the nodes (or rows) a read uses: the faces' and a collar's. @@ -1003,7 +996,7 @@ def resumed(self) -> bool: def launch( # noqa: PLR0912, PLR0913, PLR0915 - the launch and judgements, in order self, - reading: EdgeReading, + reading: _EdgeReading, z: float, *, noise: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, @@ -1016,7 +1009,7 @@ def launch( # noqa: PLR0912, PLR0913, PLR0915 - the launch and judgements, in o Parameters ---------- - reading : solprop.transverse_monitor.EdgeReading + reading : solprop.transverse_monitor._EdgeReading The launched state's reading: every node, unless :attr:`resumed`. z : float Its position, m. @@ -1195,12 +1188,12 @@ def _judge_ramp_band( stacklevel=5, ) - def _collar_rate(self, reading: EdgeReading) -> float: + def _collar_rate(self, reading: _EdgeReading) -> float: """Return a requested collar's removal rate, as a share of the launch per m. Parameters ---------- - reading : solprop.transverse_monitor.EdgeReading + reading : solprop.transverse_monitor._EdgeReading The state's reading. Returns @@ -1302,12 +1295,12 @@ def wants_read(self, z: float) -> bool: """ return self._launch > 0.0 and self._status != "unwatched" and z > self._last_z - def read(self, reading: EdgeReading, z: float) -> None: + def read(self, reading: _EdgeReading, z: float) -> None: """Take a reading at a committed position, credit the removals and judge. Parameters ---------- - reading : solprop.transverse_monitor.EdgeReading + reading : solprop.transverse_monitor._EdgeReading The state's reading. z : float Its position, m. @@ -1452,14 +1445,14 @@ def every_step(self) -> bool: def _launch_return( self, - reading: EdgeReading, + reading: _EdgeReading, noise: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, ) -> None: """Record a collar's launch baselines and floors (section 5.3). Parameters ---------- - reading : solprop.transverse_monitor.EdgeReading + reading : solprop.transverse_monitor._EdgeReading The launched state's reading. noise : numpy.ndarray or None Its input noise. @@ -1474,12 +1467,12 @@ def _launch_return( self._current_last = None self._current = 0.0 - def _judge_return(self, reading: EdgeReading, z: float) -> None: + def _judge_return(self, reading: _EdgeReading, z: float) -> None: """Judge what a collar returns: the strip and the current (section 5.3). Parameters ---------- - reading : solprop.transverse_monitor.EdgeReading + reading : solprop.transverse_monitor._EdgeReading The state's reading. z : float Its position, m. diff --git a/tests/backends/test_driver.py b/tests/backends/test_driver.py index 46fe16d1..7e74c785 100644 --- a/tests/backends/test_driver.py +++ b/tests/backends/test_driver.py @@ -873,7 +873,7 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): "_edge_reading": ( "(self, state: _StateT, edge: solprop.transverse_monitor.TransverseEdge, *, " "faces: bool = True, current: bool = False, full: bool = False" - ") -> solprop.transverse_monitor.EdgeReading" + ") -> solprop.transverse_monitor._EdgeReading" ), "_next_step": "(self) -> float", "_observed_field": ( diff --git a/tests/backends/test_native_edge_collar.py b/tests/backends/test_native_edge_collar.py index a8e5840a..5fb99bd8 100644 --- a/tests/backends/test_native_edge_collar.py +++ b/tests/backends/test_native_edge_collar.py @@ -366,7 +366,7 @@ def test_a_small_batch_reads_what_one_batch_reads( whole = backend._edge_reading(backend._require_state(), edge, full=True) finally: backend.close() - monkeypatch.setattr(transverse_monitor, "STRIP_BATCH_BYTES", 2**12) + monkeypatch.setattr(transverse_monitor, "_STRIP_BATCH_BYTES", 2**12) backend, edge = _attached(simulation, state) try: small = backend._edge_reading(backend._require_state(), edge, full=True) @@ -384,7 +384,7 @@ def test_a_collars_memory_is_its_row(route: str) -> None: backend, edge = _attached(simulation, state) try: _, _, n_pol, n_omega = problem.shape - expected = transverse_monitor.transverse_edge_bytes( + expected = transverse_monitor._transverse_edge_bytes( edge.space, edge.design, n_pol, n_omega ) assert backend.memory_ledger()["transverse_edge"] == expected @@ -822,21 +822,21 @@ def test_a_strip_of_too_few_nodes_reads_nothing_on_either_side( simulation = _case("line") problem = simulation.translate() state = _noise(problem, 101) - lines = transverse_monitor.edge_lines + lines = transverse_monitor._edge_lines def with_an_empty_line(*args: Any) -> tuple[Any, ...]: found = lines(*args) short = np.array([1.0, 2.0, 3.0]) return (*found, transverse_monitor._edge_line("x", short, 1.0, (0.0, 4.0), 2.0)) - monkeypatch.setattr(transverse_monitor, "edge_lines", with_an_empty_line) + monkeypatch.setattr(transverse_monitor, "_edge_lines", with_an_empty_line) backend, edge = _attached(simulation, state) try: read = backend._edge_reading(backend._require_state(), edge, full=True) held = edge.reduction.reading(state[0], full=True) _, _, n_pol, n_omega = problem.shape assert backend.memory_ledger()["transverse_edge"] == ( - transverse_monitor.transverse_edge_bytes( + transverse_monitor._transverse_edge_bytes( edge.space, edge.design, n_pol, n_omega ) ) diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index 318f7362..1c4c340e 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -35,7 +35,7 @@ PIController, RungeKutta, ) -from solprop.transverse_monitor import GUARD, EdgeReading, transverse_edge_bytes +from solprop.transverse_monitor import GUARD, _EdgeReading, _transverse_edge_bytes from test_transverse_reads import REREVIEW, packet_run pytestmark = pytest.mark.skipif( @@ -89,7 +89,7 @@ def _attached(simulation: Simulation, state: np.ndarray) -> Any: def _read_both( route: str, *, full: bool -) -> tuple[EdgeReading, EdgeReading, np.ndarray]: +) -> tuple[_EdgeReading, _EdgeReading, np.ndarray]: """Return native's and the host's reading of one state, and the state.""" simulation = _case(route) problem = simulation.translate() @@ -547,7 +547,7 @@ def test_the_edge_holds_memory_only_once_a_monitor_binds_it(route: str) -> None: assert monitor.transverse is not None backend._bind_monitor(monitor) _, _, n_pol, n_omega = problem.shape - expected = transverse_edge_bytes( + expected = _transverse_edge_bytes( monitor.transverse.space, monitor.transverse.design, n_pol, n_omega ) assert backend.memory_ledger()["transverse_edge"] == expected diff --git a/tests/backends/test_native_widths.py b/tests/backends/test_native_widths.py index da86cde8..8909e54e 100644 --- a/tests/backends/test_native_widths.py +++ b/tests/backends/test_native_widths.py @@ -33,9 +33,9 @@ from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import ( SpatialMedium, + _fluence_rings, + _ring_fluence_widths, cartesian_fluence_widths, - fluence_rings, - ring_fluence_widths, ) from solprop.stats import StatsSpec from solprop.stepper import PIController, RungeKutta @@ -661,16 +661,16 @@ def test_plane_widths_at_an_edge_have_only_a_moment(): def test_the_rings_are_the_host_widths_rule(): x = np.linspace(-1.0, 1.0, 9) y = np.linspace(-2.0, 2.0, 17) - rings = fluence_rings(x, y, 0.0, 0.0) + rings = _fluence_rings(x, y, 0.0, 0.0) assert rings is not None # Half the finest spacing, to the largest circle inside the axes about the centroid. assert rings.radii[1] == 0.125 assert rings.radii[-1] == 1.0 assert rings.cosines.size == rings.sines.size >= 8 # A centroid within two half-spacings of an edge has none. - assert fluence_rings(x, y, 0.9, 0.0) is None + assert _fluence_rings(x, y, 0.9, 0.0) is None # A Gaussian trace: its 1/e^2 radius and its FWHM, to the interpolant's accuracy. radii = np.linspace(0.0, 4.0, 4001) - radius, width = ring_fluence_widths(radii, np.exp(-2 * radii**2)) + radius, width = _ring_fluence_widths(radii, np.exp(-2 * radii**2)) assert radius == pytest.approx(1.0, abs=1e-6) assert width == pytest.approx(np.sqrt(2 * np.log(2)), abs=1e-6) diff --git a/tests/test_transverse_reads.py b/tests/test_transverse_reads.py index 6f25fa03..4bc7ae3b 100644 --- a/tests/test_transverse_reads.py +++ b/tests/test_transverse_reads.py @@ -44,7 +44,7 @@ from solprop.pulses import GaussianPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, Multistep, PIController, RungeKutta -from solprop.transverse_monitor import EdgeReading +from solprop.transverse_monitor import _EdgeReading BACKENDS = ("numpy_ref", "native") RELAXED = BoundaryCheck(budget=1.0, transverse_budget=1.0) @@ -424,7 +424,7 @@ def test_a_plane_the_step_already_read_reduces_nothing_more( driver = cast("type[Any]", _driver_class(backend)) edge_reading = driver._edge_reading - def counting(self: Any, *args: Any, **keywords: Any) -> EdgeReading: + def counting(self: Any, *args: Any, **keywords: Any) -> _EdgeReading: calls["readings"] += 1 return edge_reading(self, *args, **keywords) From cc4570aaa18a634a085732b6cbce81126eeb0f85 Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 22:39:14 +0000 Subject: [PATCH 098/122] Ledger: a device step beyond its transforms With its release note, and the block test's choices held on any build: the CPU's stays the CPU's on a device build, and a stated budget equal to the default still counts as stated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/device-split-collar.changed.md | 1 + docs/dev/benchmarks.md | 64 ++++++++++++++++++++++ tests/backends/test_free_split_rows.py | 15 ++++- 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 changelog.d/device-split-collar.changed.md diff --git a/changelog.d/device-split-collar.changed.md b/changelog.d/device-split-collar.changed.md new file mode 100644 index 00000000..0ab89a84 --- /dev/null +++ b/changelog.d/device-split-collar.changed.md @@ -0,0 +1 @@ +A device's low-storage step costs less beyond its transforms. A free-space envelope's split-step temporal collar now transforms the state's own rows in place and holds no coarse buffers, a whole state at a time on a device unless `node_budget` is stated (106 chunks of 5 kernels become one block at 768 x 768 x 600); a flow's lossless bin takes no exponential; and a device's first input crosses to the scratch through tiles. On an NVIDIA A40 that step falls from 2.66 s to 2.56 s, with the fields unchanged to rounding. `Transform` has a new field, `chunk_budgeted`. diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 078a8d2a..2f00c43c 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2170,6 +2170,70 @@ Xeon Gold 6240 (18 cores of one socket, passive threads, three rounds of one rep at one thread and 0.882 at sixteen, `radial_filament_256` 0.999 and 0.994, `line` 0.983 and 0.990, `planar` 0.997 and 0.980; geometric mean 0.975, fields to 4.2e-15, the same work. +### A device step beyond its transforms + +A low-storage step of the Kerr beam of `python -m benchmarks.device_split` at 768 x 768 x 600 with no boundary check, as `LowStorageRungeKutta` runs it on the Cartesian +route, was 239 ms on an NVIDIA H200 and 224 ms on an AMD Instinct MI325X, 112 to 144 ms of it in its eight rank-3 transforms. These changes take what lies beside them. +**No data-centre card was run for this entry.** Its numbers are launch counts, which do not depend on the card, and times on an NVIDIA A40 (CUDA 12.8.1, `AMPERE86`, an +AMD EPYC 7543 host, 16 threads), whose binary64 rate is a sixty-fourth of its binary32 rate, so its share of each kernel says little about an H200. They were taken at +`cb2cb068` against the merge base `43ddefab`, by a Kokkos Tools trace of that one case with a fence around each kernel. + +| Per step, 768 x 768 x 600 | Before | Collar in place | A whole state on a device | +|---|---:|---:|---:| +| Kernels | 330 | 119 | 14 | +| Transforms | 220 | 220 | 10 | + +| NVIDIA A40, ms per step | Before | After | +|---|---:|---:| +| Eight rank-3 transforms | 1,840 | 1,840 | +| The collar's transforms | 142 | 136 | +| The collar's copies in and out | 41.4 | none | +| The collar's factor and band mask | 21.0 | 22.0 | +| Fold, passes 1 to 4 | 134, 64, 129, 123 | 121, 62, 115, 109 | +| Kerr | 118 | 118 | +| Input | 42.5 | 24.9 | +| Whole step, no fences | 2,660 | 2,557 | + +The whole step takes 0.961 of its time at 768 x 768 x 600 and 0.956 at 384 x 384 x 600 (589 to 563 ms): three interleaved rounds of fresh processes, whose medians spread by +0.1 %. `python -m benchmarks ab --device --length 0.3` against the merge base, three rounds, the same steps: `kerr_beam_384` 0.974 (0.972–0.995), `kerr_beam_192` 1.030 +(0.954–1.036, a window of two steps, inside its spread), fields to 3.3e-16; `rdw` 1.000, `he_rdw_averaged` 1.002, `radial_filament_256` 1.000, fields bitwise. + +**The collar** depends on time alone, so its transform pair runs in place on the state's own rows, with the normalisation on the factor. Most of the A40's gain is the two +copies gone at the node chunk: 2,578.6 ms a step, against 2,574.6 for a whole state on the same build. What a whole state saves is launches, and each rocFFT transform is four +kernels, so what they are worth takes a data-centre card to say. cuFFT's work area is small here: at 589,824 rows of 600 points each plan holds 9 MiB, in place or +not, 0.1 MiB at the node chunk. The plans' device growth rises by 20 MiB and the two coarse buffers' 102 MiB go, so a device run's default memory falls by 82 MiB of its 38.5 GiB. What rocFFT holds was not measured. A stated `node_budget` +keeps the block to the chunk. A CPU keeps the chunk too: on an Intel Xeon Gold 6240, nine cores, at 384 x 384 x 600, FFTW's threaded plan of a whole state took +2.5 s a step in transforms against 1.5 s. + +**The fold.** A flow's magnitude on a lossless bin is `exp(+-0)`, exactly one, and took a binary64 `exp` per element: 13.4 ms a pass on the A40, whose reduced binary64 rate +overstates it. Runge-Kutta's `stage_input` and `finish_transform` fall by 13 % the same way. The flowless pass forms no exponent (64.3 to 61.5 ms). The first input crosses the +plane's stride through tiles of team scratch (42.5 to 24.9 ms). On a host the branch is out of line: inlined there, it took the tabulated fold's kernel past GCC's inlining limit, +though a tabulated fold never takes it, every pass 40 % slower and `kerr_beam_384` 1.056. + +**Dropped, for no gain on the A40.** Pass 3's dead read of register A: the fourth pass took 13.5 ms less against 13.4 for the other flows' `exp`, so the read is worth 0.1 ms. +Folding `finish` into the step: the mask it would fold is 1.5 ms of a step on the A40, and a collar's kernels cannot fold. A Kerr pass over a two-dimensional range instead of a division per element: +117.9 ms either way. The modal routes' two accumulator stores in an Adams trial: 0.997 to 1.000, inside the spread. `weighted_error`'s mirrors: 0.114 to 0.099 ms a call at +32 x 32 and 10.8 to 10.7 at 384 x 384, on the rare controller that has one. + +**The Adams trial.** A Cartesian route's source no longer accumulates its stage (3.4 of 199 ms a trial at 256 x 256 x 600, 186 ms with the collar and the flows), and the +interaction formulation's fused trial swaps the state aside rather than copying it twice: `ab --device --stepper multistep-interaction`, three rounds, `rdw` 0.994 (0.979–0.994), `he_rdw_averaged` 0.988 (0.878–0.989), +`argon_blueshift_averaged` 0.978 (0.975–0.979), `radial_filament_256` 0.993 (0.990–0.995), fields bitwise. + +**A Runge-Kutta trial.** The accept is one reduction on a device as on the host (26.4 to 23.4 µs a trial on `he_rdw_averaged`, 71.8 to 60.8 on `radial_filament_256`), and its +partials cross through a persistent pinned mirror (16.8 to 8.8 µs a trial), the ledger's `trial_norms` row, 13.5 MiB at 768 x 768 on the host's pinned memory, outside a low-storage run. `ab --device --stepper rk`, five rounds, +against the build before them: `he_rdw_averaged` 0.988 (0.986–1.007), `rdw` 0.996 (0.988–0.996), `radial_filament_256` 1.001 (0.999–1.036), fields bitwise. + +**The Adams read-back** (#362) is about 2.8 µs a trial from its last kernel's end to the host's return, by an `nsys` trace with graph nodes on the A40: 1.2 % of a 237 µs `he_rdw_averaged` trial, 0.5 % of +`rdw`'s 586 µs, 0.1 % of `radial_filament_256`'s 3.3 ms, at most 14 µs at the 90th percentile. That is well below #362's 20 %. + +**Scratch-order registers on the volume route**, tried on the A40 and not adopted, take the fold from 407 to 387 ms and the step to 2,538 ms, with the fold's tile staging the state rather than the scratch. A layout change needs its own issue. + +On a CPU, `python -m benchmarks ab` against the merge base on an Intel Xeon Gold 6240 (nine cores of one socket, bound passive threads, on a machine others shared; three rounds, the same steps): `kerr_beam_192` 0.982 (0.978–0.996) at one thread and 0.974 (0.973–0.987) at nine; `kerr_beam_384` 0.981 (0.981–0.982) at nine, fields to 6.4e-16. +`rdw` under `rk` and `multistep-interaction`, one and nine threads, without the host's fold fix, which they do not reach: 1.011, 0.974, 0.996 and 0.999. +Before the fold's fix, `radial_filament_256` 0.993 and `planar` 0.980 at nine threads, five rounds, took neither path. + +A data-centre card has to confirm the step on an H200 and an MI325X, what rocFFT's plans hold, and what the launches are worth. + ## Reference tables These tables back the indicative results above. diff --git a/tests/backends/test_free_split_rows.py b/tests/backends/test_free_split_rows.py index 371b0eca..0b170e33 100644 --- a/tests/backends/test_free_split_rows.py +++ b/tests/backends/test_free_split_rows.py @@ -30,6 +30,7 @@ from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, PIController +from solprop.translate.modal import DEFAULT_SPATIAL_NODE_BUDGET from solprop.translate.problem import KernelProblem native_backend = pytest.importorskip("solprop_native.backend") @@ -175,7 +176,7 @@ def test_a_device_takes_every_row_at_once( monkeypatch.setattr(native_backend, "_on_device", lambda: True) simulation = _beam(ENVELOPE, MARCHES[reference[0]]) problem = _blocked(simulation, 5) - backend = watched(simulation, "native", problem) + backend: Any = watched(simulation, "native", problem) try: assert backend._native_state.split_rows == problem.shape[1] * problem.shape[2] finally: @@ -203,12 +204,20 @@ def test_a_stated_budget_keeps_its_chunk_on_a_device() -> None: assert native_backend._split_rows(stated, device=True) == 5 * default.shape[2] assert native_backend._split_rows(stated, device=False) == 5 * default.shape[2] + simulation.settings = dataclasses.replace( + simulation.settings, node_budget=DEFAULT_SPATIAL_NODE_BUDGET + ) + assert simulation.translate().transform.chunk_budgeted + -def test_the_cpu_keeps_the_node_chunk_it_was_given() -> None: +def test_the_cpu_keeps_the_node_chunk_it_was_given( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(native_backend, "_on_device", lambda: False) simulation = _beam(ENVELOPE) problem = _blocked(simulation, 5) assert native_backend._split_rows(problem, device=False) == 5 * problem.shape[2] - backend = watched(simulation, "native", problem) + backend: Any = watched(simulation, "native", problem) try: assert backend._native_state.split_rows == 5 * problem.shape[2] finally: From cb5f423117bc864841bd34e18352550c47af2d3b Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 23:04:06 +0000 Subject: [PATCH 099/122] Translate a fresh simulation for the budget equal to the default Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/backends/test_free_split_rows.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/backends/test_free_split_rows.py b/tests/backends/test_free_split_rows.py index 0b170e33..74b89f83 100644 --- a/tests/backends/test_free_split_rows.py +++ b/tests/backends/test_free_split_rows.py @@ -204,6 +204,7 @@ def test_a_stated_budget_keeps_its_chunk_on_a_device() -> None: assert native_backend._split_rows(stated, device=True) == 5 * default.shape[2] assert native_backend._split_rows(stated, device=False) == 5 * default.shape[2] + simulation = _beam(ENVELOPE) simulation.settings = dataclasses.replace( simulation.settings, node_budget=DEFAULT_SPATIAL_NODE_BUDGET ) From 1ac209c6833558a35b496c9a80b2a82b78bafd9a Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 23:13:08 +0000 Subject: [PATCH 100/122] Drop a docstring reference Sphinx cannot resolve Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/translate/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/solprop/translate/modal.py b/src/solprop/translate/modal.py index 360aa4ba..574e6a95 100644 --- a/src/solprop/translate/modal.py +++ b/src/solprop/translate/modal.py @@ -127,7 +127,7 @@ class Transform: chunk_budgeted : bool Whether the caller's own ``node_budget`` set `node_chunk`. Without one a backend may take a larger block where it holds no buffer of its own for it, as a device - does for a free-space envelope's split collar (:func:`assemble`, solprop #361). + does for a free-space envelope's split collar (solprop #361). aeff : solprop.translate.chebyshev.PiecewiseChebyshev Effective area in m^2 as a table along z. It is the normalisation itself when ``kind == 'mode_averaged'`` and a diagnostic otherwise. From 9a5d2346b280c2a5bdfd27970d7ef7c1a092474d Mon Sep 17 00:00:00 2001 From: John Travers Date: Wed, 4 Nov 2026 23:39:44 +0000 Subject: [PATCH 101/122] Hold a stated block on a device, and name chunk_budgeted in the seam A fresh read found the device test ran one block three times, since its problems left chunk_budgeted unset; a default absolute tolerance hid the monitor's small readings; and a device-only attribute wasn't in TRANSFORM_FIELDS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/solprop_native/__init__.py | 2 +- tests/backends/test_free_split_rows.py | 56 ++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py index bca2717c..d1e3f491 100644 --- a/native/src/solprop_native/__init__.py +++ b/native/src/solprop_native/__init__.py @@ -307,7 +307,7 @@ def _band_request() -> int: message naming the member, rather than as an ``AttributeError`` raised from inside the adapter on the first problem it is handed.""" -TRANSFORM_FIELDS: tuple[str, ...] = ("modal_tables",) +TRANSFORM_FIELDS: tuple[str, ...] = ("modal_tables", "chunk_budgeted") """``Transform`` rows this backend relies on. The general-modal reference of schema 12, whose synthesis and projection maps this diff --git a/tests/backends/test_free_split_rows.py b/tests/backends/test_free_split_rows.py index 74b89f83..adc6a553 100644 --- a/tests/backends/test_free_split_rows.py +++ b/tests/backends/test_free_split_rows.py @@ -87,13 +87,34 @@ def _beam(grid: TimeGrid, march: object | None = None) -> Simulation: ) -def _blocked(simulation: Simulation, chunk: int | None) -> KernelProblem: - """Return the simulation's problem at a node chunk, or at its own.""" +def _blocked( + simulation: Simulation, chunk: int | None, *, stated: bool = True +) -> KernelProblem: + """Return the simulation's problem at a node chunk, or at its own. + + Parameters + ---------- + simulation : solprop.driver.Simulation + The run. + chunk : int or None + The node chunk, or ``None`` for the problem's own. + stated : bool, optional + Whether a caller stated that chunk, which a device build then keeps. Otherwise a + device build takes every row at once, as it does under the default budget. + + Returns + ------- + solprop.translate.problem.KernelProblem + The problem. + """ problem = simulation.translate() if chunk is None: return problem return dataclasses.replace( - problem, transform=dataclasses.replace(problem.transform, node_chunk=chunk) + problem, + transform=dataclasses.replace( + problem.transform, node_chunk=chunk, chunk_budgeted=stated + ), ) @@ -122,12 +143,17 @@ def reference(request: pytest.FixtureRequest) -> tuple[str, np.ndarray, dict[str def _check_block( - reference: tuple[str, np.ndarray, dict[str, Any]], chunk: int | None + reference: tuple[str, np.ndarray, dict[str, Any]], + chunk: int | None, + *, + stated: bool = True, ) -> None: """Compare the native march at a node chunk with the reference's.""" march, expected, theirs = reference simulation = _beam(ENVELOPE, MARCHES[march]) - got, ours, ledger = _marched(simulation, "native", _blocked(simulation, chunk)) + got, ours, ledger = _marched( + simulation, "native", _blocked(simulation, chunk, stated=stated) + ) # The low-storage bound of four evaluations a step, scaled to the seven of the # longest tableau; the split's pair and product are less than one evaluation's, and # its factor's size is inside the bound's margin. A device build adds its @@ -143,7 +169,7 @@ def _check_block( assert_within(got, expected, bound, what=f"{march} at node chunk {chunk}") assert theirs["raw_removed_fraction"] > 1e-6 for name in MONITORED: - assert ours[name] == pytest.approx(theirs[name], rel=2 * bound), name + assert ours[name] == pytest.approx(theirs[name], rel=2 * bound, abs=0.0), name # The rows are the state's own: no spectrum or time buffer of the split's. assert ledger["split_absorber_spectrum"] == 0 assert ledger["split_absorber_time"] == 0 @@ -175,12 +201,28 @@ def test_a_device_takes_every_row_at_once( # scratch, at the reference's numbers. monkeypatch.setattr(native_backend, "_on_device", lambda: True) simulation = _beam(ENVELOPE, MARCHES[reference[0]]) - problem = _blocked(simulation, 5) + problem = _blocked(simulation, 5, stated=False) backend: Any = watched(simulation, "native", problem) try: assert backend._native_state.split_rows == problem.shape[1] * problem.shape[2] finally: backend.close() + _check_block(reference, 5, stated=False) + + +def test_a_device_keeps_a_block_the_caller_stated( + reference: tuple[str, np.ndarray, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # A stated node_budget wins on a device too, with its short last block's plan. + monkeypatch.setattr(native_backend, "_on_device", lambda: True) + simulation = _beam(ENVELOPE, MARCHES[reference[0]]) + problem = _blocked(simulation, 5) + backend: Any = watched(simulation, "native", problem) + try: + assert backend._native_state.split_rows == 5 * problem.shape[2] + finally: + backend.close() _check_block(reference, 5) From ea88dce43ddbe11dfb5dc553e6540b804f1059c3 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 00:12:36 +0000 Subject: [PATCH 102/122] Answer what the native lint and tidy lanes found A redundant member initialiser, a needless local copy, and the three private names the adapter uses, which its stricter type check reports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- native/src/general.cpp | 5 ++--- native/src/solprop_native/backend.py | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/native/src/general.cpp b/native/src/general.cpp index 10a8f19e..fa532060 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -575,7 +575,7 @@ struct TransverseLayout { // the statistics read the coarse field (solprop's `collect_spatial`). struct PhysicalSpectrum { TransverseLayout layout{}; - RealView2 synthesis{}; + RealView2 synthesis; std::size_t n_x{}, n_p{}; double crop_scale = 1.0; bool slab = false; @@ -14994,13 +14994,12 @@ void GeneralWorkspace::add_split_removal(const double dz, const std::size_t acti const auto guards = monitor_weights_; const auto samples = Range2(execution_space_, {0, 0}, {active_rows, problem_.n_t}); if (problem_.family == FieldFamily::envelope) { - const auto time = complex_time; launch::parallel_reduce( "solprop_native.monitor.split_removal_complex", samples, KOKKOS_LAMBDA(const std::size_t row, const std::size_t index, SplitSums& update) { const double power = (stored ? weights(first_column + row) : parseval) * - (power_scale * squared_abs(time(row, index))); + (power_scale * squared_abs(complex_time(row, index))); update.values[0] += power * share(index); const double found = half(index) * power; update.values[1] += found; diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 3345a6aa..7a7f743a 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -145,8 +145,8 @@ from solprop.translate.tdse import TDSEPurpose from solprop.transverse_monitor import ( TransverseEdge, - _EdgeReading, - _transverse_edge_bytes, + _EdgeReading, # pyright: ignore[reportPrivateUsage] - released together + _transverse_edge_bytes, # pyright: ignore[reportPrivateUsage] - released together ) from . import ( @@ -1178,7 +1178,7 @@ def _bind_edge_lines(self, edge: TransverseEdge) -> None: np.sqrt(np.asarray(space.r, dtype=np.float64)) if lines and isinstance(space, RadialGrid) else np.empty(0, dtype=np.float64), - transverse_monitor._STRIP_BATCH_BYTES, + transverse_monitor._STRIP_BATCH_BYTES, # pyright: ignore[reportPrivateUsage] ) def edge_memory(self, space: object) -> int: From eb926df0e0290ebcc0d81d09171817b39546dc8f Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 00:39:58 +0000 Subject: [PATCH 103/122] Keep an updated problem's transform by identity, and bound a point beam's width CI found both. Replacing the transform for chunk_budgeted broke the sharing an update relies on. A point beam's width is the root of a moment that errs relative to the axes' squares, so on another platform's rounding it left a bound written for a beam with a size of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/translate/problem.py | 10 +++++----- tests/backends/test_native_widths.py | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/solprop/translate/problem.py b/src/solprop/translate/problem.py index 294b1b53..e21f7e88 100644 --- a/src/solprop/translate/problem.py +++ b/src/solprop/translate/problem.py @@ -6612,12 +6612,12 @@ def assemble( # noqa: PLR0913, PLR0915 - one keyword per physics object of a ru grid.n_to, node_budget, ) - transform = replace( - _balanced_chunk( - _held_current_chunk(transform, compiled, n_pol, grid.n_to, node_budget) - ), - chunk_budgeted=budgeted, + transform = _balanced_chunk( + _held_current_chunk(transform, compiled, n_pol, grid.n_to, node_budget) ) + # Only if it differs: an updated problem shares its original's transform by identity. + if transform.chunk_budgeted != budgeted: + transform = replace(transform, chunk_budgeted=budgeted) initial = _power_initial( Initial( field0=state, diff --git a/tests/backends/test_native_widths.py b/tests/backends/test_native_widths.py index 8909e54e..c164ba88 100644 --- a/tests/backends/test_native_widths.py +++ b/tests/backends/test_native_widths.py @@ -153,7 +153,7 @@ def _width_budget(grid, problem): return gamma(8 * 16 * (n_columns + n_pol * grid.n_t + 64 * int(np.log2(grid.n_t)))) -def _agree(got, want, bound, pixel, what): +def _agree(got, want, bound, pixel, what, extent): for name in WIDTHS: actual, expected = np.ravel(got[name]), np.ravel(want[name]) # A width the rule leaves undefined (a hollow profile, a beam at an edge) is @@ -166,8 +166,17 @@ def _agree(got, want, bound, pixel, what): # A pixel at least: a point beam's moment is its centroid's rounding, whose # size is the axes', not the width's. scale = max(float(np.linalg.norm(expected[live])), pixel) + allowed = bound + if name == "beam_radius" and float(np.max(expected[live])) < pixel: + # A moment errs by the budget relative to the axes' squares, and a + # width is its root: a point beam's has no size of its own to hide that. + allowed = 2 * math.sqrt(bound) * extent / scale assert_within( - actual[live], expected[live], bound, scale=scale, what=f"{what} {name}" + actual[live], + expected[live], + allowed, + scale=scale, + what=f"{what} {name}", ) @@ -196,7 +205,8 @@ def _check_plane(kind, n_pol, state): x = geometry.axes[0] pixel = float(x[1] - x[0]) what = f"{kind} {n_pol} {state}" - _agree(got, want, _width_budget(grid, problem), pixel, what) + extent = max(float(np.max(np.abs(axis))) for axis in geometry.axes) + _agree(got, want, _width_budget(grid, problem), pixel, what, extent) # The axis's coherent sum errs normwise, as a node's synthesis does, so on the # states that cancel on the axis the scale is the beam's peak, not the axis's value. # The time transform and the pairing as for the fluence (`_width_budget`), and no From b9605ee62210b43281461345640d3370c340ebd7 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 00:41:13 +0000 Subject: [PATCH 104/122] Fit that to the assembler's branch limit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/translate/problem.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/solprop/translate/problem.py b/src/solprop/translate/problem.py index e21f7e88..28ae3074 100644 --- a/src/solprop/translate/problem.py +++ b/src/solprop/translate/problem.py @@ -6615,9 +6615,12 @@ def assemble( # noqa: PLR0913, PLR0915 - one keyword per physics object of a ru transform = _balanced_chunk( _held_current_chunk(transform, compiled, n_pol, grid.n_to, node_budget) ) - # Only if it differs: an updated problem shares its original's transform by identity. - if transform.chunk_budgeted != budgeted: - transform = replace(transform, chunk_budgeted=budgeted) + # Only if it differs: an update shares its original's transform by identity. + transform = ( + transform + if transform.chunk_budgeted == budgeted + else replace(transform, chunk_budgeted=budgeted) + ) initial = _power_initial( Initial( field0=state, From 56146ab084dc11ef4de07710814429306852f44c Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 00:42:07 +0000 Subject: [PATCH 105/122] Say when the crossing ring is really allocated A large state's load takes it, even in a run that keeps no field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/host-crossing.fixed.md | 2 +- docs/dev/benchmarks.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/changelog.d/host-crossing.fixed.md b/changelog.d/host-crossing.fixed.md index 25df1374..23753993 100644 --- a/changelog.d/host-crossing.fixed.md +++ b/changelog.d/host-crossing.fixed.md @@ -1 +1 @@ -On a GPU build, a large state now crosses to and from the host through a ring of pinned chunks into huge-page memory, rather than taking its page faults one thread at a time inside the driver's copy. In a bare copy of a 5.27 GiB state that scheme reached 33 GiB/s against 1.8 GiB/s on an NVIDIA H200, and 50 GiB/s against 7.9 GiB/s on an AMD Instinct MI325X. A run that keeps no field allocates no ring, and the ring is listed in `memory_ledger` as `host_crossing_ring`. A saved plane's finiteness check and its conversion to the stored convention now run on the process's threads, and `MemoryOutput` converts straight into its own block rather than copying. Stored fields are unchanged to the bit. +On a GPU build, a large state now crosses to and from the host through a ring of pinned chunks into huge-page memory, rather than taking its page faults one thread at a time inside the driver's copy. In a bare copy of a 5.27 GiB state that scheme reached 33 GiB/s against 1.8 GiB/s on an NVIDIA H200, and 50 GiB/s against 7.9 GiB/s on an AMD Instinct MI325X. A state under 256 MiB takes no ring, and the ring is listed in `memory_ledger` as `host_crossing_ring`. A saved plane's finiteness check and its conversion to the stored convention now run on the process's threads, and `MemoryOutput` converts straight into its own block rather than copying. Stored fields are unchanged to the bit. diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 55fd2879..e28a3e10 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2249,8 +2249,9 @@ the point with the check takes 14.2 s against 3.9 s without on the H200 (49.8 s the MI325X. The NVIDIA A40 sees less (PCIe Gen 4): 183 → 147 ms at 384 x 384. A kept plane's conversion and its finiteness check take threads too, above `PARALLEL_BYTES`, and a plane converts straight -into the store's block rather than through a copy. A run that keeps no field allocates no ring: `memory_ledger`'s -`host_crossing_ring` stays zero, which a device test holds. +into the store's block rather than through a copy. A state under `ring_threshold`, 256 MiB, takes no ring, and +`memory_ledger`'s `host_crossing_ring` stays zero, which a device test holds. A larger state's load allocates it, even in a run +that keeps no field: 64 MiB of pinned host memory per host thread, 1.5 GiB on 24. On a CPU a crossing is a plain copy and takes no ring. `python -m benchmarks ab` against `main`, with the previous entry, on an Intel Xeon Gold 6240 (18 cores of one socket, passive threads, three rounds of one repeat, a shared machine): `kerr_beam_192` 0.976 From b48aeecdd45ff6acbffa5850d95f186f62448d4c Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 00:47:22 +0000 Subject: [PATCH 106/122] Add CallbackOutput, a handler built from a callable A scan that reduces each plane to a few numbers had to implement the whole Output protocol. CallbackOutput gives each plane to a callable, unconverted and uncopied, and keeps nothing. With fields="none" the plane carries field=None and its statistics, and no field leaves the backend. It is an Output and a RetainingOutput, and deliberately not a PlaneStore, whose save path converts the field into a new volume, nor a CheckpointOutput, which promises a state that resume=True can continue. Reading back is refused by name, as is a second run on one handler; check_cache, resume and resumability now refuse any output handler by name. For #499. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/499-callback-output.added.md | 4 + src/solprop/output/__init__.py | 6 +- src/solprop/output/callback.py | 299 ++++++++ src/solprop/output/hdf5.py | 55 +- src/solprop/output/resumable.py | 14 +- src/solprop/sol.py | 9 +- tests/driver/test_initial_z_and_checkpoint.py | 12 + tests/output/test_callback.py | 642 ++++++++++++++++++ 8 files changed, 1023 insertions(+), 18 deletions(-) create mode 100644 changelog.d/499-callback-output.added.md create mode 100644 src/solprop/output/callback.py create mode 100644 tests/output/test_callback.py diff --git a/changelog.d/499-callback-output.added.md b/changelog.d/499-callback-output.added.md new file mode 100644 index 00000000..8bd4ea0f --- /dev/null +++ b/changelog.d/499-callback-output.added.md @@ -0,0 +1,4 @@ +`solprop.output.CallbackOutput`, an output handler built from a callable: it gives each +saved plane to the callable and keeps nothing, so a reducing scan on `SimulationRunner` is +a few lines and costs no volume beyond the plane's own. `check_cache`, `resume` and +`resumability` now refuse any output handler by name (#499). diff --git a/src/solprop/output/__init__.py b/src/solprop/output/__init__.py index a391ea29..ee1ed489 100644 --- a/src/solprop/output/__init__.py +++ b/src/solprop/output/__init__.py @@ -8,13 +8,16 @@ (:class:`solprop.output.schema.PlaneStore`), one metadata schema and one sign convention on the way out: stored fields are in the optics convention and carry the attribute ``spectral_convention = "optics"`` (see ``docs/dev/decisions.md``). The manual page is -``docs/guide/results/output.md``. A TDSE run can also write its raw provider +``docs/guide/results/output.md``. A run reduced plane by plane stores nothing, and gives +each plane to a callable instead (:class:`~solprop.output.callback.CallbackOutput`; +``docs/guide/setup/running.md``). A TDSE run can also write its raw provider evaluations to a separate archive (:class:`~solprop.output.tdse_archive.TDSEArchive`, read with :func:`~solprop.output.tdse_archive.open_tdse_archive`; ``docs/guide/media/tdse.md``). """ +from solprop.output.callback import CallbackOutput from solprop.output.hdf5 import ( COMPRESSIONS, HDF5Output, @@ -77,6 +80,7 @@ "SPECTRAL_CONVENTION", "TDSE_ARCHIVE_SCHEMA", "BackendRecord", + "CallbackOutput", "CheckpointOutput", "GitProvenance", "GridRecord", diff --git a/src/solprop/output/callback.py b/src/solprop/output/callback.py new file mode 100644 index 00000000..c3362f0e --- /dev/null +++ b/src/solprop/output/callback.py @@ -0,0 +1,299 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The output handler that gives each plane to a callable and keeps nothing. + +The handler for a scan that reduces each plane to a few numbers: the callable is the +body of what would otherwise be a hand loop over the backend. The plane reaches it as +the run made it, with no copy and no conversion, so the handler costs no volume of its +own. + +It is an :class:`~solprop.output.schema.Output` and a +:class:`~solprop.output.schema.RetainingOutput`, and deliberately neither a +:class:`~solprop.output.schema.PlaneStore` nor a +:class:`~solprop.output.schema.CheckpointOutput`. A store's readers have nothing to read +here, and its save path converts each field into a new volume. A checkpoint promises a +state that ``resume=True`` can continue, and with no file there is nothing to continue, +so a run neither fetches a terminal field for this handler nor says that one was +written. + +The manual page is ``docs/guide/setup/running.md``. +""" + +from collections.abc import Callable + +import numpy as np + +from solprop._display import line +from solprop.exceptions import OutputError +from solprop.output.schema import ( + Convention, + FieldRetention, + RunMetadata, + SavedPlane, + resolve_metadata_write, +) + +__all__ = ["CallbackOutput"] + + +def _not_held(what: str) -> OutputError: + """Return the refusal of a read, naming what was asked for. + + Parameters + ---------- + what : str + What the caller asked to read. + + Returns + ------- + solprop.exceptions.OutputError + The error to raise. + """ + return OutputError( + f"a CallbackOutput does not hold {what}: it gives each plane to its callable " + "and keeps nothing. Keep what you need in the callable, or run into a " + "MemoryOutput or a file to read the run back" + ) + + +class CallbackOutput: + """Give each saved plane to a callable, and keep nothing. + + The callable is given the run's own :class:`~solprop.output.schema.SavedPlane`, not + a copy. Its arrays are read-only and nothing writes to them again, so the callable + may keep any of them, at the cost of what it keeps; a plane it reduces and lets go + costs one field volume on the host while it is held, the one the observation + crossed with. The field is as a backend produces it: in the internal FFT convention, + in the square root of watts. A reduction of the squared modulus does not depend on + the convention, and :func:`solprop.fourier.spectrum_to_optics` gives the form a file + stores, in a second volume. + + The run's ``fields`` setting + (:class:`~solprop.driver.simulation.SimulationSettings`) decides what a plane + carries. With ``fields="none"``, what a reduction of statistics wants, every plane + has ``field=None`` and ``noise_phase=None`` beside its position, its statistics and + its step history, and no field leaves the backend. With ``fields="planes"`` the + planes it does not name are those; with the default ``"all"`` every plane carries + its field. + + What it cannot do, it refuses by name. Reading anything back (``handler[key]``, + :meth:`fields`) raises :class:`~solprop.exceptions.OutputError`, and a handler that + has served a run refuses another, which would continue it. A resume needs a file + (:func:`~solprop.output.hdf5.check_cache`, :func:`~solprop.output.hdf5.resume`, + ``resume=True``), as does a TDSE run's ledger. A stopped run returns the handler + with no state written. + + A run with an optmat ionisation rate reads its peak rates back from its output to + report the peak field and warn past the rate's range + (:class:`~solprop.exceptions.MaterialRangeWarning`). This handler stores no + statistic, so that report and warning do not happen: read the rate in the callable. + + Parameters + ---------- + receive : collections.abc.Callable + Called once per saved plane, the entrance first, with the plane. What it returns + is ignored; what it raises stops the run and propagates, and that plane is not + counted. + + Raises + ------ + OutputError + If `receive` is not callable. + + Examples + -------- + >>> import dataclasses + >>> from solprop.driver import Simulation + >>> from solprop.output import CallbackOutput + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1.2e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, rtol=1e-6, + ... ) + >>> simulation.settings = dataclasses.replace(simulation.settings, fields="none") + >>> kept = [] + >>> handler = CallbackOutput(lambda plane: kept.append((plane.z, plane.field))) + >>> simulation.run(handler, progress="none") is handler + True + >>> kept + [(0.0, None), (0.005, None), (0.01, None)] + >>> "z" in handler, handler + (False, CallbackOutput(3 planes, closed)) + """ + + def __init__(self, receive: Callable[[SavedPlane], None]) -> None: + """Hold the callable, with no run begun.""" + if not callable(receive): + msg = f"a CallbackOutput needs a callable taking a plane, got {receive!r}" + raise OutputError(msg) + self._receive = receive + self._metadata: RunMetadata | None = None + self._n_saved = 0 + self._closed = False + + def __repr__(self) -> str: + """Return a one-line description: how many planes, and whether it is open. + + Returns + ------- + str + For example ``CallbackOutput(3 planes, closed)``. + """ + state = "closed" if self._closed else "open" + return line(type(self).__name__, [("", f"{self._n_saved} planes"), ("", state)]) + + def open(self, metadata: RunMetadata, *, force: bool = False) -> None: + """Begin a run, holding its record. + + Parameters + ---------- + metadata : RunMetadata + The record of the run about to start. + force : bool, optional + Whether to replace a different record held already + (:func:`~solprop.output.schema.resolve_metadata_write`). + + Raises + ------ + OutputError + If the handler has served a run already, or a different record is held and + `force` is false. + """ + if self._closed or self._n_saved: + msg = ( + "this CallbackOutput has served a run and keeps none of its planes, so " + "no run can continue it; build a new one for each run" + ) + raise OutputError(msg) + resolve_metadata_write(self._metadata, metadata, force=force) + self._metadata = metadata + + def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: + """Accept the run's field rule: there is no storage for it to size. + + Declares :class:`~solprop.output.schema.RetainingOutput`, which is what admits + field-free planes. The rule reaches the callable as each plane's ``field``. + + Parameters + ---------- + retention : FieldRetention + Which planes keep their field. + field_shape : tuple of int + ``(n_modes, n_pol, n_omega)`` of the run's state. + """ + + def save(self, plane: SavedPlane) -> None: + """Give one plane to the callable, and count it once the callable has returned. + + Parameters + ---------- + plane : SavedPlane + The plane, handed on as it is. + + Raises + ------ + OutputError + If the handler is closed or was never opened. + """ + if self._closed: + msg = "this CallbackOutput is closed; build a new one for each run" + raise OutputError(msg) + if self._metadata is None: + raise OutputError("call open(metadata) before saving a plane") + self._receive(plane) + self._n_saved += 1 + + def close(self) -> None: + """Finish the run. Calling it twice is harmless.""" + self._closed = True + + @property + def n_saved(self) -> int: + """Return how many planes the callable has taken. + + Returns + ------- + int + The number of planes. + """ + return self._n_saved + + @property + def metadata(self) -> RunMetadata | None: + """Return the record of the run, or ``None`` before it is opened. + + Returns + ------- + RunMetadata or None + What produced the planes. + """ + return self._metadata + + @property + def stat_names(self) -> tuple[str, ...]: + """Return the statistics stored at every plane: none. + + Returns + ------- + tuple of str + Empty, so that nothing asks to read one back. + """ + return () + + @property + def step_stat_names(self) -> tuple[str, ...]: + """Return the statistics stored between planes: none. + + Returns + ------- + tuple of str + Empty. + """ + return () + + def __getitem__(self, key: str) -> np.ndarray: + """Refuse to read anything back, naming what was asked for. + + Parameters + ---------- + key : str + What was asked for. + + Raises + ------ + OutputError + Always. + """ + raise _not_held(repr(key)) + + def __contains__(self, key: object) -> bool: + """Return whether ``self[key]`` would return an array: it never would. + + Parameters + ---------- + key : object + A candidate key. + + Returns + ------- + bool + ``False``. + """ + return False + + def fields(self, convention: Convention = "optics") -> np.ndarray: # noqa: ARG002 - the protocol's signature + """Refuse to read the fields back. + + Parameters + ---------- + convention : {'optics', 'fft'}, optional + The convention that was asked for. + + Raises + ------ + OutputError + Always. + """ + raise _not_held("the fields") diff --git a/src/solprop/output/hdf5.py b/src/solprop/output/hdf5.py index a38df450..17a8c1ed 100644 --- a/src/solprop/output/hdf5.py +++ b/src/solprop/output/hdf5.py @@ -86,6 +86,7 @@ FieldsChoice, JsonValue, MarchSnapshot, + Output, PlaneLayout, PlaneStore, RecipeFile, @@ -1962,7 +1963,40 @@ def _resume_refusal( ) -def check_cache(path: Path | str, metadata: RunMetadata) -> bool: +def _file_path(path: Path | str | Output, reader: str) -> Path: + """Return the file a reader was pointed at, refusing an output handler by name. + + A handler is not a file: one that keeps nothing has nothing to continue, and one + that has a file is reached through its path. + + Parameters + ---------- + path : pathlib.Path or str or solprop.output.schema.Output + What the caller gave. + reader : str + The function being called, for the message. + + Returns + ------- + pathlib.Path + The file. + + Raises + ------ + OutputError + If `path` is an output handler. + """ + if isinstance(path, Output): + msg = ( + f"{reader} reads a results file, and was given a {type(path).__name__}, " + "which is an output handler: only a file can be continued, so run into a " + "path and pass that" + ) + raise OutputError(msg) + return Path(path) + + +def check_cache(path: Path | str | Output, metadata: RunMetadata) -> bool: """Say whether a file may be continued as the run ``metadata`` describes. The resume gate (ADR-066): the two records' configuration hashes must be equal. A @@ -1971,8 +2005,8 @@ def check_cache(path: Path | str, metadata: RunMetadata) -> bool: Parameters ---------- - path : pathlib.Path or str - The file to check. + path : pathlib.Path or str or solprop.output.schema.Output + The file to check; an output handler is refused by name. metadata : RunMetadata The record of the run about to start. @@ -1987,7 +2021,7 @@ def check_cache(path: Path | str, metadata: RunMetadata) -> bool: OutputError If the hashes differ, or the file was written at another output schema; or if the file's general modal record names an accessor this build no longer - admits (``_check_accessors``). + admits (``_check_accessors``); or if `path` is an output handler. Examples -------- @@ -1996,7 +2030,8 @@ def check_cache(path: Path | str, metadata: RunMetadata) -> bool: >>> check_cache("no-such-file.h5", _example_metadata()) False """ - if not Path(path).exists(): + path = _file_path(path, "check_cache") + if not path.exists(): logger.debug("no cache at %s; starting from the beginning", path) return False stored, _ = _stored_metadata(path) @@ -2132,7 +2167,7 @@ def _last_step_stats(file: h5py.File, index: int) -> StepStats: ) -def resume(path: Path | str) -> ResumeState: +def resume(path: Path | str | Output) -> ResumeState: """Read a file's resumable state and cumulative work, preferring its checkpoint. The last plane comes back in the **internal** FFT convention, through @@ -2149,8 +2184,8 @@ def resume(path: Path | str) -> ResumeState: Parameters ---------- - path : pathlib.Path or str - The file to read. + path : pathlib.Path or str or solprop.output.schema.Output + The file to read; an output handler is refused by name. Returns ------- @@ -2161,7 +2196,8 @@ def resume(path: Path | str) -> ResumeState: Raises ------ OutputError - If the file cannot be read, carries no metadata, or holds no plane. + If the file cannot be read, carries no metadata, or holds no plane; or if + `path` is an output handler. Examples -------- @@ -2196,6 +2232,7 @@ def resume(path: Path | str) -> ResumeState: ... resume(Path(folder) / "run.h5").n_saved 2 """ + path = _file_path(path, "resume") metadata, _ = _stored_metadata(path) _check_plasma_benchmark(metadata) with h5py.File(path, "r") as file: diff --git a/src/solprop/output/resumable.py b/src/solprop/output/resumable.py index 7422c23b..13f5aaed 100644 --- a/src/solprop/output/resumable.py +++ b/src/solprop/output/resumable.py @@ -26,8 +26,8 @@ from solprop.exceptions import OutputError, SolpropError from solprop.output._writer_lock import WriterLock -from solprop.output.hdf5 import ResumeState, resume -from solprop.output.schema import SCHEMA_VERSION, _decode +from solprop.output.hdf5 import ResumeState, _file_path, resume +from solprop.output.schema import SCHEMA_VERSION, Output, _decode __all__ = ["Resumability", "ResumeMode", "resumability"] @@ -175,13 +175,13 @@ def _unusable_state(state: ResumeState) -> str | None: return None -def resumability(path: str | Path, *, gate: bool = False) -> Resumability: +def resumability(path: str | Path | Output, *, gate: bool = False) -> Resumability: """Say whether a results file can continue, rebuild itself, and pass the gate. Parameters ---------- - path : str or pathlib.Path - The HDF5 output. + path : str or pathlib.Path or solprop.output.schema.Output + The HDF5 output; an output handler is refused by name. gate : bool, optional Also rebuild the run from its recorded recipe and compare the records' hashes, as a resume would (ADR-066), so that, for example, a new optmat release shows @@ -195,11 +195,11 @@ def resumability(path: str | Path, *, gate: bool = False) -> Resumability: Raises ------ OutputError - If the file cannot be read as HDF5. + If the file cannot be read as HDF5, or `path` is an output handler. """ from solprop.recipe.document import FORMAT # noqa: PLC0415 - the layer above - path = Path(path) + path = _file_path(path, "resumability") try: schema = _schema(path) found = _recipe_format(path) diff --git a/src/solprop/sol.py b/src/solprop/sol.py index 20abbe8c..3ebe990a 100644 --- a/src/solprop/sol.py +++ b/src/solprop/sol.py @@ -119,7 +119,13 @@ chirped_mirrors, littrow_angle, ) -from solprop.output import HDF5Output, MemoryOutput, TDSEArchive, open_tdse_archive +from solprop.output import ( + CallbackOutput, + HDF5Output, + MemoryOutput, + TDSEArchive, + open_tdse_archive, +) from solprop.output.hdf5 import open_file, resume from solprop.physdata import ( Gas, @@ -256,6 +262,7 @@ "ZDW", "Batch", "CWPulse", + "CallbackOutput", "CepOptimum", "Chain", "ChirpedMirror", diff --git a/tests/driver/test_initial_z_and_checkpoint.py b/tests/driver/test_initial_z_and_checkpoint.py index 86cba0cf..dfd8f51e 100644 --- a/tests/driver/test_initial_z_and_checkpoint.py +++ b/tests/driver/test_initial_z_and_checkpoint.py @@ -36,6 +36,7 @@ TDSESampling, ) from solprop.output import ( + CallbackOutput, MemoryOutput, RunOutcome, SavedPlane, @@ -964,6 +965,17 @@ def forbidden(*args, **kwargs): ) +def test_a_handler_that_keeps_nothing_is_refused_a_tdse_run( + atomic_simulation, monkeypatch +): + def forbidden(*args, **kwargs): + raise AssertionError("resource preparation must follow output admission") + + monkeypatch.setattr(runtime_module, "prepare_binding", forbidden) + with pytest.raises(DriverError, match="terminal checkpoints"): + atomic_simulation.run(CallbackOutput(lambda plane: None), progress="none") + + @pytest.mark.medium def test_legacy_custom_output_retains_ordinary_success_and_failure_behavior(): simulation = tiny_simulation() diff --git a/tests/output/test_callback.py b/tests/output/test_callback.py new file mode 100644 index 00000000..68d06f21 --- /dev/null +++ b/tests/output/test_callback.py @@ -0,0 +1,642 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A handler built from a callable: every run reaches it, and it keeps nothing. + +Three things are held here. A run on either backend and any march delivers every plane +as that run made it. The handler costs no volume: what it allocates itself is measured +with ``tracemalloc``, which sees NumPy's allocations and so a copy on any backend, and +how long a plane lives with weak references, which also see the native backend's +observation that the tracer cannot. And what it cannot do it refuses by name. +""" + +import dataclasses +import gc +import logging +import threading +import tracemalloc +import weakref +from collections.abc import Callable + +import numpy as np +import pytest + +from bounds import assert_within +from driver.test_field_free_planes import cartesian, keeping, replay_bound +from driver.test_plane_adoption import CASES +from driver.test_runner import bound, capillary, inputs +from runtime_types import unchecked +from solprop import capabilities, sol +from solprop.backends.protocol import Observation +from solprop.backends.registry import available +from solprop.driver import Simulation +from solprop.driver.run import run_problem +from solprop.exceptions import DriverError, OutputError +from solprop.output import ( + CallbackOutput, + MemoryOutput, + check_cache, + resumability, + resume, +) +from solprop.output import schema as output_schema +from solprop.output.schema import ( + PEAK_FIELDS_ANNOTATION, + CheckpointOutput, + FieldRetention, + Output, + PlaneStore, + RetainingOutput, + SavedPlane, + _example_metadata, +) +from solprop.plotting.style import field_modes +from solprop.processing import energy +from solprop.stats import StatsSpec, default_spec + +# These cases run coarse grids for speed; their windows are not what is tested here. +pytestmark = pytest.mark.filterwarnings("ignore::solprop.exceptions.BoundaryWarning") + +KEYS = ( + "field", + "z", + "noise_phase", + "field_planes", + "stats/energy", + "step_history/z", + "step_stats/z", + "not a key", +) + + +def nothing(plane: SavedPlane) -> None: + """Take a plane and let it go.""" + + +def opened() -> CallbackOutput: + """Return a handler with a run begun, for what needs one.""" + handler = CallbackOutput(nothing) + handler.open(_example_metadata()) + return handler + + +def field_free_plane() -> SavedPlane: + """Return the smallest plane a handler can be given.""" + return SavedPlane(z=0.0, field=None) + + +def fibre(backend: str, stats: StatsSpec | None = None) -> Simulation: + """Return a short soliton of three planes, on the route that reads a run back.""" + return Simulation.from_gnlse( + 0.11, + 0.01, + [0.0, 0.0, -1.183e-26], + wavelength=835e-9, + wavelength_limits=(500e-9, 2e-6), + trange=1.2e-12, + width=100e-15, + peak_power=9.8, + shape="sech", + n_save=3, + rtol=1e-6, + backend=backend, + stats=stats, + ) + + +@pytest.fixture +def no_collector(): + """Hold the cycle collector off: a volume must go by its count, not by a pass.""" + gc.disable() + yield + gc.enable() + + +# ------------------------------------------------------------- what it declares + + +def test_it_declares_only_what_it_can_do(): + handler = CallbackOutput(nothing) + assert isinstance(handler, Output) + assert isinstance(handler, RetainingOutput) + # A checkpoint output promises a state that resume=True continues. + assert not isinstance(handler, CheckpointOutput) + assert not isinstance(handler, PlaneStore) + assert sol.CallbackOutput is CallbackOutput + + +def test_a_handler_needs_a_callable(): + handler = CallbackOutput.__new__(CallbackOutput) + with pytest.raises(OutputError, match="needs a callable"): + unchecked(CallbackOutput.__init__)(handler, 3) + + +def test_it_holds_the_record_and_no_statistic(): + handler = CallbackOutput(nothing) + assert handler.metadata is None + metadata = _example_metadata() + handler.open(metadata) + handler.retain(FieldRetention("none"), (1, 1, 8)) + handler.save(SavedPlane(z=0.0, field=None, stats={"energy": np.ones(1)})) + assert handler.metadata is metadata + assert handler.n_saved == 1 + assert handler.stat_names == () + assert handler.step_stat_names == () + assert repr(handler) == "CallbackOutput(1 planes, open)" + handler.close() + handler.close() + assert repr(handler) == "CallbackOutput(1 planes, closed)" + + +def test_the_callable_is_given_the_plane_itself_and_only_then_is_it_counted(): + given = SavedPlane(z=0.5, field=np.ones((1, 1, 4), dtype=np.complex128)) + received: list[tuple[SavedPlane, int]] = [] + handler = CallbackOutput(lambda plane: received.append((plane, handler.n_saved))) + handler.open(_example_metadata()) + handler.save(given) + assert received[0][0] is given + assert received[0][1] == 0 + assert handler.n_saved == 1 + + +def test_a_plane_needs_a_run_open(): + handler = CallbackOutput(nothing) + with pytest.raises(OutputError, match=r"call open\(metadata\)"): + handler.save(field_free_plane()) + handler.open(_example_metadata()) + handler.close() + with pytest.raises(OutputError, match="is closed"): + handler.save(field_free_plane()) + + +def test_a_different_record_is_refused_unless_forced(): + handler = opened() + other = dataclasses.replace(_example_metadata(), configuration={"gamma": 0.2}) + with pytest.raises(OutputError, match="different run"): + handler.open(other) + handler.open(other, force=True) + assert handler.metadata is other + + +# ------------------------------------------------------------------ what it runs + + +@pytest.mark.medium +@pytest.mark.parametrize("backend", available()) +@pytest.mark.parametrize("case", CASES) +@pytest.mark.usefixtures("no_collector") +def test_every_march_delivers_its_planes_and_keeps_none_alive(backend, case): + """Fields on the first two planes and none after: one run of each kind of plane. + + At each plane the plane before it must be gone, so that one observation lives at a + time. This is the volume the native backend's observation costs, which it allocates + outside the tracer's sight, and it would go unseen if the run or the handler held + on. + """ + simulation = keeping(CASES[case](backend), "planes", (0, 1)) + earlier: list[weakref.ref] = [] + kept: list[bool] = [] + positions: list[float] = [] + alive: list[bool] = [] + + def receive(plane: SavedPlane) -> None: + alive.extend(reference() is not None for reference in earlier) + earlier.clear() + earlier.append(weakref.ref(plane)) + if plane.field is not None: + earlier.append(weakref.ref(plane.field)) + kept.append(plane.field is not None) + positions.append(plane.z) + assert (plane.noise_phase is None) or (plane.field is not None) + assert plane.stats + + handler = CallbackOutput(receive) + assert simulation.run(handler, progress="none") is handler + planes = simulation.settings.save_planes + assert handler.n_saved == len(kept) == len(planes) + 1 + assert kept == [index < 2 for index in range(len(kept))] + # The positions are the save planes, landed on within a relative rounding. + np.testing.assert_allclose(positions, [0.0, *planes], rtol=1e-12) + assert alive + assert not any(alive) + + +@pytest.mark.parametrize("backend", available()) +@pytest.mark.parametrize( + ("fields", "carries"), [("all", True), ("none", False)], ids=["all", "none"] +) +def test_the_fields_setting_says_what_every_plane_carries(backend, fields, carries): + simulation = keeping(cartesian(backend, noise=True), fields) + planes: list[SavedPlane] = [] + simulation.run(CallbackOutput(planes.append), progress="none") + assert planes + for plane in planes: + assert (plane.field is not None) is carries + assert (plane.noise_phase is not None) is carries + assert set(plane.stats) == set(default_spec("free").names) + + +@pytest.mark.parametrize("backend", available()) +def test_the_planes_of_natural_steps_reach_the_callable(backend): + simulation = cartesian(backend, replay=False) + end = simulation.settings.save_planes[-1:] + simulation.settings = dataclasses.replace( + simulation.settings, step_planes=2, save_planes=end + ) + planes: list[SavedPlane] = [] + handler = simulation.run(CallbackOutput(planes.append), progress="none") + # More planes than were asked for: the entrance, the end and the steps' own. + assert handler.n_saved == len(planes) > 2 + assert planes[-1].z == pytest.approx(end[0], rel=1e-12) + + +@pytest.mark.parametrize("backend", available()) +@pytest.mark.parametrize("fields", ["all", "none"]) +def test_a_periodic_subset_reaches_the_callable(backend, fields): + spec = StatsSpec(names=("energy", "peak_power"), periodic=("energy",), period=1) + simulation = keeping(fibre(backend, spec), fields) + planes: list[SavedPlane] = [] + handler = simulation.run(CallbackOutput(planes.append), progress="none") + assert planes[0].step_stats.n_rows == 0 + for plane in planes[1:]: + assert plane.step_stats.names == ("energy",) + assert plane.step_stats.n_rows > 0 + assert handler.step_stat_names == () + + +@pytest.mark.parametrize("backend", available()) +def test_a_field_free_scan_takes_no_field_from_the_backend(backend, monkeypatch): + simulation = keeping(cartesian(backend), "none") + asked: list[bool] = [] + with simulation.runner() as runner: + observe = runner.backend.observe + + def watched(*, field: bool = True) -> Observation: + asked.append(field) + return observe(field=field) + + monkeypatch.setattr(runner.backend, "observe", watched) + runner.run(simulation._field0, output=CallbackOutput(nothing), progress="none") + assert asked + assert not any(asked) + + +@pytest.mark.medium +@pytest.mark.parametrize("backend", available()) +@pytest.mark.parametrize( + ("build", "rounding"), + [(capillary, bound), (cartesian, replay_bound)], + ids=["modal", "spatial"], +) +def test_a_runner_hands_over_what_a_store_would_hold(backend, build, rounding): + """The same input on one runner, into a callable and a store, on a replayed march. + + The field the callable is given is the store's in the FFT convention, which is + what says it arrives unconverted; the two agree within the rounding bound of two + replayed runs, fixed before them, and a statistic quadratic in it within twice that. + """ + simulation = build(backend) + limit = rounding(simulation) + with simulation.runner() as runner: + for launch in inputs(simulation)[:2]: + planes: list[SavedPlane] = [] + handler = runner.run( + launch, output=CallbackOutput(planes.append), progress="none" + ) + store = runner.run(launch, progress="none") + assert isinstance(store, MemoryOutput) + assert handler.n_saved == store.n_saved == len(planes) + assert not handler.stat_names + np.testing.assert_array_equal([p.z for p in planes], store["z"]) + for index, plane in enumerate(planes): + assert plane.field is not None + assert not plane.field.flags.writeable + assert_within( + plane.field, store.fields("fft")[index], limit, what="field" + ) + energies = plane.stats["energy"] + assert_within( + energies, store["stats/energy"][index], 2 * limit, what="energy" + ) + assert runner.runs == 4 + + +# ------------------------------------------------------------------- what it costs + + +class _Measured(CallbackOutput): + """Note what each save allocates and what it leaves allocated, beside the tracer.""" + + def __init__(self, receive: Callable[[SavedPlane], None]) -> None: + super().__init__(receive) + self.peaks: list[int] = [] + self.left: list[int] = [] + + def save(self, plane: SavedPlane) -> None: + before = tracemalloc.get_traced_memory()[0] + tracemalloc.reset_peak() + super().save(plane) + after, peak = tracemalloc.get_traced_memory() + self.peaks.append(peak - before) + self.left.append(after - before) + + +@pytest.fixture +def traced(): + """Trace allocations for one test.""" + tracemalloc.start() + yield + tracemalloc.stop() + + +@pytest.mark.parametrize("backend", available()) +@pytest.mark.usefixtures("traced") +def test_saving_a_plane_allocates_no_volume(backend): + """Whatever the run does, ``save`` itself copies and converts nothing. + + A copy of the field or its conversion to the optics convention is a whole volume. + What ``save`` should allocate is a handful of Python objects, so a tenth of a + volume, tens of kilobytes on this grid, leaves room for them and none for an array. + """ + simulation = cartesian(backend) + volume = simulation._field0.nbytes + handler = _Measured(nothing) + simulation.run(handler, progress="none") + assert handler.n_saved == len(simulation.settings.save_planes) + 1 + assert max(handler.peaks) < volume / 10 + assert max(handler.left) < volume / 10 + + +def _live_levels_at_planes( + simulation: Simulation, receive: Callable[[SavedPlane], None] +) -> list[int]: + """Return the traced memory alive at each plane of a second run, in bytes. + + The first run on the runner settles what a backend and the run path build once. Each + reading follows a collection, because what is measured is what stays alive: a run + leaves cycles for the collector, and a pass may free an earlier run's at any moment. + """ + levels: list[int] = [] + + def noting(plane: SavedPlane) -> None: + receive(plane) + gc.collect() + levels.append(tracemalloc.get_traced_memory()[0]) + + with simulation.runner() as runner: + runner.run(simulation._field0, output=CallbackOutput(nothing), progress="none") + runner.run(simulation._field0, output=CallbackOutput(noting), progress="none") + return levels + + +@pytest.mark.medium +@pytest.mark.parametrize("backend", available()) +@pytest.mark.usefixtures("traced") +def test_a_reducing_run_keeps_no_more_alive_as_it_goes(backend, monkeypatch): + """From the second plane to the last the live level stays under one volume. + + From the second, because the planes before it may still be building buffers. A run + keeps about a kilobyte per plane, and this session's runtime type checks some tens + more in their caches, on a volume of hundreds. Keeping a copy of every field is the + control, which shows that the measure can see a volume at all: it adds one per + plane, so three here, of which the bound takes a tenth off for what a pass frees. + """ + monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + volume = cartesian(backend)._field0.nbytes + copies: list[np.ndarray] = [] + + def keep_a_copy(plane: SavedPlane) -> None: + assert plane.field is not None + copies.append(plane.field.copy()) + + reducing = _live_levels_at_planes(cartesian(backend), nothing) + copying = _live_levels_at_planes(cartesian(backend), keep_a_copy) + planes = len(reducing) + assert planes >= 4 + assert reducing[-1] - reducing[1] < volume + assert copying[-1] - copying[1] > 0.9 * (planes - 2) * volume + + +@pytest.mark.parametrize("backend", available()) +def test_the_callable_holds_the_observation_itself(backend, monkeypatch): + """No copy on the way to the callable, on a route with no conversion of units.""" + simulation = cartesian(backend) + observed: list[Observation] = [] + shared: list[bool] = [] + + def receive(plane: SavedPlane) -> None: + assert plane.field is not None + shared.append(np.shares_memory(plane.field, observed[-1].field)) + + with simulation.runner() as runner: + observe = runner.backend.observe + + def watched(*, field: bool = True) -> Observation: + observation = observe(field=field) + observed.append(observation) + return observation + + monkeypatch.setattr(runner.backend, "observe", watched) + runner.run(simulation._field0, output=CallbackOutput(receive), progress="none") + assert shared + assert all(shared) + + +@pytest.mark.parametrize("backend", available()) +@pytest.mark.parametrize("case", ["runge-kutta replayed", "adams modal", "low-storage"]) +def test_what_the_callable_keeps_stays_as_it_was(backend, case): + """A reduction may keep a plane's statistics or history without copying them.""" + kept: list[tuple[np.ndarray, np.ndarray]] = [] + + def receive(plane: SavedPlane) -> None: + arrays = [*plane.stats.values(), plane.step_history.z, plane.step_history.h] + kept.extend((values, values.copy()) for values in arrays) + + CASES[case](backend).run(CallbackOutput(receive), progress="none") + assert kept + for held, as_it_arrived in kept: + assert np.array_equal(held, as_it_arrived, equal_nan=True) + + +# ---------------------------------------------------------------- what it refuses + + +@pytest.mark.parametrize("key", KEYS) +def test_reading_a_key_is_refused_by_name(key): + handler = opened() + handler.save(field_free_plane()) + with pytest.raises(OutputError, match=rf"does not hold '{key}'.*keeps nothing"): + handler[key] + assert key not in handler + + +@pytest.mark.parametrize("convention", ["optics", "fft"]) +def test_reading_the_fields_is_refused_by_name(convention): + with pytest.raises(OutputError, match="does not hold the fields"): + opened().fields(convention) + + +def test_what_reads_a_run_is_refused_by_name(): + handler = fibre("numpy_ref").run(CallbackOutput(nothing), progress="none") + assert handler.n_saved == 3 + with pytest.raises(OutputError, match="does not hold 'field_planes'"): + capabilities.output(handler) + with pytest.raises(OutputError, match="does not hold 'field'"): + field_modes(handler) + with pytest.raises(OutputError, match="does not hold"): + energy(handler) + + +@pytest.mark.parametrize("choice", [True, "interpolated"]) +def test_a_resume_is_refused_by_name(choice): + planes: list[SavedPlane] = [] + handler = CallbackOutput(planes.append) + simulation = cartesian("numpy_ref") + with pytest.raises(DriverError, match="only a path can be resumed"): + simulation.run(handler, resume=choice, progress="none") + assert handler.metadata is None + assert not planes + + +@pytest.mark.parametrize( + ("reader", "name"), + [ + (lambda handler: check_cache(handler, _example_metadata()), "check_cache"), + (resume, "resume"), + (resumability, "resumability"), + ], + ids=["check_cache", "resume", "resumability"], +) +@pytest.mark.parametrize("build", [opened, MemoryOutput], ids=["callback", "memory"]) +def test_a_check_against_a_handler_is_refused_by_name(reader, name, build): + handler = build() + with pytest.raises( + OutputError, match=rf"{name} reads a results file.*{type(handler).__name__}" + ): + reader(handler) + + +def test_a_handler_that_has_served_a_run_refuses_another(): + simulation = cartesian("numpy_ref") + handler = simulation.run(CallbackOutput(nothing), progress="none") + served = handler.n_saved + with pytest.raises(OutputError, match="no run can continue it"): + simulation.run(handler, progress="none") + with simulation.runner() as runner: + with pytest.raises(OutputError, match="no run can continue it"): + runner.run(simulation._field0, output=handler, progress="none") + # The runner is unharmed, and the next input takes a handler of its own. + assert not runner.closed + again = runner.run( + simulation._field0, output=CallbackOutput(nothing), progress="none" + ) + assert again.n_saved == handler.n_saved == served + + +def test_a_handler_holding_planes_is_not_continued(): + """The in-memory form of a resume: the run asks the handler where it stands.""" + simulation = cartesian("numpy_ref") + problem = simulation.translate() + metadata = simulation.metadata() + planes: list[SavedPlane] = [] + handler = CallbackOutput(planes.append) + handler.open(metadata) + handler.save(field_free_plane()) + with ( + simulation.runner() as runner, + pytest.raises(OutputError, match="no run can continue it"), + ): + run_problem(problem, runner.backend, handler, metadata=metadata) + assert len(planes) == 1 + + +# ---------------------------------------------------------- how a run ends early + + +class _RefusedError(Exception): + """What the callable raises, which nothing in the package does.""" + + +@pytest.mark.parametrize("backend", available()) +def test_what_the_callable_raises_stops_the_run_and_propagates(backend): + def refuse_the_second(plane: SavedPlane) -> None: + if plane.z > 0.0: + raise _RefusedError + + simulation = cartesian(backend) + handler = CallbackOutput(refuse_the_second) + with pytest.raises(_RefusedError): + simulation.run(handler, progress="none") + assert handler.n_saved == 1 + assert repr(handler) == "CallbackOutput(1 planes, closed)" + # A run that raises leaves a runner open for the next input. + with simulation.runner() as runner: + with pytest.raises(_RefusedError): + runner.run( + simulation._field0, + output=CallbackOutput(refuse_the_second), + progress="none", + ) + assert not runner.closed + assert runner.run( + simulation._field0, output=CallbackOutput(nothing), progress="none" + ).n_saved == handler.n_saved + len(simulation.settings.save_planes) + + +@pytest.mark.parametrize("backend", available()) +def test_a_stopped_run_returns_the_handler_with_no_state_written(backend, caplog): + """Nothing can continue it, so the run does not say a state was written.""" + flag = threading.Event() + simulation = cartesian(backend) + + def stop_at_the_entrance(plane: SavedPlane) -> None: + flag.set() + + handler = CallbackOutput(stop_at_the_entrance) + with caplog.at_level(logging.INFO, logger="solprop.driver.run"): + assert simulation.run(handler, progress="none", stop=flag) is handler + assert handler.n_saved == 1 + assert "no state was written there" in caplog.text + assert "resume=True continues" not in caplog.text + + +@pytest.mark.medium +@pytest.mark.parametrize("backend", available()) +def test_a_run_with_an_optmat_rate_finishes_with_no_report(backend): + """A store is read back for the peak rates; this handler answers that it has none. + + The rate is there for the callable to read, which is what the guide tells a scan. + """ + simulation = Simulation.from_capillary( + 125e-6, + 1e-3, + "Ar", + 1.0, + wavelength=800e-9, + wavelength_limits=(200e-9, 2e-6), + trange=100e-15, + fwhm=10e-15, + energy=20e-6, + n_save=2, + rtol=1e-6, + backend=backend, + noise=None, + ) + rates: list[np.ndarray] = [] + + def receive(plane: SavedPlane) -> None: + rates.append(plane.stats["peak_ionisation_rate"]) + + handler = simulation.run(CallbackOutput(receive), progress="none") + assert handler.n_saved == len(rates) == 2 + assert simulation._peak_fields == () + with simulation.runner() as runner: + scanned = runner.run( + simulation._field0, output=CallbackOutput(receive), progress="none" + ) + assert scanned.n_saved == 2 + # The store answers on the same run: what the handler gives up is this. + store = runner.run(simulation._field0, progress="none") + assert isinstance(store, PlaneStore) + assert PEAK_FIELDS_ANNOTATION in store.annotations From bc922c632db0934abbb28582d7c22c73742a9cf6 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 00:47:22 +0000 Subject: [PATCH 107/122] Show a reducing scan in the running guide, and test that it runs The guide's first executed block on this page, run on its own in the default lane as well as with the page in the slow lane. It says what fields="none" gives the callable, what a plane costs, and what the handler refuses. For #499. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/api/index.md | 2 +- docs/dev/architecture.md | 5 ++ docs/guide/results/output.md | 4 +- docs/guide/setup/running.md | 81 +++++++++++++++++++++++++++++ docs/guide/workflows/interactive.md | 2 +- tests/output/test_callback.py | 15 ++++++ 6 files changed, 106 insertions(+), 3 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index 117f78ae..0e606f75 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -52,7 +52,7 @@ In scripts and library code, import from the home modules listed below. | module | what it holds | |---|---| -| {mod}`solprop.output` | the in-memory and HDF5 output handlers, the run's metadata record, `open_file` and resuming a run, live snapshots (`SnapshotController`, `read_snapshot`, `restore_snapshot`; [the output page](../guide/results/output.md)), and the raw TDSE response archive (`TDSEArchive`, `open_tdse_archive`; [the TDSE response](../guide/media/tdse.md#the-raw-response-archive)) | +| {mod}`solprop.output` | the in-memory and HDF5 output handlers, `CallbackOutput` for a run reduced plane by plane, the run's metadata record, `open_file` and resuming a run, live snapshots (`SnapshotController`, `read_snapshot`, `restore_snapshot`; [the output page](../guide/results/output.md)), and the raw TDSE response archive (`TDSEArchive`, `open_tdse_archive`; [the TDSE response](../guide/media/tdse.md#the-raw-response-archive)) | | {mod}`solprop.processing` | post-processing of a saved run: fields, spectra, pulse metrics, coherence, compression, the physical fields of a general modal run, and `field_plane`, a saved plane read back for coupling | | {mod}`solprop.coupling` | coupling simulations through a field plane: `FieldPlane` (from a run, a `SpatialPulse`, an analytic field or arrays), `regrid`, `InputPort`, `couple` under a `CouplingModel` with its `CouplingReport`, the scalar profiles `ScalarModeBasis`, the recorded pair envelopes and `Outcouple`; `transport` through the linear elements `FreeSpace`, `ThinLens`, `Aperture`, `TransmissionMask`, `Alignment`, `PolarisationTransform`, `Mirror`, `PlanarInterface` and `SpectralTransfer` ([coupling simulations](../guide/workflows/coupling.md); [the transport report](transport.md)) | | {mod}`solprop.stats` | the statistics recorded at the saved planes, and `StatsSpec` to choose them | diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index c43da41d..3b79629d 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -600,6 +600,11 @@ A TDSE run may also write its raw provider evaluations to a separate archive (`output/tdse_archive.py`, schema `solprop.tdse-archive.v1`), which the optical record references and never needs ([the archive page](../guide/media/tdse.md)). +`CallbackOutput` (`output/callback.py`) stores nothing: it implements `Output` and +`RetainingOutput` and hands each plane to a callable, unconverted. It is deliberately not a +`PlaneStore`, whose save path converts the field into a new volume, and not a +`CheckpointOutput`, which promises a state a resume can continue. + `output/snapshots.py` coordinates optional immutable disk snapshots at the existing accepted-step commit boundary. It persists retained march state and execution facts, suspends the optical and optional archive handles while their advisory writer locks diff --git a/docs/guide/results/output.md b/docs/guide/results/output.md index ea6027f2..99861f93 100644 --- a/docs/guide/results/output.md +++ b/docs/guide/results/output.md @@ -143,7 +143,9 @@ writes its terminal checkpoint **with** the field ([resuming](resuming.md)). A handler of your own takes field-free planes by declaring {py:class}`~solprop.output.schema.RetainingOutput`, one method beside the {py:class}`~solprop.output.schema.Output` protocol; a run that keeps fewer than every field -refuses a handler without it, by name. +refuses a handler without it, by name. To reduce each plane and keep nothing, no class is +needed: {py:class}`~solprop.output.callback.CallbackOutput` is built from a callable +([a scan that reduces each plane](../setup/running.md#a-scan-that-reduces-each-plane)). ```{code-block} python class Energies: diff --git a/docs/guide/setup/running.md b/docs/guide/setup/running.md index 5aee0d3e..2d73cfb5 100644 --- a/docs/guide/setup/running.md +++ b/docs/guide/setup/running.md @@ -555,3 +555,84 @@ CPU at 256 × 256 × 48, with sixteen steps and two planes at sixteen threads, t from 1.5 to 2.1 volumes above the level before the run. A scan whose window is already settled can still pass `boundary_check=None`. That also turns off the check of the time window's edges, since no setting keeps one without the other, so settle the window on one input with the check on first. + +### A scan that reduces each plane + +A scan whose result is a few numbers per input has no use for a store. +{py:class}`~solprop.output.callback.CallbackOutput` is a handler built from a callable: it +gives each {py:class}`~solprop.output.schema.SavedPlane` to the callable, the entrance +first, and keeps nothing, so the body of a hand loop over the backend moves into a few +lines. Most reductions want statistics and no field, which is a run with `fields="none"` +and the statistics it names: + +```python +import dataclasses + +from solprop.driver import Simulation +from solprop.output import CallbackOutput +from solprop.pulses import SechPulse +from solprop.stats import StatsSpec + +simulation = Simulation.from_gnlse( + 0.11, + 0.01, + [0.0, 0.0, -1.183e-26], + wavelength=835e-9, + wavelength_limits=(500e-9, 2e-6), + trange=1.2e-12, + width=100e-15, + peak_power=9.8, + shape="sech", + n_save=3, + rtol=1e-6, + stats=StatsSpec(names=("peak_power",)), +) +simulation.settings = dataclasses.replace(simulation.settings, fields="none") + +peak_power = {} +with simulation.runner() as runner: + for launched in (8.0, 9.8, 12.0): + peaks = [] + runner.run( + SechPulse(wavelength=835e-9, width=100e-15, peak_power=launched), + output=CallbackOutput( + lambda plane: peaks.append(plane.stats["peak_power"][0]) + ), + progress="none", + ) + peak_power[launched] = max(peaks) +``` + +Under `fields="none"` the callable's plane carries `z`, `stats`, `step_history` and +`step_stats`, with `field=None` and `noise_phase=None`, and no field leaves the backend, +even on a device. `Simulation.run` takes the same handler for a single run. + +To reduce the field itself, keep it: the default `fields="all"`, or `fields="planes"`, whose +other planes arrive with `field=None`. The plane then holds the backend's observation +itself, read-only and not copied, so a plane reduced and let go costs one field volume on +the host and the handler adds none. (A modal route first converts the amplitudes to true +power in a new array, so that conversion holds a second volume while it runs.) The field is +in the internal FFT convention, in the square root of watts: `np.abs(plane.field) ** 2` +does not depend on the convention, and {py:func}`~solprop.fourier.spectrum_to_optics` gives +what a file stores, in a second volume. + +Nothing writes to a plane's arrays again, so the callable may keep any of them, at the cost +of what it keeps: `plane.field` is a volume. What the callable raises stops the run and +propagates, and that plane is not counted. + +What the handler cannot do, it refuses by name: + +- Reading anything back: `handler["stats/peak_power"]` and `handler.fields()` raise + {py:class}`~solprop.exceptions.OutputError`, and so does whatever reads a run through + them; `key in handler` answers `False`. +- A resume: `resume=True` needs a path, and + {py:func}`~solprop.output.hdf5.check_cache`, {py:func}`~solprop.output.hdf5.resume` and + {py:func}`~solprop.output.resumable.resumability` read a file and refuse any handler. +- A second run: having kept no plane, it has nothing to continue, so build one per run. +- A TDSE run, whose call ledger has to be recorded. + +It also has nowhere to put a checkpoint or a record: a run stopped with `stop=` returns the +handler with no state written, and a run with an optmat ionisation rate, which reads its +peak rates back from its output, neither reports its peak field nor warns past the rate's +range ({py:class}`~solprop.exceptions.MaterialRangeWarning`). Read `peak_ionisation_rate` +in the callable where that matters. diff --git a/docs/guide/workflows/interactive.md b/docs/guide/workflows/interactive.md index 18fdcd5b..efd11641 100644 --- a/docs/guide/workflows/interactive.md +++ b/docs/guide/workflows/interactive.md @@ -91,7 +91,7 @@ the same object as in its home module: * - runs - ``prop_capillary``, ``prop_gnlse``, ``prop_waveguide``, ``prop_radial``, ``prop_free``, ``prop_planar``, ``Simulation``, ``SimulationSettings``, ``Multistep``, ``RungeKutta``, ``Scan``, ``ScanPoint``, ``run_scan``, ``Local``, ``Pool``, ``Queue``, ``Slurm``, ``Batch`` * - outputs - - ``open_file``, ``resume``, ``MemoryOutput``, ``HDF5Output``, ``open_scan``, ``ScanOutput`` + - ``open_file``, ``resume``, ``MemoryOutput``, ``HDF5Output``, ``CallbackOutput``, ``open_scan``, ``ScanOutput`` * - building blocks - the grids, the pulses, ``MarcatiliMode``, ``VincettiMode``, ``TubeLattice``, ``StepIndexMode``, ``RectMode``, ``SlabMode`` (with ``gaussian_coupling``, ``gaussian_projection``, ``per_unit_width_to_gaussian`` and ``gaussian_to_per_unit_width``), ``PlanarGrid``, ``IndexTable``, the tabulated modes, ``ModeCollection``, ``Gas``, ``Mixture``, ``species``, ``MaterialSelection``, the glasses and ``glass``, ``Constant``, ``Linear``, ``PiecewiseLinear``, ``PressureGradient``, ``DensityPath``, ``OptmatRate``, ``KerrResponse``, ``PlasmaResponse``, ``EnvelopePlasmaResponse``, ``RamanResponse``, ``DampedOscillator``, ``RotationalLines``, ``SpecifiedMode`` * - mode and material numbers diff --git a/tests/output/test_callback.py b/tests/output/test_callback.py index 68d06f21..817c3627 100644 --- a/tests/output/test_callback.py +++ b/tests/output/test_callback.py @@ -18,6 +18,7 @@ import tracemalloc import weakref from collections.abc import Callable +from typing import cast import numpy as np import pytest @@ -54,6 +55,7 @@ from solprop.plotting.style import field_modes from solprop.processing import energy from solprop.stats import StatsSpec, default_spec +from test_guide_snippets import GUIDE, guide_code # These cases run coarse grids for speed; their windows are not what is tested here. pytestmark = pytest.mark.filterwarnings("ignore::solprop.exceptions.BoundaryWarning") @@ -322,6 +324,19 @@ def test_a_runner_hands_over_what_a_store_would_hold(backend, build, rounding): assert runner.runs == 4 +def test_the_guides_reducing_scan_runs(): + page = GUIDE / "setup" / "running.md" + names: dict[str, object] = {"__name__": "__guide__"} + exec(compile(guide_code(page), str(page), "exec"), names) + peaks = cast("dict[float, float]", names["peak_power"]) + assert list(peaks) == [8.0, 9.8, 12.0] + # Each input reached its own callable: a stronger launch has the higher peak. + assert peaks[8.0] < peaks[9.8] < peaks[12.0] + # The page's case is the statistics-only one. + simulation = cast("Simulation", names["simulation"]) + assert simulation.settings.fields == "none" + + # ------------------------------------------------------------------- what it costs From 5f8b0477186b87c6d7953fea7ad399264bd6239c Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 01:01:52 +0000 Subject: [PATCH 108/122] Catch a handler holding a plane across a step The check ran when the next plane reached the callable, by when a handler holding only the latest plane had already let the earlier one go. It now runs just before the next observation is made, which is when a held plane would be a second volume. A mutation keeping the last plane showed the gap. For #499. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/output/test_callback.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/output/test_callback.py b/tests/output/test_callback.py index 817c3627..19ab8ec9 100644 --- a/tests/output/test_callback.py +++ b/tests/output/test_callback.py @@ -32,6 +32,7 @@ from solprop.backends.protocol import Observation from solprop.backends.registry import available from solprop.driver import Simulation +from solprop.driver import run as run_module from solprop.driver.run import run_problem from solprop.exceptions import DriverError, OutputError from solprop.output import ( @@ -188,23 +189,31 @@ def test_a_different_record_is_refused_unless_forced(): @pytest.mark.parametrize("backend", available()) @pytest.mark.parametrize("case", CASES) @pytest.mark.usefixtures("no_collector") -def test_every_march_delivers_its_planes_and_keeps_none_alive(backend, case): +def test_every_march_delivers_its_planes_and_keeps_none_alive( + backend, case, monkeypatch +): """Fields on the first two planes and none after: one run of each kind of plane. - At each plane the plane before it must be gone, so that one observation lives at a - time. This is the volume the native backend's observation costs, which it allocates - outside the tracer's sight, and it would go unseen if the run or the handler held - on. + When a plane is about to be observed the plane before it must be gone, so that one + observation lives at a time. This is the volume the native backend's observation + costs, which it allocates outside the tracer's sight, and it would go unseen if the + run or the handler held on across a step. """ simulation = keeping(CASES[case](backend), "planes", (0, 1)) earlier: list[weakref.ref] = [] kept: list[bool] = [] positions: list[float] = [] alive: list[bool] = [] + observed = run_module._observed - def receive(plane: SavedPlane) -> None: + def watched(problem, backend, *, keep) -> Observation: alive.extend(reference() is not None for reference in earlier) earlier.clear() + return observed(problem, backend, keep=keep) + + monkeypatch.setattr(run_module, "_observed", watched) + + def receive(plane: SavedPlane) -> None: earlier.append(weakref.ref(plane)) if plane.field is not None: earlier.append(weakref.ref(plane.field)) From 636a5b21bdac83414af4fb3e7e96562af74fe74e Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 01:18:49 +0000 Subject: [PATCH 109/122] Let the command's status say what the file says A signal after a run's last poll stops nothing, so it no longer exits as stopped or says to resume. A signal during a recipe's build is documented as waiting for the build. Closes #521. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/519-native-adams-stop.fixed.md | 2 +- docs/guide/results/resuming.md | 5 ++++- src/solprop/cli.py | 15 +++++++++++-- tests/driver/test_native_march_lifecycle.py | 25 +++++++++++++++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/changelog.d/519-native-adams-stop.fixed.md b/changelog.d/519-native-adams-stop.fixed.md index bf1bb127..453e4525 100644 --- a/changelog.d/519-native-adams-stop.fixed.md +++ b/changelog.d/519-native-adams-stop.fixed.md @@ -2,4 +2,4 @@ plane: within a native call's bound, 256 attempts or 0.1 s, and the attempt in flight. With one plane the flag was ignored and the run finished as `complete`. `solprop run` and `solprop resume`, whose first SIGTERM or Ctrl-C reaches a run through that flag, stop the -same way; they used to report a stop that the file showed had not happened (#519). +same way; they used to report a stop that the file showed had not happened (#519). A signal that comes too late to stop the run now exits with status zero and says the file is complete (#521). diff --git a/docs/guide/results/resuming.md b/docs/guide/results/resuming.md index 826d7407..cf794a73 100644 --- a/docs/guide/results/resuming.md +++ b/docs/guide/results/resuming.md @@ -194,7 +194,10 @@ requested stop is not an error, and the outcome's message says the caller's flag it. `resume=True` continues the file. A front end sets the flag from any thread, on any platform, with no signals; each run polls its own. `solprop run` and `solprop resume` set it on Ctrl-C, SIGTERM and, on Windows, Ctrl-Break, and exit with the usual status; a -second Ctrl-C interrupts at once. +second Ctrl-C interrupts at once. A signal that comes too late to stop anything, with the file +already complete, exits with status zero and says so. One that comes while a recipe is +building waits for the build to finish, since the flag is only polled once a march is running, +so a large grid's build can take most of a batch system's grace period. The exception is the native backend's `Multistep()` march, which runs many attempts in one native call, where the flag cannot be read. The run polls before each call instead, diff --git a/src/solprop/cli.py b/src/solprop/cli.py index 1b1b7633..0b1b85ea 100644 --- a/src/solprop/cli.py +++ b/src/solprop/cli.py @@ -157,12 +157,23 @@ def _stopped(received: list[int], output: Path) -> int: Returns ------- int - Zero, or ``128`` plus the first signal's number. + Zero, or ``128`` plus the first signal's number. A signal that came too late to + stop anything, since the file says the run completed, is zero too. """ if not received: return 0 + from solprop.output.hdf5 import open_file # noqa: PLC0415 - imports the physics + + with open_file(output) as stored: + complete = stored.outcome is not None and stored.outcome.status == "complete" + name = signal.Signals(received[0]).name + if complete: + sys.stderr.write( + f"solprop: {name} arrived too late to stop the run; {output} is complete\n" + ) + return 0 sys.stderr.write( - f"solprop: stopped by {signal.Signals(received[0]).name}; the state is " + f"solprop: stopped by {name}; the state is " f"written to {output}, and solprop resume {output} continues it\n" ) return 128 + received[0] diff --git a/tests/driver/test_native_march_lifecycle.py b/tests/driver/test_native_march_lifecycle.py index ab490e35..f3a3d185 100644 --- a/tests/driver/test_native_march_lifecycle.py +++ b/tests/driver/test_native_march_lifecycle.py @@ -27,6 +27,7 @@ from solprop.cli import main from solprop.exceptions import ConvergenceError, ResponseBudgetError, RunTerminated from solprop.output.hdf5 import open_file, resume +from solprop.recipe import Recipe from solprop.stepper.multistep import Multistep pytestmark = [ @@ -413,3 +414,27 @@ def test_both_marches_take_the_same_steps(build_simulation, tmp_path, monkeypatc ) assert found["native"]["error"] is None assert_same_stop(found["native"], found["host"]) + + +@needs_default_sigterm +def test_a_signal_too_late_to_stop_the_run_says_so(tmp_path, monkeypatch, capsys): + # A signal after the run's last poll stops nothing: the file is complete, so the + # command mustn't exit as stopped, or tell anyone to resume it. + recipe, output = tmp_path / "soliton.toml", tmp_path / "run.h5" + recipe.write_text(ONE_PLANE_RECIPE, encoding="utf-8") + run = Recipe.run + + def signalled(self, *args, **kwargs): + result = run(self, *args, **kwargs) + signal.raise_signal(signal.SIGTERM) + return result + + monkeypatch.setattr(Recipe, "run", signalled) + status = main(["run", str(recipe), "-o", str(output), "--progress", "none"]) + assert status == 0 + error = capsys.readouterr().err + assert "too late" in error + assert "solprop resume" not in error + with open_file(output) as stored: + assert stored.outcome is not None + assert stored.outcome.status == "complete" From 6f82f978b92b23247c2f8720d5c384b3da5bd2b4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 01:19:59 +0000 Subject: [PATCH 110/122] Read a stopped run's file only if it wrote one Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/solprop/cli.py b/src/solprop/cli.py index 0b1b85ea..598fa793 100644 --- a/src/solprop/cli.py +++ b/src/solprop/cli.py @@ -162,10 +162,16 @@ def _stopped(received: list[int], output: Path) -> int: """ if not received: return 0 + from solprop.exceptions import OutputError # noqa: PLC0415 - imports the physics from solprop.output.hdf5 import open_file # noqa: PLC0415 - imports the physics - with open_file(output) as stored: - complete = stored.outcome is not None and stored.outcome.status == "complete" + # A signal during the build can stop a run before it has written anything. + try: + with open_file(output) as stored: + outcome = stored.outcome + except (OSError, OutputError): + outcome = None + complete = outcome is not None and outcome.status == "complete" name = signal.Signals(received[0]).name if complete: sys.stderr.write( From ccd701d5ab8b65bc28be35193b17edf59552fbea Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 02:36:48 +0000 Subject: [PATCH 111/122] Keep a device's split collar at the node chunk, and draw the handler A whole state at a time missed the 10 % #361 asks of a default-memory change on both data-centre cards: at 768 x 768 x 600, 8 steps, x0.996 on an NVIDIA H200 and x0.978 on an AMD Instinct MI325X. So this reverts ae82a2a9, with the seam field it added: `Transform.chunk_budgeted`, `split_rows` on the native problem, and the identity check translation needed for it. The collar stays in place on the state's rows. A device's choices are still tested on any build. The ledger keeps the numbers. `output/callback.py` goes into the layers figure, which CI found missing on every lane. It was the only failure of 18,500 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/device-split-collar.changed.md | 2 +- docs/dev/benchmarks.md | 15 +- docs/dev/figures/build.py | 1 + docs/dev/figures/layers.svg | 155 +++++++++++---------- docs/dev/kernel-contract.md | 1 - docs/guide/performance/tuning.md | 3 +- native/include/solprop_native/core.hpp | 5 - native/src/bindings.cpp | 7 - native/src/general.cpp | 9 +- native/src/solprop_native/__init__.py | 2 +- native/src/solprop_native/_core.pyi | 2 - native/src/solprop_native/backend.py | 18 --- src/solprop/driver/simulation.py | 3 +- src/solprop/translate/modal.py | 5 - src/solprop/translate/problem.py | 7 - tests/backends/test_free_split_rows.py | 101 +------------- 16 files changed, 99 insertions(+), 237 deletions(-) diff --git a/changelog.d/device-split-collar.changed.md b/changelog.d/device-split-collar.changed.md index 0ab89a84..9a9853ec 100644 --- a/changelog.d/device-split-collar.changed.md +++ b/changelog.d/device-split-collar.changed.md @@ -1 +1 @@ -A device's low-storage step costs less beyond its transforms. A free-space envelope's split-step temporal collar now transforms the state's own rows in place and holds no coarse buffers, a whole state at a time on a device unless `node_budget` is stated (106 chunks of 5 kernels become one block at 768 x 768 x 600); a flow's lossless bin takes no exponential; and a device's first input crosses to the scratch through tiles. On an NVIDIA A40 that step falls from 2.66 s to 2.56 s, with the fields unchanged to rounding. `Transform` has a new field, `chunk_budgeted`. +A device's low-storage step costs less beyond its transforms. A free-space envelope's split-step temporal collar now transforms the state's own rows in place and holds no coarse buffers; a flow's lossless bin takes no exponential; and a device's first input crosses to the scratch through tiles. On an NVIDIA A40 that step falls from 2.66 s to about 2.56 s, with the fields unchanged to rounding. diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index e28a3e10..b4f36708 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2323,7 +2323,7 @@ route, was 239 ms on an NVIDIA H200 and 224 ms on an AMD Instinct MI325X, 112 to AMD EPYC 7543 host, 16 threads), whose binary64 rate is a sixty-fourth of its binary32 rate, so its share of each kernel says little about an H200. They were taken at `cb2cb068` against the merge base `43ddefab`, by a Kokkos Tools trace of that one case with a fence around each kernel. -| Per step, 768 x 768 x 600 | Before | Collar in place | A whole state on a device | +| Per step, 768 x 768 x 600 | Before | Collar in place | A whole state, tried and dropped | |---|---:|---:|---:| | Kernels | 330 | 119 | 14 | | Transforms | 220 | 220 | 10 | @@ -2344,11 +2344,14 @@ The whole step takes 0.961 of its time at 768 x 768 x 600 and 0.956 at 384 x 384 (0.954–1.036, a window of two steps, inside its spread), fields to 3.3e-16; `rdw` 1.000, `he_rdw_averaged` 1.002, `radial_filament_256` 1.000, fields bitwise. **The collar** depends on time alone, so its transform pair runs in place on the state's own rows, with the normalisation on the factor. Most of the A40's gain is the two -copies gone at the node chunk: 2,578.6 ms a step, against 2,574.6 for a whole state on the same build. What a whole state saves is launches, and each rocFFT transform is four -kernels, so what they are worth takes a data-centre card to say. cuFFT's work area is small here: at 589,824 rows of 600 points each plan holds 9 MiB, in place or -not, 0.1 MiB at the node chunk. The plans' device growth rises by 20 MiB and the two coarse buffers' 102 MiB go, so a device run's default memory falls by 82 MiB of its 38.5 GiB. What rocFFT holds was not measured. A stated `node_budget` -keeps the block to the chunk. A CPU keeps the chunk too: on an Intel Xeon Gold 6240, nine cores, at 384 x 384 x 600, FFTW's threaded plan of a whole state took -2.5 s a step in transforms against 1.5 s. +copies gone at the node chunk: 2,578.6 ms a step, against 2,574.6 for a whole state on the same build. The "After" column and the `python -m benchmarks ab` figures above were +taken with a whole state, which a device took by default until the cards were measured. + +**A whole state was dropped.** It saves launches, and #361 asks 10 % of a default-memory change. At 768 x 768 x 600, 8 steps, three fresh processes each at `9a5d2346`, median +whole against node chunk: NVIDIA H200 2.661 s against 2.671 s, x0.996; AMD Instinct MI325X 2.023 s against 2.068 s, x0.978. So a device keeps the node chunk, and +`Transform` needs no field to tell a stated budget from the default. The plans' device growth was the same to 6 MiB on the MI325X, 23.65 GiB of 42.6 GiB either way, and cuFFT's +work area is small (at 589,824 rows of 600 points each plan holds 9 MiB, in place or not, 0.1 MiB at the node chunk), so it would not have cost memory either. A CPU keeps the +chunk for its own reason: on an Intel Xeon Gold 6240, nine cores, at 384 x 384 x 600, FFTW's threaded plan of a whole state took 2.5 s a step in transforms against 1.5 s. **The fold.** A flow's magnitude on a lossless bin is `exp(+-0)`, exactly one, and took a binary64 `exp` per element: 13.4 ms a pass on the A40, whose reduced binary64 rate overstates it. Runge-Kutta's `stage_input` and `finish_transform` fall by 13 % the same way. The flowless pass forms no exponent (64.3 to 61.5 ms). The first input crosses the diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 03b01fa5..1917540d 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -757,6 +757,7 @@ class Layer(NamedTuple): "schema", "capability", "memory", + "callback", "hdf5", "tdse_archive", "snapshots", diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index e89f1475..4b4f6a95 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -1,6 +1,6 @@ + width="1200" height="1684.5" viewBox="0 0 1200 1684.5"> - + The layers of solprop, and every module in each Bands and colours from architecture.md §3 and decisions.md ADR-001; modules from src/solprop. Arrows point from the layer that imports to the layer it imports. @@ -193,70 +193,71 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: backends/mlx_gpu/ backend · batch · passes arithmetic · envelope - - -D -driver -may import -P, T -B through the seam, h5py - + + +D +driver +may import +P, T +B through the seam, h5py + log.py - + stats.py - + stepper/ tableau · runge_kutta · multistep low_storage · controller · schedule - + output/ -schema · capability · memory · hdf5 -tdse_archive · snapshots · scan -scan_artifacts · resumable - -scan.py - -driver/executors/ -local · pool · queue · slurm -batch - -driver/ -simulation · run · summary -interface · scaffold · mixtures -material_output · tdse -tdse_consistency · scan · batch -defaults · estimate · plane_view - -recipe/ -core · document · errors · export -metadata · paths · pins · preview -provenance · quantity · registry - -cli.py - - -X -processing / plotting -may import -numpy, scipy -matplotlib, D (outputs) -T (table identities) - -processing/ -axes · fields · spectra · time -coherence · compression · mixtures -modal · planes · scans - -plotting/ -propagation · statistics -spectrograms · style · geometry - -sol.py - -capabilities.py - -processing/ -windows +schema · capability · memory +callback · hdf5 · tdse_archive +snapshots · scan · scan_artifacts +resumable + +scan.py + +driver/executors/ +local · pool · queue · slurm +batch + +driver/ +simulation · run · summary +interface · scaffold · mixtures +material_output · tdse +tdse_consistency · scan · batch +defaults · estimate · plane_view + +recipe/ +core · document · errors · export +metadata · paths · pins · preview +provenance · quantity · registry + +cli.py + + +X +processing / plotting +may import +numpy, scipy +matplotlib, D (outputs) +T (table identities) + +processing/ +axes · fields · spectra · time +coherence · compression · mixtures +modal · planes · scans + +plotting/ +propagation · statistics +spectrograms · style · geometry + +sol.py + +capabilities.py + +processing/ +windows T → P physics objects @@ -266,28 +267,28 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: D → B backends.select, then the seam - - -X → D a saved run, through the Output protocol - + + +X → D a saved run, through the Output protocol + - -1 - + +1 + - -2 + +2 3 - + - -4 -1 Simulation calls translate.assemble -2 the driver builds the grid, the modes, the pulse and the responses -3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object -4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X -The package-wide modules carry no layer letter: __init__.py · exceptions.py + +4 +1 Simulation calls translate.assemble +2 the driver builds the grid, the modes, the pulse and the responses +3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object +4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X +The package-wide modules carry no layer letter: __init__.py · exceptions.py \ No newline at end of file diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md index ee74816c..c01604d9 100644 --- a/docs/dev/kernel-contract.md +++ b/docs/dev/kernel-contract.md @@ -608,7 +608,6 @@ sections and refuse a `"cf4"` section by name | `projection_coarse` | same | the embedded coarse-rule projection for the error estimate | | `taper_scale` | `ZProfile` table or `None` | the scalar rescaling of synthesis/projection along z for scale-invariant modes | | `node_chunk` | int | nodes per chunk for the response pass | -| `chunk_budgeted` | bool | whether the caller's own `node_budget` set `node_chunk`: without one, a device takes a free-space envelope's split collar a whole state at a time, the one block it holds no buffer for | | `aeff` | `ZProfile` table | effective area for mode-averaged normalisation (`kind == "mode_averaged"`), a diagnostic otherwise | | `geometry` | `SpatialGeometry` or `None` | the spatial leaf below; absent for modal transforms | | `node_weights` | `float64 (n_nodes,)` or `None` | physical transverse weights in m² at z=0 for resolved modal populations; divide by taper scale squared at later planes; mode-averaged runs use `aeff` and spatial runs use geometry weights | diff --git a/docs/guide/performance/tuning.md b/docs/guide/performance/tuning.md index d28202cb..17c896a9 100644 --- a/docs/guide/performance/tuning.md +++ b/docs/guide/performance/tuning.md @@ -104,8 +104,7 @@ plane's columns in node chunks, each paying a fixed cost for its launches, its t transforms and its gather and scatter. Their default budget is therefore 1 GiB, against 64 MiB on modal routes; `SimulationSettings.node_budget` sets it explicitly. On a laptop short of memory a smaller budget saves memory at some speed; on a large device a larger -one can still help. An envelope's split collar transforms the state's own rows in place -and holds no buffer for them, so a device takes them all at once unless a budget is set. +one can still help. **Where a free-space step goes.** On the native backend the stages of a free-space evaluation are profiling regions (`solprop_native.free.*`) that any Kokkos Tools timer diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index 65abbc4d..5373977b 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -489,9 +489,6 @@ struct Problem { std::size_t n_pol_transform{1}; std::size_t n_nodes{1}; std::size_t node_chunk{1}; - // The temporal rows a free-space envelope's split collar transforms at a time, in - // place on the state; zero takes the node chunk's. It holds no buffer of its own. - std::size_t split_rows{}; std::size_t n_stages{}; // solprop SEP 24 phase 3 (kernel schema 32): the low-storage RK4IP march. With it no // Runge-Kutta view is allocated (stages, accumulators, accept buffer, residual, @@ -978,8 +975,6 @@ class GeneralState { [[nodiscard]] std::map memory_ledger() const; [[nodiscard]] CpuSizing execution_sizing() const; [[nodiscard]] double planning_seconds() const noexcept; - /** The rows a free-space envelope's split collar transforms at a time, or zero. */ - [[nodiscard]] std::size_t split_rows() const noexcept; [[nodiscard]] double raman_planning_seconds() const noexcept; [[nodiscard]] std::string raman_fft_provider() const; [[nodiscard]] std::map raman_fft_tasks() const; diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index d6800c11..37682c1b 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -192,8 +192,6 @@ native::Problem problem_from(const nb::dict& values) { result.n_pol_transform = nb::cast(values["n_pol_transform"]); result.n_nodes = nb::cast(values["n_nodes"]); result.node_chunk = nb::cast(values["node_chunk"]); - if (values.contains("split_rows")) - result.split_rows = nb::cast(values["split_rows"]); } } catch (const nb::cast_error&) { throw std::invalid_argument("problem dimensions must be integers"); @@ -1569,10 +1567,6 @@ class BoundState { require_open(); return general_state_->planning_seconds(); } - std::size_t split_rows() const { - require_open(); - return general_state_->split_rows(); - } double raman_planning_seconds() const { require_open(); return general_state_->raman_planning_seconds(); @@ -3031,7 +3025,6 @@ NB_MODULE(_core, module_) { .def_prop_ro("execution_sizing", &BoundState::execution_sizing) .def_prop_ro("raman_fft_tasks", [](BoundState& self) { return self.raman_fft_tasks(); }) - .def_prop_ro("split_rows", [](BoundState& self) { return self.split_rows(); }) .def_prop_ro("raman_planning_seconds", [](BoundState& self) { return self.raman_planning_seconds(); }) .def_prop_ro("raman_kernel_transforms", diff --git a/native/src/general.cpp b/native/src/general.cpp index fa532060..430eccbc 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -992,9 +992,6 @@ void require_general_problem(const Problem& problem) { throw std::invalid_argument("at most two effective node components are supported"); if (problem.node_chunk == 0 || problem.node_chunk > problem.n_nodes) throw std::invalid_argument("node_chunk must lie in [1, n_nodes]"); - if (problem.split_rows > - checked_product(problem.n_modes, problem.n_pol_state, "split collar columns")) - throw std::invalid_argument("split_rows must not exceed the field's columns"); if (problem.n_stages == 0) throw std::invalid_argument("the tableau must contain a stage"); const auto columns = @@ -2103,7 +2100,6 @@ class GeneralWorkspace { } void set_initial(std::span field, std::span noise); - [[nodiscard]] std::size_t split_rows() const noexcept { return split_rows_; } std::vector trial_step(double dz, const TrialScalars& scalars); void prepare_trial(double dz, const TrialScalars& scalars); std::vector adams_source(const std::vector& input, @@ -4203,7 +4199,7 @@ void GeneralWorkspace::allocate() { split_factor_ = RealView("solprop_native.absorber.split_factor", problem_.n_t); #endif if (problem_.free_geometry() && problem_.family == FieldFamily::envelope) { - split_rows_ = problem_.split_rows != 0 ? problem_.split_rows : temporal_columns; + split_rows_ = temporal_columns; } else { coarse_spectrum_ = ComplexView2("solprop_native.absorber.coarse_spectrum", temporal_columns, n); @@ -16537,9 +16533,6 @@ void GeneralState::adopt(const std::vector& field, const double dz) { const CpuTeamScope team(workspace_->cpu_team()); workspace_->adopt(field, dz); } -std::size_t GeneralState::split_rows() const noexcept { - return workspace_->split_rows(); -} double GeneralState::low_storage_step(const double dz, const TrialScalars& scalars, const bool rebuild_table) { const CpuTeamScope team(workspace_->cpu_team()); diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py index d1e3f491..bca2717c 100644 --- a/native/src/solprop_native/__init__.py +++ b/native/src/solprop_native/__init__.py @@ -307,7 +307,7 @@ def _band_request() -> int: message naming the member, rather than as an ``AttributeError`` raised from inside the adapter on the first problem it is handed.""" -TRANSFORM_FIELDS: tuple[str, ...] = ("modal_tables", "chunk_budgeted") +TRANSFORM_FIELDS: tuple[str, ...] = ("modal_tables",) """``Transform`` rows this backend relies on. The general-modal reference of schema 12, whose synthesis and projection maps this diff --git a/native/src/solprop_native/_core.pyi b/native/src/solprop_native/_core.pyi index 25768cbd..1a1330e5 100644 --- a/native/src/solprop_native/_core.pyi +++ b/native/src/solprop_native/_core.pyi @@ -98,8 +98,6 @@ class State: material: dict[str, npt.NDArray[np.float64] | int], ) -> npt.NDArray[np.complex128]: ... def adopt(self, field: npt.NDArray[np.complex128], dz: float) -> None: ... - @property - def split_rows(self) -> int: ... def low_storage_step( self, dz: float, diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 7a7f743a..703f8f80 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -2592,21 +2592,6 @@ def _on_device() -> bool: return _build_facts().info["device_backend"] != "none" -def _split_rows(problem: KernelProblem, *, device: bool) -> int: - """Return the temporal rows a free-space envelope's split collar takes at a time. - - The collar transforms the state's own rows in place, so a block holds no buffer of - its own: it sets the launches, three per block, and the plans' work area, which is - small (``docs/dev/benchmarks.md``, the split collar in place). A device therefore - takes every row at once. A caller's own `node_budget` wins (solprop #361). A CPU - keeps the node chunk: FFTW's threaded plan of a whole state was much the slower. - """ - transform = problem.transform - if device and not transform.chunk_budgeted: - return problem.shape[1] * problem.shape[2] - return min(transform.node_chunk, transform.n_nodes) * problem.shape[2] - - def _low_storage_payload(problem: KernelProblem, *, device: bool) -> dict[str, object]: """Return the native problem's low-storage rows (solprop kernel schema 32). @@ -3930,9 +3915,6 @@ def _native_problem( "n_pol_transform": problem.transform.n_pol, "n_nodes": problem.transform.n_nodes, "node_chunk": node_chunk, - "split_rows": 0 - if geometry is None or geometry.kind != "free" - else _split_rows(problem, device=_on_device()), "family": family, "nonlinear": kerr is not None, "modified_noise": problem.initial.noise_model == "modified", diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 8452a109..f099b722 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -681,8 +681,7 @@ class SimulationSettings(Displayed): (``docs/dev/architecture.md`` section 13). ``None``, the default, takes 1 GiB on a free-space, line or planar grid and 64 MiB otherwise (:data:`~solprop.translate.modal.DEFAULT_SPATIAL_NODE_BUDGET`, - :data:`~solprop.translate.modal.DEFAULT_NODE_BUDGET`). A stated budget also - holds a device's in-place split collar on a free-space envelope to the chunk. + :data:`~solprop.translate.modal.DEFAULT_NODE_BUDGET`). stats : solprop.stats.StatsSpec or None Which statistics to collect at every saved plane; ``None`` collects none. polarisation_taper : bool diff --git a/src/solprop/translate/modal.py b/src/solprop/translate/modal.py index 574e6a95..0af17451 100644 --- a/src/solprop/translate/modal.py +++ b/src/solprop/translate/modal.py @@ -124,10 +124,6 @@ class Transform: cross-section does not change. node_chunk : int Nodes the response pass handles at a time, at least 1 and at most ``n_nodes``. - chunk_budgeted : bool - Whether the caller's own ``node_budget`` set `node_chunk`. Without one a backend - may take a larger block where it holds no buffer of its own for it, as a device - does for a free-space envelope's split collar (solprop #361). aeff : solprop.translate.chebyshev.PiecewiseChebyshev Effective area in m^2 as a table along z. It is the normalisation itself when ``kind == 'mode_averaged'`` and a diagnostic otherwise. @@ -189,7 +185,6 @@ class Transform: node_weights: Float[np.ndarray, " n_weights"] | None = None modal_tables: ModalTransformTables | None = None kind: TransformKind = field(kw_only=True) - chunk_budgeted: bool = field(default=False, kw_only=True) def __post_init__(self) -> None: """Adopt read-only copies and check the shapes the annotations cannot. diff --git a/src/solprop/translate/problem.py b/src/solprop/translate/problem.py index 28ae3074..ca25bf22 100644 --- a/src/solprop/translate/problem.py +++ b/src/solprop/translate/problem.py @@ -6416,7 +6416,6 @@ def assemble( # noqa: PLR0913, PLR0915 - one keyword per physics object of a ru >>> problem.flags.single_mode, problem.linear.n_intervals (True, 1) """ - budgeted = node_budget is not None if node_budget is None: node_budget = ( DEFAULT_SPATIAL_NODE_BUDGET @@ -6615,12 +6614,6 @@ def assemble( # noqa: PLR0913, PLR0915 - one keyword per physics object of a ru transform = _balanced_chunk( _held_current_chunk(transform, compiled, n_pol, grid.n_to, node_budget) ) - # Only if it differs: an update shares its original's transform by identity. - transform = ( - transform - if transform.chunk_budgeted == budgeted - else replace(transform, chunk_budgeted=budgeted) - ) initial = _power_initial( Initial( field0=state, diff --git a/tests/backends/test_free_split_rows.py b/tests/backends/test_free_split_rows.py index adc6a553..c427662a 100644 --- a/tests/backends/test_free_split_rows.py +++ b/tests/backends/test_free_split_rows.py @@ -30,7 +30,6 @@ from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, PIController -from solprop.translate.modal import DEFAULT_SPATIAL_NODE_BUDGET from solprop.translate.problem import KernelProblem native_backend = pytest.importorskip("solprop_native.backend") @@ -87,9 +86,7 @@ def _beam(grid: TimeGrid, march: object | None = None) -> Simulation: ) -def _blocked( - simulation: Simulation, chunk: int | None, *, stated: bool = True -) -> KernelProblem: +def _blocked(simulation: Simulation, chunk: int | None) -> KernelProblem: """Return the simulation's problem at a node chunk, or at its own. Parameters @@ -98,9 +95,6 @@ def _blocked( The run. chunk : int or None The node chunk, or ``None`` for the problem's own. - stated : bool, optional - Whether a caller stated that chunk, which a device build then keeps. Otherwise a - device build takes every row at once, as it does under the default budget. Returns ------- @@ -112,9 +106,7 @@ def _blocked( return problem return dataclasses.replace( problem, - transform=dataclasses.replace( - problem.transform, node_chunk=chunk, chunk_budgeted=stated - ), + transform=dataclasses.replace(problem.transform, node_chunk=chunk), ) @@ -145,15 +137,11 @@ def reference(request: pytest.FixtureRequest) -> tuple[str, np.ndarray, dict[str def _check_block( reference: tuple[str, np.ndarray, dict[str, Any]], chunk: int | None, - *, - stated: bool = True, ) -> None: """Compare the native march at a node chunk with the reference's.""" march, expected, theirs = reference simulation = _beam(ENVELOPE, MARCHES[march]) - got, ours, ledger = _marched( - simulation, "native", _blocked(simulation, chunk, stated=stated) - ) + got, ours, ledger = _marched(simulation, "native", _blocked(simulation, chunk)) # The low-storage bound of four evaluations a step, scaled to the seven of the # longest tableau; the split's pair and product are less than one evaluation's, and # its factor's size is inside the bound's margin. A device build adds its @@ -192,93 +180,16 @@ def test_a_device_block_of_any_size_takes_the_reference_s_steps( _check_block(reference, chunk) -def test_a_device_takes_every_row_at_once( +def test_a_devices_choices_on_any_build( reference: tuple[str, np.ndarray, dict[str, Any]], monkeypatch: pytest.MonkeyPatch, ) -> None: - # A device's choices, run on any build: one block where the node chunk would have - # made 39, flows recomputed under "auto", and its input through tiles of team - # scratch, at the reference's numbers. + # Flows recomputed under "auto" and input through tiles of team scratch, with a + # short last block's plan, at the reference's numbers. monkeypatch.setattr(native_backend, "_on_device", lambda: True) - simulation = _beam(ENVELOPE, MARCHES[reference[0]]) - problem = _blocked(simulation, 5, stated=False) - backend: Any = watched(simulation, "native", problem) - try: - assert backend._native_state.split_rows == problem.shape[1] * problem.shape[2] - finally: - backend.close() - _check_block(reference, 5, stated=False) - - -def test_a_device_keeps_a_block_the_caller_stated( - reference: tuple[str, np.ndarray, dict[str, Any]], - monkeypatch: pytest.MonkeyPatch, -) -> None: - # A stated node_budget wins on a device too, with its short last block's plan. - monkeypatch.setattr(native_backend, "_on_device", lambda: True) - simulation = _beam(ENVELOPE, MARCHES[reference[0]]) - problem = _blocked(simulation, 5) - backend: Any = watched(simulation, "native", problem) - try: - assert backend._native_state.split_rows == 5 * problem.shape[2] - finally: - backend.close() _check_block(reference, 5) -def test_a_stated_budget_keeps_its_chunk_on_a_device() -> None: - # An explicit node_budget wins (#361): the block stays the node chunk it set, and - # the unset budget is told apart from one equal to the default. - default = _beam(ENVELOPE).translate() - columns = default.shape[1] * default.shape[2] - assert not default.transform.chunk_budgeted - assert native_backend._split_rows(default, device=True) == columns - assert native_backend._split_rows(default, device=False) == columns - - simulation = _beam(ENVELOPE) - column_bytes = 20 * 16 * default.shape[2] * ENVELOPE.n_to - simulation.settings = dataclasses.replace( - simulation.settings, node_budget=5 * column_bytes - ) - stated = simulation.translate() - assert stated.transform.chunk_budgeted - assert stated.transform.node_chunk == 5 - assert native_backend._split_rows(stated, device=True) == 5 * default.shape[2] - assert native_backend._split_rows(stated, device=False) == 5 * default.shape[2] - - simulation = _beam(ENVELOPE) - simulation.settings = dataclasses.replace( - simulation.settings, node_budget=DEFAULT_SPATIAL_NODE_BUDGET - ) - assert simulation.translate().transform.chunk_budgeted - - -def test_the_cpu_keeps_the_node_chunk_it_was_given( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(native_backend, "_on_device", lambda: False) - simulation = _beam(ENVELOPE) - problem = _blocked(simulation, 5) - assert native_backend._split_rows(problem, device=False) == 5 * problem.shape[2] - backend: Any = watched(simulation, "native", problem) - try: - assert backend._native_state.split_rows == 5 * problem.shape[2] - finally: - backend.close() - - -def test_a_block_past_the_field_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: - simulation = _beam(ENVELOPE) - problem = simulation.translate() - monkeypatch.setattr( - native_backend, - "_split_rows", - lambda problem, *, device: problem.shape[1] * problem.shape[2] + 1, - ) - with pytest.raises(ValueError, match="split_rows"): - watched(simulation, "native", problem) - - def test_real_rows_keep_their_buffers() -> None: # A real time row is not a complex spectrum's row: a field grid takes the chunked # pair through the coarse buffers still. From 911b09e896d21345847110bdd04292891a90b35b Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 02:49:59 +0000 Subject: [PATCH 112/122] Leave a batch member's field where it is at a plane no one reads At each plane a lockstep scan drained every member's whole observation, field included, even under fields="none". On an NVIDIA H200 that took 22 % of a plane interval at 8 steps a plane, and on an AMD Instinct MI325X 13 to 16 %, against #299's 5 %. `observe_members` now takes `field=`, as `observe` does, and the scan asks under the rule a lone point of the same scan keeps. `_retained`'s reasoning moves to `_field_retention` so that both executors use one rule. Closes #299. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/299-batch-field-free.changed.md | 1 + docs/guide/workflows/scans.md | 4 +- src/solprop/backends/batch.py | 14 +++-- src/solprop/backends/protocol.py | 11 +++- src/solprop/driver/_scan_batch.py | 19 +++++- src/solprop/driver/_scan_run.py | 47 ++++++++++---- tests/backends/test_batch_lockstep.py | 58 ++++++++++++++++- tests/backends/test_driver.py | 3 +- tests/driver/test_scan_batch.py | 69 +++++++++++++++++++++ 9 files changed, 203 insertions(+), 23 deletions(-) create mode 100644 changelog.d/299-batch-field-free.changed.md diff --git a/changelog.d/299-batch-field-free.changed.md b/changelog.d/299-batch-field-free.changed.md new file mode 100644 index 00000000..e1685f7f --- /dev/null +++ b/changelog.d/299-batch-field-free.changed.md @@ -0,0 +1 @@ +A lockstep `Batch` scan no longer moves a member's field to the host at a plane the scan doesn't read: `BatchBackend.observe_members` takes `field=`, as `observe` does, and a scan asks for what a lone point of the same scan keeps. On an NVIDIA H200 `Batch(8)` spent 22 % of a plane interval observing at 8 steps a plane, and on an AMD Instinct MI325X 13 to 16 %. diff --git a/docs/guide/workflows/scans.md b/docs/guide/workflows/scans.md index 74a124ec..12bf7c55 100644 --- a/docs/guide/workflows/scans.md +++ b/docs/guide/workflows/scans.md @@ -179,7 +179,9 @@ planar grids. Adjacent points with matching grids, schedules, response layouts a statistics can share a group; layout changes and the final partial batch make smaller groups. Mixing backend names in a batch scan raises an error. Global point indices are unchanged. General modal runs may be batched under a narrower rule -([running](../waveguides/supplied-guides.md#a-guide-instead-of-a-fill)). +([running](../waveguides/supplied-guides.md#a-guide-instead-of-a-fill)). A member's field +crosses to the host only at a plane the scan reads, as for a point run alone: under +`fields="none"`, with reductions of statistics, none does. Each member watches its own time window, as the point would run alone ([watching the window's edges](../numerics/boundaries.md#watching-the-windows-edges)): diff --git a/src/solprop/backends/batch.py b/src/solprop/backends/batch.py index f004e12b..aea88335 100644 --- a/src/solprop/backends/batch.py +++ b/src/solprop/backends/batch.py @@ -400,17 +400,23 @@ def stats(self, spec: StatsSpec) -> dict[str, np.ndarray]: for name in values[0] } - def observe_members(self) -> tuple[Observation, ...]: + def observe_members(self, *, field: bool = True) -> tuple[Observation, ...]: """Drain each member's host observation and its own diagnostic history. + Parameters + ---------- + field : bool, optional + Whether each member's field and noise phase cross to the host, as for a + lone run's ``observe``. Default ``True``. + Returns ------- tuple of solprop.backends.protocol.Observation - Singleton observations in batch order, with unchanged field shapes and - independently retained periodic rows. + Singleton observations in batch order, with unchanged field shapes or no + field, and independently retained periodic rows. """ self._require_state() - observations = tuple(member.observe() for member in self._members) + observations = tuple(member.observe(field=field) for member in self._members) self._history = [] return observations diff --git a/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py index afb904b8..2138ea8c 100644 --- a/src/solprop/backends/protocol.py +++ b/src/solprop/backends/protocol.py @@ -924,13 +924,20 @@ class BatchBackend(Backend, Protocol): The common maximum error norm is diagnostic and never changes a fixed schedule. """ - def observe_members(self) -> tuple[Observation, ...]: + def observe_members(self, *, field: bool = True) -> tuple[Observation, ...]: """Drain independently owned host observations in member order. + Parameters + ---------- + field : bool, optional + Whether each member's field and noise phase cross to the host, as for + :meth:`Backend.observe`. Default ``True``. + Returns ------- tuple of Observation - Singleton field shapes and each member's own diagnostic history. + Singleton field shapes, or no field, and each member's own diagnostic + history. """ ... diff --git a/src/solprop/driver/_scan_batch.py b/src/solprop/driver/_scan_batch.py index 44cda8cb..c086e240 100644 --- a/src/solprop/driver/_scan_batch.py +++ b/src/solprop/driver/_scan_batch.py @@ -18,7 +18,7 @@ from solprop.backends import select, select_batch from solprop.backends.protocol import Backend, BatchBackend -from solprop.driver._scan_run import _Capture, _Job, _Work +from solprop.driver._scan_run import _Capture, _field_retention, _Job, _Work from solprop.driver.batch import BatchMember, compatible_groups, shared_table_bytes from solprop.driver.executors.batch import Batch from solprop.driver.executors.pool import _memory_limit, peak_rss @@ -214,6 +214,8 @@ def _save( errors: list[str], spec: StatsSpec, problems: Sequence[KernelProblem], + *, + keep: bool, ) -> None: """Split observations into the existing singleton saved-plane schema. @@ -229,8 +231,10 @@ def _save( Shared names and windows evaluated independently for each member. problems : sequence of solprop.translate.problem.KernelProblem Independent member tables for the true-power output conversion. + keep : bool + Whether the scan reads this plane's field. Otherwise no member's field crosses. """ - observations = runner.observe_members() + observations = runner.observe_members(field=keep) statistics = runner.stats(spec) planes = tuple( plane_from_observation( @@ -352,15 +356,23 @@ def failed(index: int, error: str) -> PointResult: member.metadata, backend=BackendRecord.from_metadata(backend) ) ) + capture.retain( + _field_retention(capture).resolved(capture.count), + tuple(member.simulation.translate().shape[1:]), + ) captures.append(capture) + # One scan's options and one group's planes: one rule for all its members. _save( runner, captures, errors, spec, [member.simulation.translate() for member in group], + keep=captures[0].retention.keeps(0), ) - for target in group[0].simulation.settings.save_planes: + for plane, target in enumerate( + group[0].simulation.settings.save_planes, start=1 + ): summary = runner.propagate_to(float(target)) if not summary.met_tolerance: logger.warning( @@ -376,6 +388,7 @@ def failed(index: int, error: str) -> PointResult: errors, spec, [member.simulation.translate() for member in group], + keep=captures[0].retention.keeps(plane), ) runner.finish_monitors() # A warning raised as an error at the end fails its member alone. diff --git a/src/solprop/driver/_scan_run.py b/src/solprop/driver/_scan_run.py index cc83c176..da11e719 100644 --- a/src/solprop/driver/_scan_run.py +++ b/src/solprop/driver/_scan_run.py @@ -910,25 +910,22 @@ def _closing_monitor(capture: _Capture | None) -> Mapping[str, object] | None: return cast("Mapping[str, object]", record) if isinstance(record, Mapping) else None -def _retained(simulation: Simulation, capture: _Capture) -> Simulation: - """Return the point's simulation keeping only the fields the scan reads. +def _field_retention(capture: _Capture) -> FieldRetention: + """Return which planes of a point keep the field, for what the scan reads of them. The fields the scan retains, those its built-in reductions read at their target planes, and, while a custom reduction that reads fields spools the run, every - field (SEP 22, Q3). The builder's own simulation is left as it is: a builder may - return one object for every point. + field (SEP 22, Q3). Parameters ---------- - simulation : Simulation - The point's simulation, translated. capture : _Capture The point's capture, which knows the selected and target planes. Returns ------- - Simulation - A shallow copy sharing the translation, with the scan's field retention. + solprop.output.schema.FieldRetention + The rule, its plane indices resolved. """ options = capture.options spooled = any( @@ -936,17 +933,45 @@ def _retained(simulation: Simulation, capture: _Capture) -> Simulation: for reduction in options.reductions ) if options.fields == "all" or spooled: - return simulation + return FieldRetention("all") needed = set(capture.selected) | { capture.targets[reduction.name] for reduction in options.reductions if isinstance(reduction, _Builtin) and reduction.reads_fields } + return ( + FieldRetention("planes", tuple(sorted(needed))) + if needed + else FieldRetention("none") + ) + + +def _retained(simulation: Simulation, capture: _Capture) -> Simulation: + """Return the point's simulation keeping only the fields the scan reads. + + The builder's own simulation is left as it is: a builder may return one object for + every point. + + Parameters + ---------- + simulation : Simulation + The point's simulation, translated. + capture : _Capture + The point's capture, which knows the selected and target planes. + + Returns + ------- + Simulation + A shallow copy sharing the translation, with the scan's field retention. + """ + retention = _field_retention(capture) + if retention.fields == "all": + return simulation retained = copy.copy(simulation) retained.settings = dataclasses.replace( simulation.settings, - fields="planes" if needed else "none", - field_planes=tuple(sorted(needed)), + fields=retention.fields, + field_planes=retention.field_planes, ) return retained diff --git a/tests/backends/test_batch_lockstep.py b/tests/backends/test_batch_lockstep.py index a5904fa1..853e7d4e 100644 --- a/tests/backends/test_batch_lockstep.py +++ b/tests/backends/test_batch_lockstep.py @@ -9,9 +9,10 @@ """ import dataclasses +import math from collections.abc import Sequence from contextlib import ExitStack -from typing import cast +from typing import Any, cast import numpy as np import pytest @@ -272,6 +273,61 @@ def test_lockstep_refuses_shared_history_observation_and_closes_members(): owner.observe() +@pytest.mark.parametrize("backend", ["numpy_ref", "native"]) +def test_a_field_free_observation_moves_no_field_and_keeps_each_member_s_record( + backend, +): + # solprop #299: a scan that keeps no field of a plane asks for none. Each member's + # position, history and statistics are what the same march observes with its field. + if backend == "native": + pytest.importorskip("solprop_native") + spec = StatsSpec(names=("energy", "peak_power"), periodic=("energy",), period=2) + with ExitStack() as cleanup: + kept = LockstepRunner( + tuple(loaded(simulation(i, backend), cleanup) for i in range(2)) + ) + cleanup.callback(kept.close) + free = LockstepRunner( + tuple(loaded(simulation(i, backend), cleanup) for i in range(2)) + ) + cleanup.callback(free.close) + kept.propagate_to(0.0005) + free.propagate_to(0.0005) + for theirs, ours in zip( + kept.observe_members(), free.observe_members(field=False), strict=True + ): + assert theirs.field is not None + assert ours.field is None + assert ours.noise_phase is None + assert ours.z == theirs.z + np.testing.assert_array_equal(ours.step_history.z, theirs.step_history.z) + np.testing.assert_array_equal(ours.step_history.dz, theirs.step_history.dz) + np.testing.assert_array_equal( + ours.step_history.error_norm, theirs.step_history.error_norm + ) + np.testing.assert_array_equal(ours.step_stats.z, theirs.step_stats.z) + for name, row in theirs.step_stats.values.items(): + np.testing.assert_array_equal(ours.step_stats.values[name], row) + statistics = free.stats(spec) + assert statistics.keys() == kept.stats(spec).keys() + for name, value in kept.stats(spec).items(): + np.testing.assert_array_equal(statistics[name], value) + # A field after a field-free plane is the march's own, not a stale copy. + kept.propagate_to(0.001) + free.propagate_to(0.001) + last = free.observe_members() + for theirs, ours in zip(kept.observe_members(), last, strict=True): + np.testing.assert_array_equal(ours.field, theirs.field) + if backend == "native": + # One field to the host in that last observation, and none before it. + field = cast(np.ndarray, last[0].field) + for member in cast(Sequence[Any], free._members): + assert ( + member._native_state.crossed_bytes()[0] + == math.prod(field.shape) * 16 + ) + + def test_lockstep_refuses_empty_members(): with pytest.raises(BackendError, match="at least one member"): LockstepRunner([]) diff --git a/tests/backends/test_driver.py b/tests/backends/test_driver.py index 7e74c785..ea947850 100644 --- a/tests/backends/test_driver.py +++ b/tests/backends/test_driver.py @@ -1152,7 +1152,8 @@ def test_the_surface_a_backend_subclasses_is_frozen(driver): assert StepDriver.reads_transverse_edge is False for name, signature in { "observe_members": ( - "(self) -> tuple[solprop.backends.protocol.Observation, ...]" + "(self, *, field: bool = True) -> " + "tuple[solprop.backends.protocol.Observation, ...]" ), "member_metadata": "(self) -> tuple[dict[str, object], ...]", }.items(): diff --git a/tests/driver/test_scan_batch.py b/tests/driver/test_scan_batch.py index 578e0e3f..047d48a6 100644 --- a/tests/driver/test_scan_batch.py +++ b/tests/driver/test_scan_batch.py @@ -15,6 +15,7 @@ from legacy_geometry import LEGACY_GRID, bare_absorber from march_bounds import FIXED_ORDER, assert_points_matched +from solprop.backends.batch import LockstepRunner from solprop.backends.numpy_ref.backend import NumpyRefBackend from solprop.backends.numpy_ref.batch import NumpyBatchBackend from solprop.backends.registry import resolve_name @@ -171,6 +172,74 @@ def test_batch_device_budget_refuses_or_splits_before_group_allocation( assert executor.budgets[0]["host_admitted"] == 3 +def _total_energy(output): + return np.asarray(output["stats/energy"][-1].sum()) + + +# Three planes: the entrance and two more. A built-in reduction of a field reads its +# target plane, and a custom one reads every plane unless it says it reads none. +FIELDS_ASKED = { + "no field": ({"fields": "none"}, [False, False, False]), + "one plane": ({"fields": "planes", "field_planes": [-1]}, [False, False, True]), + "the entrance": ({"fields": "planes", "field_planes": [0]}, [True, False, False]), + "every plane": ({"fields": "all"}, [True, True, True]), + "a built-in's plane": ( + {"fields": "none", "reductions": [scans.peak_power(plane=1)]}, + [False, True, False], + ), + "a custom reduction": ( + { + "fields": "none", + "reductions": [scans.custom("energy", _total_energy, (), "J")], + }, + [True, True, True], + ), + "a statistics reduction": ( + { + "fields": "none", + "reductions": [ + scans.custom("energy", _total_energy, (), "J", reads_fields=False) + ], + }, + [False, False, False], + ), +} + + +@pytest.mark.filterwarnings("ignore::solprop.exceptions.HostMarchWarning") +@pytest.mark.parametrize("backend", ["numpy_ref", "native"]) +@pytest.mark.parametrize( + ("options", "asked"), FIELDS_ASKED.values(), ids=FIELDS_ASKED.keys() +) +def test_a_batch_asks_its_members_only_for_the_fields_the_scan_reads( + tmp_path, monkeypatch, backend, options, asked +): + # solprop #299: a plane no one reads moves no member's field, and what the scan + # stores is what the same points store run alone, under the same rule. + if backend == "native": + pytest.importorskip("solprop_native") + scan = Scan("field_free", base_seed=772, power=[1.0, 2.0, 3.0]) + builder = partial(build, backend=backend) + keywords = {"progress": "none", **options} + reference = run_scan( + scan, builder, output=tmp_path / "local.h5", executor=Local(), **keywords + ) + fields = [] + original = LockstepRunner.observe_members + + def spy(self, *, field=True): + fields.append(field) + return original(self, field=field) + + monkeypatch.setattr(LockstepRunner, "observe_members", spy) + result = run_scan( + scan, builder, output=tmp_path / "batch.h5", executor=Batch(3), **keywords + ) + assert result.status.tolist() == ["done"] * 3 + assert fields == asked + assert_points_matched(result, reference, scan, builder, what="batch member") + + def test_batch_custom_reducer_failure_isolated_and_retryable(tmp_path): scan = Scan("batch", base_seed=772, power=[1.0, 2.0, 3.0]) fail = [True] From 8cd170e213434fff7c01ca1f191fadfb4606bd05 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 02:53:44 +0000 Subject: [PATCH 113/122] Check a replaced input on threads, and fuzz columns on a shape's hard edge A runner's stated input took one thread's finiteness pass over the whole volume: 0.72 s of a 3.24 s point at 768 x 768 x 600 on an NVIDIA H200's host, 0.17 s on an AMD Instinct MI325X's. It now takes the threaded pass a loaded state does. The exact hard-edge fuzz that found #497's bug joins the tree (#504): columns placed on a circle's, a half-plane's and an ellipse's edge, and a float either side, against the reference's decisions, on CPU and in the device tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 65 +++++++++++- src/solprop/driver/simulation.py | 6 +- tests/backends/test_native_far_field.py | 131 +++++++++++++++++++++++- tests/driver/test_runner.py | 6 ++ 4 files changed, 199 insertions(+), 9 deletions(-) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index b4f36708..beb90e51 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -849,7 +849,7 @@ process's peak memory from 0.45 to 0.68 GB. The SEP's phase 3 report has the ful ### The native read of the transverse edge -[SEP 44](seps/sep-0044-native-edge-read.md) reads the edge on the state's own memory, with only numbers returning. Phase 3 holds the measurements and what's still unmeasured, namely the NVIDIA H200 and the AMD Instinct MI325X. +[SEP 44](seps/sep-0044-native-edge-read.md) reads the edge on the state's own memory, with only numbers returning. Its Part II holds the measurements phase by phase, and [the next section](#the-native-read-on-the-data-centre-cards) those on an NVIDIA H200 and an AMD Instinct MI325X. Against itself, SEP 40's four-way control, `python -m benchmarks.edge_overhead`, native at 18 threads of an Intel Xeon Gold 6240, 5 rounds, default check against no ramp and no reads: @@ -873,6 +873,61 @@ Against the code before it, `python -m benchmarks ab`, `e49925d1` to this stack, The geometric mean is 0.85. A requested collar's read, at 384 x 384 x 600 on those 18 cores, one state: the current at each commit 116 ms against the host's 7.36 s, and a read with its strip 0.94 s against 41.9 s, against a step of 2.97 s. On an NVIDIA A40 with 16 host threads, 36 ms against 1.54 s, and 204 ms against 7.98 s, against 0.70 s. +### The native read on the data-centre cards + +An NVIDIA H200 (driver 580.196.07, `nvcc` 13.1, an Intel Xeon Platinum 8592+ host, 24 cores) and an AMD Instinct MI325X (ROCm 10.0.0, `gfx942`, an AMD EPYC 9575F host, 20 cores), one rented machine each, on 2026-11-04 and 2026-11-05, at `9a5d2346`. That commit took a device's split collar a whole state at a time, which was [then dropped](#a-device-step-beyond-its-transforms): by that section's control the node chunk adds 0.4 % to a step on the H200 and 2.2 % on the MI325X, which changes no verdict here. "Start" is the same probe on [the default check's section](#the-default-check-on-a-device-reads-occupancy-and-sums)'s base, with no work from these changes. + +`python -m benchmarks.device_split`, field-free, a stated input identity, `held_plane=False`, the first of a runner's points set apart: + +| | H200, start | H200, now | MI325X, start | MI325X, now | Aim | +|---|---:|---:|---:|---:|---| +| 768 x 768 x 600, 8 steps, 4 planes: check on, s | 70 | 3.24 | 29 | 2.68 | under 8, under 6 | +| The same, check off, s | 5.9 | 2.66 | 4.2 | 2.00 | | +| On against off | x11.9 | x1.22 | x6.9 | x1.34 | under x2 | +| 384 x 384 x 600, 51 steps, 17 planes: on against off | x12.6 | x1.21 | not run | x1.20 | under x1.25 | +| State crossings of that field-free point | 13 to 35 | 0 | 13 | 0 | 0 | +| A step at 768, ms | 239 | 220 | 224 | 208 | | +| An edge read, ms | about 4,300 | 25 | about 2,000 | 31 | under half a step | +| The temporal monitor's sums, each step, ms | | 24 | | 32 | | +| `field_to_host`, 5.27 GiB, ms | 2,929 | 162 | 506 | 106 | under 300 | +| `energy`, ms | 55 | 2.3 | 80 | 6.7 | under 10 | +| `on_axis_intensity`, ms | | 11.8 | | 14.1 | | +| `beam_radius`, `fwhm_r`, ms | about 1,000 | 43 | about 1,000 | 44 | | +| `peak_intensity`, ms | 134 to 164 | 75 | 134 to 164 | 74 | | + +The crossings are native's own byte count in `tests/backends/test_native_edge_reading.py`, which ran on both cards, and the point's profile, which lists no crossing. No separate Kokkos trace was taken on these cards. At 51 steps the temporal monitor's sums take most of what the check still costs: on the H200 0.38 s of 0.58 s, and the edge's 18 reads 0.11 s. + +`python -m benchmarks ab --device` against `main` at `8ed26dd7`, 3 rounds, the same work: + +| | H200 | Fields | MI325X | Fields | +|---|---:|---:|---:|---:| +| `kerr_beam_384` | 12.491 s to 0.640 s, x0.051 | 6.2e-16 | 5.101 s to 0.606 s, x0.117 | 6.1e-16 | +| `kerr_beam_192` | 1.535 s to 0.219 s, x0.141 | 6.2e-16 | 0.807 s to 0.111 s, x0.138 | 6.1e-16 | + +A requested collar at 384 x 384 x 600, one state, field-free, native against the host's reduction of a host copy, with a step of 72 ms on both: + +| | Current, each commit | Read with its strip and regions | +|---|---:|---:| +| H200 | 4.5 ms against 2,719 ms | 18.7 ms against 12,936 ms | +| MI325X | 3.9 ms against 1,218 ms | 14.1 ms against 6,205 ms | + +**On an AMD EPYC 7763,** one NUMA domain of an exclusive two-socket node, `--threading passive`, not interleaved, against the history's 0.1.0 rows, since 0.1.0 can't run this harness: `kerr_beam_192` at one thread 12.791 s against 12.071 s, x1.060, and `kerr_beam_384` at 16 threads 23.870 s against 25.261 s, x0.945. The aim was under x1.08, from x1.07 to x1.18. + +**What a run holds.** Three runs at 768 x 768 x 600: the ledger totals 38.96 GiB on the H200 and 39.65 GiB on the MI325X, for a state of 5.27 GiB. FFT plans are the largest row, 21.10 GiB and 22.03 GiB. The state, the register and the scratch are 5.27 GiB each, and the ring 1.25 GiB to 1.5 GiB. + +**What a march costs in Python** ([#110](https://github.com/LupoLab/solprop/issues/110)), the wall time outside native calls, on the same harness, 600 fixed calls at most: + +| Route, grid, march | H200 | MI325X | +|---|---:|---:| +| Cartesian 32, 64, 128, 256, low-storage | 26, 21, 6.8, 1.2 % | 13, 8.4, 4.0, 0.7 % | +| Cartesian 32, 64, 128, Runge-Kutta | 21, 13, 4.8 % | 13, 7.9, 2.1 % | +| Radial 64, 256, Runge-Kutta | 17, 11 % | 10, 2.4 % | +| Cartesian 64, radial 64, native Adams | 0.6, 1.1 % | 0.6, 0.6 % | + +**A lockstep batch's observation** ([#299](https://github.com/LupoLab/solprop/issues/299)), `Batch(8)` of a capillary on native, 8 steps a plane, before a batch learned to leave a field where it was: an observation took 5.3 to 6.2 ms of a plane interval of 17 to 19 ms on the H200, 22 %, and 2.6 to 2.8 ms of 13 to 18 ms on the MI325X, 13 to 16 %, for `n_omega` from 768 to 32,768. On the CPU it was 1 to 4 %. The statistics at each plane took 0.9 to 2.1 ms and 0.6 to 1.6 ms. + +**A scan worker's runner** ([#298](https://github.com/LupoLab/solprop/issues/298)), on the MI325X at 768 x 768 x 600: `from_free` 3.27 s, `translate` 1.62 s, the runner 0.29 s to 0.48 s, a first point's extra run 0.17 s, and 2.68 s for 8 steps. A worker that kept its runner would save at most 0.65 s of about 8.1 s, 8 %, less on a longer point, and under the issue's 10 %. A kept runner saves neither the build nor the translation, since a point's problem must be built to compare it. + ### The transverse edge against 0.1.0 [#453](https://github.com/LupoLab/solprop/issues/453) found the tracking set's `kerr_beam` @@ -2347,10 +2402,10 @@ The whole step takes 0.961 of its time at 768 x 768 x 600 and 0.956 at 384 x 384 copies gone at the node chunk: 2,578.6 ms a step, against 2,574.6 for a whole state on the same build. The "After" column and the `python -m benchmarks ab` figures above were taken with a whole state, which a device took by default until the cards were measured. -**A whole state was dropped.** It saves launches, and #361 asks 10 % of a default-memory change. At 768 x 768 x 600, 8 steps, three fresh processes each at `9a5d2346`, median -whole against node chunk: NVIDIA H200 2.661 s against 2.671 s, x0.996; AMD Instinct MI325X 2.023 s against 2.068 s, x0.978. So a device keeps the node chunk, and -`Transform` needs no field to tell a stated budget from the default. The plans' device growth was the same to 6 MiB on the MI325X, 23.65 GiB of 42.6 GiB either way, and cuFFT's -work area is small (at 589,824 rows of 600 points each plan holds 9 MiB, in place or not, 0.1 MiB at the node chunk), so it would not have cost memory either. A CPU keeps the +**A whole state was dropped.** It saves launches, and #361 asks 10 % of a default-memory change. At 768 x 768 x 600, 8 steps, at `9a5d2346`, the median of three runs on one runner in one process, whole +state first and not interleaved, against the node chunk: NVIDIA H200 2.661 s against 2.671 s, x0.996; AMD Instinct MI325X 2.023 s against 2.068 s, x0.978. So a device keeps the +node chunk, and `Transform` needs no field to tell a stated budget from the default. It wouldn't have cost memory either: the plans' device growth was the same to 6 MiB on both +cards, and cuFFT's work area is small (at 589,824 rows of 600 points each plan holds 9 MiB, in place or not, 0.1 MiB at the node chunk). A CPU keeps the chunk for its own reason: on an Intel Xeon Gold 6240, nine cores, at 384 x 384 x 600, FFTW's threaded plan of a whole state took 2.5 s a step in transforms against 1.5 s. **The fold.** A flow's magnitude on a lossless bin is `exp(+-0)`, exactly one, and took a binary64 `exp` per element: 13.4 ms a pass on the A40, whose reduced binary64 rate diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index f099b722..aa907eaf 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -48,7 +48,7 @@ from solprop._display import Displayed, engineering, line, summarise from solprop.adiabatic import AdiabaticAccuracy from solprop.backends.multistep import CallLedger -from solprop.backends.protocol import Backend +from solprop.backends.protocol import Backend, _all_finite from solprop.backends.registry import metal_available, resolve_name, select from solprop.boundaries import ( Absorber, @@ -2903,13 +2903,13 @@ def _check_replaced_input( """ if field0.shape != self._field0.shape: raise DriverError("with_input requires the existing mode and grid shape") - if not np.all(np.isfinite(field0)): + if not _all_finite(field0): raise DriverError("with_input requires a finite initial field") if noise is None: return if noise.spectrum.shape != self._field0.shape: raise DriverError("replacement noise must match the input field shape") - if not np.all(np.isfinite(noise.spectrum)): + if not _all_finite(noise.spectrum): raise DriverError("with_input requires a finite noise realisation") def runner(self) -> "SimulationRunner": # noqa: UP037 - defined below diff --git a/tests/backends/test_native_far_field.py b/tests/backends/test_native_far_field.py index 99f08128..fc57795c 100644 --- a/tests/backends/test_native_far_field.py +++ b/tests/backends/test_native_far_field.py @@ -48,7 +48,13 @@ from solprop.grid import FreeGrid, LineGrid from solprop.output import MemoryOutput from solprop.pulses import GaussianPulse, SpatialPulse -from solprop.spatial import FAR_FIELD_STATISTICS, SpatialMedium, mask_plane_radius +from solprop.spatial import ( + FAR_FIELD_STATISTICS, + SpatialMedium, + aperture_outline, + mask_plane_offsets, + mask_plane_radius, +) from solprop.stats import ApertureSet, StatsSpec from solprop.stepper import PIController, RungeKutta from solprop.translate.spatial import aperture_tables @@ -422,6 +428,129 @@ def test_a_column_at_a_hard_edge_is_decided_natively_as_the_reference_decides(): assert (expected["aperture_spectrum"][0, bin_] > 0) == inside +def edge_rows(shape, rng, across, along): + """Rows of one shape that put one column on its hard edge and a float either side. + + The circle's diameter and the half-plane's position are placed exactly. An + ellipse's edge has no closed form in floats, so its height is bisected to the two + adjacent values between which the reference's decision flips. + """ + if shape == "circle": + radius = float(np.sqrt(across * across + along * along)) + twice = 2 * radius + return [ + {"diameter": value} + for value in (twice, np.nextafter(twice, 0), np.nextafter(twice, np.inf)) + ] + if shape == "half_plane_x_ge": + # across = s k + X, so the edge is at X = -(s k), where the sum is exactly zero. + edge = -across + return [ + {"position": value} + for value in (edge, np.nextafter(edge, -np.inf), np.nextafter(edge, np.inf)) + ] + width = float(rng.uniform(3.0, 6.0)) * max(abs(across), 1e-5) + + def inside(height): + outline = aperture_outline("ellipse", 0.0, (width, height), 0.0, "hard") + return outline.weight(np.array([across]), np.array([along]))[0] > 0 + + low, high = 1e-9, 1.0 + assert not inside(low) + assert inside(high) + while True: + middle = 0.5 * (low + high) + if middle in (low, high): + break + if inside(middle): + high = middle + else: + low = middle + return [{"width": width, "height": value} for value in (low, high)] + + +def decide_edges(seed, shape): + """Compare native's decisions at columns on a shape's edge with the reference's. + + One lit sample per frequency bin, so a row's spectrum at that bin is that column's + alone, in the row or out of it. + """ + rng = np.random.default_rng(seed) + grid = time_grid("envelope") + space = FreeGrid(3e-4, 12, 3e-4, 10, collar_fraction=0) + tables = aperture_tables( + spec_of(holes("hard"), ("aperture_spectrum",)), grid, space + ) + assert tables is not None + field = np.zeros((space.n_columns, 1, grid.n_omega), complex) + middle = grid.n_omega // 2 + bins = middle + rng.choice(np.arange(-20, 21), size=5, replace=False) + rows = [] + for bin_ in bins: + column = int(rng.integers(space.n_columns)) + field[column, 0, bin_] = 1e3 + centre = rng.uniform(-3e-4, 3e-4, 2) + if shape == "half_plane_x_ge": + centre[1] = 0.0 + across, along = mask_plane_offsets( + tables.scale[0, bin_ : bin_ + 1], + tables.k_x[column % space.n_x : column % space.n_x + 1], + tables.k_y[column // space.n_x : column // space.n_x + 1], + (0.0, centre[1]) if shape == "half_plane_x_ge" else centre, + ) + offsets = float(across[0, 0]), float(along[0, 0]) + rows += [(int(bin_), centre, row) for row in edge_rows(shape, rng, *offsets)] + apertures = ApertureSet( + centres=[ + [row.get("position", centre[0]), centre[1]] for _, centre, row in rows + ], + diameters=[row.get("diameter", 0.0) for _, _, row in rows], + distances=[DISTANCE] * len(rows), + shapes=(shape,) * len(rows), + extents=None + if shape != "ellipse" + else [[row["width"], row["height"]] for _, _, row in rows], + ) + spec = StatsSpec(names=("aperture_spectrum",), apertures=apertures) + problem = problem_of(grid, space, field, spec) + values, field = native_stats(problem, grid, field, spec.names) + tables = aperture_tables(spec, problem.grids, problem.transform.geometry) + expected = reference(spec.names, grid, problem, field, tables) + decided = [ + ( + bool(values["aperture_spectrum"][index, bin_] > 0), + bool(expected["aperture_spectrum"][index, bin_] > 0), + ) + for index, (bin_, _, _) in enumerate(rows) + ] + # Each target's rows straddle its edge, so neither answer can be vacuous. + assert {theirs for _, theirs in decided} == {True, False} + assert [ours for ours, _ in decided] == [theirs for _, theirs in decided] + + +EDGE_SHAPES = ("circle", "half_plane_x_ge", "ellipse") + + +@pytest.mark.parametrize("seed", range(4)) +@pytest.mark.parametrize("shape", EDGE_SHAPES) +def test_columns_on_a_shape_s_edge_decide_natively_as_the_reference_decides( + shape, seed +): + decide_edges(seed, shape) + + +@pytest.mark.gpu +@pytest.mark.parametrize("seed", range(40)) +@pytest.mark.parametrize("shape", EDGE_SHAPES) +def test_columns_on_a_shape_s_edge_decide_on_a_device_as_the_reference_decides( + shape, seed +): + # solprop #504: a fused product and sum on HIP once moved 16 of 588 such decisions, + # and this fuzz is what would catch that coming back. + require_device() + decide_edges(seed, shape) + + # ------------------------------------------------ item 11: thread counts, bands diff --git a/tests/driver/test_runner.py b/tests/driver/test_runner.py index 2f94f1a4..ac69bada 100644 --- a/tests/driver/test_runner.py +++ b/tests/driver/test_runner.py @@ -347,6 +347,12 @@ def test_with_input_validates_the_replaced_input_alone(): field = inputs(simulation)[0] with pytest.raises(DriverError, match="finite initial field"): simulation.with_input(np.full_like(field, np.nan)) + # One sample settles it, and finite samples too large to add do not. + one = field.copy() + one.flat[one.size // 2] = complex(np.inf, 0.0) + with pytest.raises(DriverError, match="finite initial field"): + simulation.with_input(one) + simulation.with_input(np.full_like(field, 1e308)) with pytest.raises(DriverError, match="existing mode and grid shape"): simulation.with_input(field[:1]) with pytest.raises(DriverError, match="recorded by the run itself"): From 84f381c36893a7fa47ef85d1407b1aa411090dcf Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 02:58:58 +0000 Subject: [PATCH 114/122] Format the command's stop, and name the protocol's reference in full Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/backends/protocol.py | 2 +- src/solprop/cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py index 2138ea8c..36fe2ab5 100644 --- a/src/solprop/backends/protocol.py +++ b/src/solprop/backends/protocol.py @@ -931,7 +931,7 @@ def observe_members(self, *, field: bool = True) -> tuple[Observation, ...]: ---------- field : bool, optional Whether each member's field and noise phase cross to the host, as for - :meth:`Backend.observe`. Default ``True``. + :meth:`solprop.backends.protocol.Backend.observe`. Default ``True``. Returns ------- diff --git a/src/solprop/cli.py b/src/solprop/cli.py index 598fa793..6d9eeb3b 100644 --- a/src/solprop/cli.py +++ b/src/solprop/cli.py @@ -169,7 +169,7 @@ def _stopped(received: list[int], output: Path) -> int: try: with open_file(output) as stored: outcome = stored.outcome - except (OSError, OutputError): + except OSError, OutputError: outcome = None complete = outcome is not None and outcome.status == "complete" name = signal.Signals(received[0]).name From 05e324467e4b35380d01dbf1f4ded3d974187be0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 03:56:15 +0000 Subject: [PATCH 115/122] Cover the exact residual's corner, and the launch mask's way past its check Both came with the setup work, and this is the first coverage report since it merged. The rational residual on axes beyond `math.fsum`'s squares had no test. And a nested `def` straight after an `if` hides that `if`'s false arc from branch coverage under the type-checking import hook, so the mask's worker moves up beside the other one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/pulses.py | 20 ++++++++++---------- tests/test_cartesian_precision.py | 9 ++++++++- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/solprop/pulses.py b/src/solprop/pulses.py index bae51d87..2401a1ff 100644 --- a/src/solprop/pulses.py +++ b/src/solprop/pulses.py @@ -1164,6 +1164,16 @@ def shares(rows: slice) -> tuple[float, float]: norm = weights[rows, None] * np.sum(np.abs(field[rows]) ** 2, axis=1) return float(np.sum(norm[outside[rows]])), float(np.sum(norm[:, band])) + def mask(rows: slice) -> None: + """Zero one block's dead bins. + + Parameters + ---------- + rows : slice + The block of the first axis. + """ + field[rows] *= live[rows, None, :] + # Judged against a budget of rounding, so that the blocks' order of summing is free. parts = parallel_rows(field.shape[0], field.nbytes, shares) removed = math.fsum(part[0] for part in parts) @@ -1179,16 +1189,6 @@ def shares(rows: slice) -> tuple[float, float]: ) raise PulseError(msg) - def mask(rows: slice) -> None: - """Zero one block's dead bins. - - Parameters - ---------- - rows : slice - The block of the first axis. - """ - field[rows] *= live[rows, None, :] - parallel_rows(field.shape[0], field.nbytes, mask) return field diff --git a/tests/test_cartesian_precision.py b/tests/test_cartesian_precision.py index 98887f0c..334d566d 100644 --- a/tests/test_cartesian_precision.py +++ b/tests/test_cartesian_precision.py @@ -146,7 +146,14 @@ def test_cartesian_launch_uses_the_corrected_exact_linear_operator(): @pytest.mark.parametrize( ("half_x", "n_x", "half_y", "n_y"), - [(3e-7, 9, 4e-7, 12), (2.3e-4, 64, 7.1e-5, 48), (1.0, 2, 1e-3, 33)], + [ + (3e-7, 9, 4e-7, 12), + (2.3e-4, 64, 7.1e-5, 48), + (1.0, 2, 1e-3, 33), + # Axes beyond what `math.fsum` of squares can hold keep the rational sum itself. + (1e-150, 4, 1e-150, 4), + (1e140, 4, 1e140, 4), + ], ) def test_the_transverse_residual_is_the_rational_ones_bit_for_bit( half_x, n_x, half_y, n_y From 0e0d092d75297c8642cd82632518d834efd1dc95 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 03:56:15 +0000 Subject: [PATCH 116/122] SEP 44: report phase 4, and enter both cards in the ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 14 +++++------ docs/dev/seps/sep-0044-native-edge-read.md | 29 ++++++++++++++++++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index beb90e51..86a2fe0d 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -885,15 +885,15 @@ An NVIDIA H200 (driver 580.196.07, `nvcc` 13.1, an Intel Xeon Platinum 8592+ hos | The same, check off, s | 5.9 | 2.66 | 4.2 | 2.00 | | | On against off | x11.9 | x1.22 | x6.9 | x1.34 | under x2 | | 384 x 384 x 600, 51 steps, 17 planes: on against off | x12.6 | x1.21 | not run | x1.20 | under x1.25 | -| State crossings of that field-free point | 13 to 35 | 0 | 13 | 0 | 0 | +| State crossings of the 8-step point | 13 | 0 | 13 | 0 | 0 | | A step at 768, ms | 239 | 220 | 224 | 208 | | -| An edge read, ms | about 4,300 | 25 | about 2,000 | 31 | under half a step | +| An edge read, ms | about 4,300 | 25 | about 1,000 | 31 | under half a step | | The temporal monitor's sums, each step, ms | | 24 | | 32 | | | `field_to_host`, 5.27 GiB, ms | 2,929 | 162 | 506 | 106 | under 300 | | `energy`, ms | 55 | 2.3 | 80 | 6.7 | under 10 | | `on_axis_intensity`, ms | | 11.8 | | 14.1 | | | `beam_radius`, `fwhm_r`, ms | about 1,000 | 43 | about 1,000 | 44 | | -| `peak_intensity`, ms | 134 to 164 | 75 | 134 to 164 | 74 | | +| `peak_intensity`, ms | 134 | 75 | 164 | 74 | | The crossings are native's own byte count in `tests/backends/test_native_edge_reading.py`, which ran on both cards, and the point's profile, which lists no crossing. No separate Kokkos trace was taken on these cards. At 51 steps the temporal monitor's sums take most of what the check still costs: on the H200 0.38 s of 0.58 s, and the edge's 18 reads 0.11 s. @@ -902,7 +902,7 @@ The crossings are native's own byte count in `tests/backends/test_native_edge_re | | H200 | Fields | MI325X | Fields | |---|---:|---:|---:|---:| | `kerr_beam_384` | 12.491 s to 0.640 s, x0.051 | 6.2e-16 | 5.101 s to 0.606 s, x0.117 | 6.1e-16 | -| `kerr_beam_192` | 1.535 s to 0.219 s, x0.141 | 6.2e-16 | 0.807 s to 0.111 s, x0.138 | 6.1e-16 | +| `kerr_beam_192` | 1.535 s to 0.219 s, x0.141 | 5.2e-16 | 0.807 s to 0.111 s, x0.138 | 5.8e-16 | A requested collar at 384 x 384 x 600, one state, field-free, native against the host's reduction of a host copy, with a step of 72 ms on both: @@ -911,9 +911,9 @@ A requested collar at 384 x 384 x 600, one state, field-free, native against the | H200 | 4.5 ms against 2,719 ms | 18.7 ms against 12,936 ms | | MI325X | 3.9 ms against 1,218 ms | 14.1 ms against 6,205 ms | -**On an AMD EPYC 7763,** one NUMA domain of an exclusive two-socket node, `--threading passive`, not interleaved, against the history's 0.1.0 rows, since 0.1.0 can't run this harness: `kerr_beam_192` at one thread 12.791 s against 12.071 s, x1.060, and `kerr_beam_384` at 16 threads 23.870 s against 25.261 s, x0.945. The aim was under x1.08, from x1.07 to x1.18. +**On an AMD EPYC 7763,** 16 cores of an exclusive two-socket node, `SOLPROP_FFTW_PLAN=measure`, `--threading passive`, not interleaved, against the history's 0.1.0 rows, since 0.1.0 can't run this harness: `kerr_beam_192` at one thread 12.791 s against 12.071 s, x1.060, and `kerr_beam_384` at 16 threads 23.870 s against 25.261 s, x0.945. The aim was under x1.08, from x1.07 to x1.18. -**What a run holds.** Three runs at 768 x 768 x 600: the ledger totals 38.96 GiB on the H200 and 39.65 GiB on the MI325X, for a state of 5.27 GiB. FFT plans are the largest row, 21.10 GiB and 22.03 GiB. The state, the register and the scratch are 5.27 GiB each, and the ring 1.25 GiB to 1.5 GiB. +**What a run holds.** After three runs at 768 x 768 x 600 the ledger totals 38.96 GiB on the H200 and 39.64 GiB on the MI325X, for a state of 5.27 GiB. FFT plans are the largest row, 21.09 GiB and 22.02 GiB. The state, the register and the scratch are 5.27 GiB each, and the ring 1.5 GiB and 1.25 GiB. Each plan's own work area is why ([#529](https://github.com/LupoLab/solprop/issues/529)). **What a march costs in Python** ([#110](https://github.com/LupoLab/solprop/issues/110)), the wall time outside native calls, on the same harness, 600 fixed calls at most: @@ -924,7 +924,7 @@ A requested collar at 384 x 384 x 600, one state, field-free, native against the | Radial 64, 256, Runge-Kutta | 17, 11 % | 10, 2.4 % | | Cartesian 64, radial 64, native Adams | 0.6, 1.1 % | 0.6, 0.6 % | -**A lockstep batch's observation** ([#299](https://github.com/LupoLab/solprop/issues/299)), `Batch(8)` of a capillary on native, 8 steps a plane, before a batch learned to leave a field where it was: an observation took 5.3 to 6.2 ms of a plane interval of 17 to 19 ms on the H200, 22 %, and 2.6 to 2.8 ms of 13 to 18 ms on the MI325X, 13 to 16 %, for `n_omega` from 768 to 32,768. On the CPU it was 1 to 4 %. The statistics at each plane took 0.9 to 2.1 ms and 0.6 to 1.6 ms. +**A lockstep batch's observation** ([#299](https://github.com/LupoLab/solprop/issues/299)), `Batch(8)` of a fibre on native, 8 steps a plane, before a batch learned to leave a field where it was: an observation took 5.3 to 6.2 ms of a plane interval of 17 to 19 ms on the H200, 22 %, and 2.6 to 2.8 ms of 13 to 18 ms on the MI325X, 13 to 16 %, for `n_omega` from 768 to 32,768. On the CPU it was 1 to 4 %. The statistics at each plane took 0.9 to 2.1 ms and 0.6 to 1.6 ms. **A scan worker's runner** ([#298](https://github.com/LupoLab/solprop/issues/298)), on the MI325X at 768 x 768 x 600: `from_free` 3.27 s, `translate` 1.62 s, the runner 0.29 s to 0.48 s, a first point's extra run 0.17 s, and 2.68 s for 8 steps. A worker that kept its runner would save at most 0.65 s of about 8.1 s, 8 %, less on a longer point, and under the issue's 10 %. A kept runner saves neither the build nor the translation, since a point's problem must be built to compare it. diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index 10c1a7dd..f1a36088 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -15,7 +15,7 @@ | **Requires** | [SEP 40](archive/sep-0040-transverse-absorbing-boundaries.md) | | **Supersedes** | — | | **Superseded-by** | — | -| **Implementation** | Claude Opus 5.5, on `feature/sep-0044-native-edge`: phases 1, 2 and 3 done; device qualification next | +| **Implementation** | Claude Opus 5.5, on `feature/sep-0044-native-edge`: phases 1 to 4 done; phase 5's documents are in, and `Final` is the maintainer's | | **Manual pages** | — | ## Part I — Proposal @@ -499,7 +499,31 @@ SEP 40's four-way control, `python -m benchmarks.edge_overhead`, native at 18 th **Not yet done.** Phase 4's device qualification; the rest of §8's documents and the final ledger entry, phase 5. -**Open.** Whether to approve a rental for phase 4. +### Phase 4 — 2026-11-05 + +The read qualified on an NVIDIA H200 and an AMD Instinct MI325X, the first AMD card to run it, within a rental the maintainer approved. Each ran a CUDA or HIP build on the card, at `9a5d2346`. The tables are in the ledger, [the native read on the data-centre cards](../benchmarks.md#the-native-read-on-the-data-centre-cards). + +**§7's gates,** `python -m benchmarks.device_split 768`, 8 fixed steps and 4 planes, one read a step: + +| Gate | NVIDIA H200 | AMD Instinct MI325X | +|---|---:|---:| +| No state transfer | none | none | +| A read under half a step | 25 ms of 220 ms | 31 ms of 208 ms | +| The point under twice its check-off time | x1.22, 3.24 s against 2.66 s | x1.34, 2.68 s against 2.00 s | +| Context: 51 steps, 17 planes at 384 | x1.21 | x1.20 | + +Before this work those points took 70 s and 29 s, so x11.9 and x6.9. A requested collar at 384 x 384 x 600 reads in 18.7 ms and 14.1 ms, against 12.9 s and 6.2 s for the host's reduction of a host copy, and its current takes 4.5 ms and 3.9 ms at each commit, against a step of 72 ms on both. + +**Tests.** On each card, `pytest -m gpu` over `tests` 130 passed, 1 skipped, and over `native/tests` 31 passed; the edge's, collar's, widths' and statistics' modules with `test_transverse_backends.py` 269 passed, 3 skipped; `test_transverse_reads.py`, `test_transverse_monitor.py` and `test_stop.py` 66 passed; `tests:cand`'s targeted set 210 passed, 7 skipped; native's radial and backend modules 92 passed. + +**Deviations from Part I.** + +- §7 asks that a trace of the workload confirm no state crosses on a device. I relied on native's own byte count, which `test_native_edge_reading.py` asserts on both cards, and on the point's profile, which lists no crossing. No separate Kokkos trace was taken on these cards. +- §7 asks for an A/B against 0.1.0 on an AMD EPYC 7763. 0.1.0 can't run the harness as it stands, so the first figures are against the history's 0.1.0 rows, not interleaved. They're in the ledger. + +**Found on the way, not part of this SEP.** A state's plans are the largest row of a run: 21.09 GiB of 38.96 GiB on the H200 at 768 x 768 x 600, and 22.02 GiB of 39.64 GiB on the MI325X. That's unchanged by this work, and it has its own issue, [#529](https://github.com/LupoLab/solprop/issues/529). + +**Phase 5.** §8's documents went in with the phases that changed what they say, and this report and the ledger entry finish them. Nothing is left for `Final` but the maintainer's decision. ## Part III — History @@ -508,6 +532,7 @@ SEP 40's four-way control, `python -m benchmarks.edge_overhead`, native at 18 th - 2026-11-04: Revised in response, with the maintainer's decisions on R2, R4 and R7. Phase 4 goes, and SEP 40 §7.7's 2 % limit is retired. - 2026-11-04: John Travers (human) reviewed the revised proposal, and the maintainer accepted it. - 2026-11-04: Implementation started on `feature/sep-0044-native-edge`; phases 1 and 2 reported in Part II. +- 2026-11-05: Phase 4 reported in Part II. ### AI review — 2026-11-04 From 0e1c37627d112d4acec061d21177770cf4182ddd Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 04:13:48 +0000 Subject: [PATCH 117/122] Fuzz only bins the load keeps Seed 36 drew five inactive bins, which the load masks, so every row read zero and the test's guard against a vacuous answer failed on the NVIDIA H200's first pass, and on CPU when run that far. Each target now has to give both answers on its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- tests/backends/test_native_far_field.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/backends/test_native_far_field.py b/tests/backends/test_native_far_field.py index fc57795c..86514cd8 100644 --- a/tests/backends/test_native_far_field.py +++ b/tests/backends/test_native_far_field.py @@ -483,8 +483,9 @@ def decide_edges(seed, shape): ) assert tables is not None field = np.zeros((space.n_columns, 1, grid.n_omega), complex) - middle = grid.n_omega // 2 - bins = middle + rng.choice(np.arange(-20, 21), size=5, replace=False) + # An inactive bin is masked at the load, so it would decide nothing. + active = np.flatnonzero(grid.active_mask & (grid.omega > 0)) + bins = rng.choice(active, size=5, replace=False) rows = [] for bin_ in bins: column = int(rng.integers(space.n_columns)) @@ -523,8 +524,10 @@ def decide_edges(seed, shape): ) for index, (bin_, _, _) in enumerate(rows) ] - # Each target's rows straddle its edge, so neither answer can be vacuous. - assert {theirs for _, theirs in decided} == {True, False} + # Each of the five targets' rows straddle its edge, so each gives both answers. + for start in range(0, len(decided), len(decided) // 5): + target = decided[start : start + len(decided) // 5] + assert {theirs for _, theirs in target} == {True, False} assert [ours for ours, _ in decided] == [theirs for _, theirs in decided] From 180b558396b40aba5dccdde1580244816f9f9b10 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 04:39:31 +0000 Subject: [PATCH 118/122] Keep a device's backend across a local scan's input-only points A new backend took 2.1 s of a 4.7 s point at 768 x 768 x 600 on an NVIDIA H200, and 1.0 s of 4.2 s at 384 x 384 x 600 over 51 steps, well past #298's 10 %. On a CUDA or HIP build a `Local` scan's worker now keeps it while each point passes the check a runner makes, which a builder using `with_input` gives. Any other point closes it first, so two are never held. `Simulation.run`'s body moves to `_run`, which can be handed a backend, so that a kept point takes the same path as one alone: its record differs in when it was made and how long planning took. Pool and Batch don't keep one, and nor does a CPU, where SEP 22 measured no gain and the memory goes back to the next point's build. Closes #298. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- changelog.d/298-scan-keeps-backend.changed.md | 1 + docs/guide/workflows/scans.md | 4 +- src/solprop/backends/registry.py | 21 ++ src/solprop/driver/_scan_run.py | 63 ++++- src/solprop/driver/simulation.py | 135 +++++++++- tests/driver/test_scan_kept_backend.py | 248 ++++++++++++++++++ 6 files changed, 457 insertions(+), 15 deletions(-) create mode 100644 changelog.d/298-scan-keeps-backend.changed.md create mode 100644 tests/driver/test_scan_kept_backend.py diff --git a/changelog.d/298-scan-keeps-backend.changed.md b/changelog.d/298-scan-keeps-backend.changed.md new file mode 100644 index 00000000..567e014e --- /dev/null +++ b/changelog.d/298-scan-keeps-backend.changed.md @@ -0,0 +1 @@ +A `Local` scan on a CUDA or HIP build keeps its backend between points that differ only in their input, as `Simulation.with_input` builds them, so such a scan no longer allocates and plans a device's backend for each point. A point's stored results and record are unchanged to rounding. diff --git a/docs/guide/workflows/scans.md b/docs/guide/workflows/scans.md index 12bf7c55..035798de 100644 --- a/docs/guide/workflows/scans.md +++ b/docs/guide/workflows/scans.md @@ -125,7 +125,9 @@ and metadata while sharing propagation products, and validates only the replaced its `configuration=` records keys of your own, such as a point's parameters, in the derived run's record. Outside a scan, a [runner](../setup/running.md#many-inputs-one-backend-the-runner) also keeps one backend for -many inputs; scan workers construct a backend per point. `noise="keep"` (the default) keeps the +many inputs. A `Local` scan on a CUDA or HIP build does the same for you while each point is +the last but for its input, which is what `with_input` gives, and any other scan or point +constructs a backend. `noise="keep"` (the default) keeps the base noise, `noise=None` disables it, and a `NoiseRealisation` replaces it. {py:meth}`~solprop.driver.simulation.Simulation.with_mixture` changes the fill ([running](../setup/running.md#a-capillary-end-to-end)); combine the two for material and input diff --git a/src/solprop/backends/registry.py b/src/solprop/backends/registry.py index 04d774f3..4bb7a95c 100644 --- a/src/solprop/backends/registry.py +++ b/src/solprop/backends/registry.py @@ -301,6 +301,27 @@ def resolve_name(name: str = "auto") -> str: """Why ``"auto"`` refuses with no native package (SEP 43 section 5).""" +def device_build(name: str) -> bool: + """Return whether a backend steps on an accelerator in this installation. + + Parameters + ---------- + name : str + A backend's name, or ``"auto"``. + + Returns + ------- + bool + True for ``native`` built for CUDA or HIP. Metal isn't counted: ``mlx`` keeps + its own arrangements for now. + """ + if resolve_name(name) != "native": + return False + import solprop_native # noqa: PLC0415 # pyright: ignore[reportMissingImports] + + return solprop_native.build_info().device_backend != "none" + + def _check_required_device(name: str) -> None: """Refuse a run that wouldn't meet :data:`REQUIRE_DEVICE_SETTING` (SEP 43). diff --git a/src/solprop/driver/_scan_run.py b/src/solprop/driver/_scan_run.py index da11e719..85b9212e 100644 --- a/src/solprop/driver/_scan_run.py +++ b/src/solprop/driver/_scan_run.py @@ -13,7 +13,7 @@ import time import traceback from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from tempfile import TemporaryDirectory from typing import cast @@ -21,6 +21,7 @@ import numpy as np from solprop.backends import select +from solprop.backends.registry import device_build from solprop.driver._scan_context import current_executor from solprop.driver._scan_model import Scan, ScanPoint, name_seed from solprop.driver.executors import Batch, Executor, Local @@ -28,7 +29,7 @@ from solprop.driver.executors.pool import PlaneScalars, peak_rss from solprop.driver.executors.queue import Queue, collect from solprop.driver.executors.slurm import Slurm -from solprop.driver.simulation import Simulation +from solprop.driver.simulation import Simulation, SimulationRunner from solprop.exceptions import DriverError, OutputError, StopRequested from solprop.fourier import spectrum_to_optics from solprop.log import ( @@ -511,6 +512,8 @@ class _Work: watched: bool = False stop: StopFlag | None = None events: _ScanEvents | None = None + keeps_backend: bool = False + _kept: SimulationRunner | None = field(default=None, init=False, repr=False) def __getstate__(self) -> dict[str, object]: """Drop the controller's flag and reporter, which stay in its process. @@ -521,7 +524,56 @@ def __getstate__(self) -> dict[str, object]: The fields a worker needs. """ state: dict[str, object] = dict(self.__dict__) - return state | {"stop": None, "events": None} + return state | {"stop": None, "events": None, "_kept": None} + + def close(self) -> None: + """Close the backend a point left, if one is kept; a second call does nothing.""" + if self._kept is not None: + self._kept.close() + self._kept = None + + def _run_point( + self, + simulation: Simulation, + capture: _Capture, + *, + progress: ProgressReporter | ProgressChoice | None, + stop: StopFlag | None, + ) -> None: + """Run a point, on the backend the last point left where it can be kept. + + A device backend takes long to build beside a point's march (solprop #298), so + a scan in this process keeps it while each point is the last but for its input, + which a builder arranges with :meth:`Simulation.with_input`. A point that isn't + closes it first, so that two are never held, and one that raises takes it down. + + Parameters + ---------- + simulation : Simulation + The point, keeping the fields the scan reads. + capture : _Capture + Where its planes go. + progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None + Where progress goes. + stop : object or None + The point's stop flag. + """ + if ( + not self.keeps_backend + or simulation.translate().tdse is not None + or not device_build(simulation.settings.backend) + ): + simulation.run(capture, progress=progress, stop=stop) + return + if self._kept is not None and not self._kept._takes(simulation): + self.close() + if self._kept is None: + self._kept = simulation.runner() + try: + self._kept._run_point(simulation, capture, progress=progress, stop=stop) + except BaseException: + self.close() + raise def dispatched(self, job: _Job) -> None: """Report that a pool sent a point to a worker. @@ -847,7 +899,8 @@ def run_point( prefix="solprop-scan-", ignore_cleanup_errors=True ) as scratch: capture = _Capture(self.options, simulation, Path(scratch)) - _retained(simulation, capture).run( + self._run_point( + _retained(simulation, capture), capture, progress="none" if reporter is None else reporter, stop=stop, @@ -1126,6 +1179,7 @@ def execute_scan( # noqa: PLR0913 - delegated public controls watched=display is None and isinstance(progress, WatchingScanReporter), stop=stop, events=events, + keeps_backend=isinstance(executor, Local), ) validate = getattr(executor, "validate", None) if callable(validate): @@ -1163,6 +1217,7 @@ def execute_scan( # noqa: PLR0913 - delegated public controls ) events.reporter.scan_finished() finally: + work.close() if display is not None: display.close() _finish_execution(executor, build, writer, output) diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index aa907eaf..4e99a191 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -3560,6 +3560,56 @@ def run( # noqa: PLR0913 - independent execution and output controls >>> handler.n_saved, handler["z"].tolist() (3, [0.0, 0.005, 0.01]) """ + return self._run( + output, + progress=progress, + progress_period=progress_period, + resume=resume, + tdse_archive=tdse_archive, + snapshots=snapshots, + stop=stop, + ) + + def _run( # noqa: PLR0913 - run's controls, and the backend it may be handed + self, + output: Output | Path | str | None = None, + *, + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, + resume: _ResumeChoice = False, + tdse_archive: TDSEArchive | None = None, + snapshots: SnapshotController | None = None, + stop: StopFlag | None = None, + kept: Backend | None = None, + ) -> Output: + """Execute the run, on a new backend or on one a caller keeps between runs. + + Parameters + ---------- + output : solprop.output.schema.Output or pathlib.Path or str or None, optional + As for :meth:`run`. + progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional + As for :meth:`run`. + progress_period : float, optional + As for :meth:`run`, s. + resume : bool or str, optional + As for :meth:`run`. + tdse_archive : object or None, optional + As for :meth:`run`. + snapshots : object or None, optional + As for :meth:`run`. + stop : object or None, optional + As for :meth:`run`. + kept : solprop.backends.protocol.Backend or None, optional + A backend built for this problem but for its input, which is loaded and + left open for the caller; ``None`` constructs one and closes it. It serves + a fresh run only, with no TDSE response. + + Returns + ------- + solprop.output.schema.Output + The handler the run was written to. + """ _check_resume_choice(resume) with _optmat.material_context(self._material_log): if resume: @@ -3620,15 +3670,25 @@ def run( # noqa: PLR0913 - independent execution and output controls _AtomicContext(handler, state, metadata), tdse_archive, ) - backend = select( - self.settings.backend, - problem, - grid=self.grid, - modes=self.modes, - profiles=self.profiles, - tdse_binding=binding, - ) - start_plane = self._prime(backend, problem, state) + if kept is None: + backend = select( + self.settings.backend, + problem, + grid=self.grid, + modes=self.modes, + profiles=self.profiles, + tdse_binding=binding, + ) + start_plane = self._prime(backend, problem, state) + else: + backend = kept + # Its own noise, which the backend it was built for would hold. + backend.set_initial( + problem.initial.field0, + _realisation(problem), + z=problem.initial.z, + ) + start_plane = 0 self._consistency_gate(binding, problem, handler, metadata, backend) run_problem( problem, @@ -3658,7 +3718,13 @@ def run( # noqa: PLR0913 - independent execution and output controls failure = error raise finally: - _close_run(backend, binding, archive, handler, failure) + _close_run( + None if kept is not None else backend, + binding, + archive, + handler, + failure, + ) self._peak_fields = _peak_field_reports(problem, handler, self.responses) if ( state is not None @@ -7213,6 +7279,55 @@ def run( # noqa: PLR0913 - one input's run, as with_input and run_problem take finally: self._lock.release() + def _takes(self, simulation: Simulation) -> bool: + """Return whether a simulation is this runner's but for its input. + + Parameters + ---------- + simulation : Simulation + A simulation that may share this one's translation. + + Returns + ------- + bool + Whether its problem passes :func:`check_derived_problem`. + """ + try: + check_derived_problem(self._problem, simulation.translate()) + except DriverError as error: + logger.debug("a new backend for this run: %s", error) + return False + return True + + def _run_point( + self, + simulation: Simulation, + output: Output, + *, + progress: ProgressReporter | ProgressChoice | None, + stop: StopFlag | None, + ) -> None: + """Run a simulation this runner :meth:`_takes`, as its own ``run`` would. + + For a scan's worker, which has built the point's simulation and holds its + record already. Unlike :meth:`run` it honours `stop`, and the handler's record + is the simulation's own. + + Parameters + ---------- + simulation : Simulation + The point. + output : solprop.output.schema.Output + Where its planes go. + progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None + Where progress goes. + stop : object or None + The point's stop flag. + """ + with self._lock: + simulation._run(output, progress=progress, stop=stop, kept=self._backend) + self.runs += 1 + def _load( self, initial: Pulse | SpatialPulse | np.ndarray, diff --git a/tests/driver/test_scan_kept_backend.py b/tests/driver/test_scan_kept_backend.py new file mode 100644 index 00000000..8c71109e --- /dev/null +++ b/tests/driver/test_scan_kept_backend.py @@ -0,0 +1,248 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A local scan on a device keeps one backend while its points differ only in input. + +solprop #298. The device is a patched predicate, so that the worker's choices run on any +build, and a real device runs this module too: what a kept backend owes a point is the +same on each, which is the point it would have computed on a backend of its own. +""" + +import threading +from collections.abc import Mapping +from functools import partial +from typing import cast + +import numpy as np +import pytest + +from legacy_geometry import LEGACY_GRID, bare_absorber +from march_bounds import FIXED_ORDER, assert_points_matched +from solprop.driver import Simulation, _scan_batch, _scan_run, simulation +from solprop.driver.executors import Local, Pool +from solprop.driver.scan import Scan, run_scan +from solprop.grid import EnvelopeGrid +from solprop.noise import input_noise +from solprop.processing import scans +from solprop.pulses import SechPulse +from solprop.stats import StatsSpec +from solprop.stepper import FixedSchedule + + +def base(backend="numpy_ref"): + """Return a short soliton fibre on a prescribed schedule, one discrete march.""" + grid = EnvelopeGrid.from_limits(0.001, 835e-9, (500e-9, 2e-6), 1e-12, **LEGACY_GRID) + return Simulation.from_gnlse( + 0.11, + 0.001, + [0.0, 0.0, -1.183e-26], + wavelength=835e-9, + grid=grid, + absorber=bare_absorber(grid), + width=100e-15, + peak_power=1.0, + shape="sech", + save_planes=[0.0005, 0.001], + schedule=FixedSchedule.uniform( + 0.0, 0.001, 0.00025, save_planes=[0.0005, 0.001] + ), + stats=StatsSpec(names=("energy", "peak_power"), periodic=("energy",), period=2), + integrator=FIXED_ORDER, + backend=backend, + ) + + +def with_pulse(shared, point): + """Return the shared simulation with a point's own pulse and noise.""" + pulse = SechPulse( + wavelength=835e-9, width=100e-15, peak_power=float(point.values["power"]) + ) + return shared.with_input( + pulse, noise=input_noise(shared.grid, np.random.default_rng(point.seed)) + ) + + +def _record(metadata): + """Return a point's record without when it was made and how long planning took.""" + backend = metadata["backend"] + settings = { + name: value + for name, value in backend["settings"].items() + if name != "planning_seconds" + } + return { + name: {**backend, "settings": settings} if name == "backend" else value + for name, value in metadata.items() + if name != "created" + } + + +SCAN = Scan("kept", base_seed=772, power=[1.0, 2.0, 3.0]) +OPTIONS = {"fields": "all", "progress": "none", "reductions": [scans.peak_power()]} + + +@pytest.fixture +def made(monkeypatch): + """Watch the backends a run constructs, and that two are never held at once.""" + backends = [] + original = simulation.select + + def watched(*arguments, **keywords): + assert all(backend.closed for backend in backends) + backends.append(original(*arguments, **keywords)) + return backends[-1] + + monkeypatch.setattr(simulation, "select", watched) + return backends + + +@pytest.fixture +def on_a_device(monkeypatch): + monkeypatch.setattr(_scan_run, "device_build", lambda name: True) + + +@pytest.mark.parametrize("backend", ["numpy_ref", "native"]) +def test_a_device_scan_of_inputs_keeps_one_backend_and_the_same_points( + tmp_path, monkeypatch, made, backend +): + if backend == "native": + pytest.importorskip("solprop_native") + builder = partial(with_pulse, base(backend)) + reference = run_scan(SCAN, builder, output=tmp_path / "alone.h5", **OPTIONS) + assert len(made) == 3 + made.clear() + monkeypatch.setattr(_scan_run, "device_build", lambda name: True) + result = run_scan(SCAN, builder, output=tmp_path / "kept.h5", **OPTIONS) + assert result.status.tolist() == ["done"] * 3 + assert len(made) == 1 + assert made[0].closed + # Each point's own noise is on the backend, or these would not match. + assert_points_matched(result, reference, SCAN, builder, what="kept point") + for point in SCAN.points(): + ours, theirs = result.point(point.index), reference.point(point.index) + assert ours["configuration_hash"] == theirs["configuration_hash"] + assert _record(ours["metadata"]) == _record(theirs["metadata"]) + assert ( + cast(Mapping, ours["boundary_monitor"]).keys() + == cast(Mapping, theirs["boundary_monitor"]).keys() + ) + # A rerun finds them all. + assert ( + run_scan(SCAN, builder, output=tmp_path / "kept.h5", **OPTIONS).status.tolist() + == ["done"] * 3 + ) + + +def test_a_point_that_shares_no_translation_takes_its_own_backend( + tmp_path, made, on_a_device +): + result = run_scan( + SCAN, + lambda point: with_pulse(base(), point), + output=tmp_path / "scan.h5", + **OPTIONS, + ) + assert result.status.tolist() == ["done"] * 3 + assert len(made) == 3 + assert all(backend.closed for backend in made) + + +def test_a_change_of_problem_closes_the_kept_backend_first(tmp_path, made, on_a_device): + # The fixture refuses a second while the first is open, so this also holds that. + one, other = base(), base() + + def builder(point): + return with_pulse(one if point.index < 2 else other, point) + + result = run_scan( + Scan("kept", base_seed=772, power=[1.0, 2.0, 3.0]), + builder, + output=tmp_path / "scan.h5", + **OPTIONS, + ) + assert result.status.tolist() == ["done"] * 3 + assert len(made) == 2 + assert all(backend.closed for backend in made) + + +def test_a_failed_point_takes_the_kept_backend_with_it( + tmp_path, monkeypatch, made, on_a_device +): + builder = partial(with_pulse, base()) + reference = run_scan(SCAN, builder, output=tmp_path / "alone.h5", **OPTIONS) + made.clear() + original = _scan_batch._Capture.save + + def capture(self, plane): + if self.metadata.noise.seed == SCAN.point(1).seed: + raise RuntimeError("deliberate capture failure") + original(self, plane) + + with monkeypatch.context() as patched: + patched.setattr(_scan_batch._Capture, "save", capture) + result = run_scan(SCAN, builder, output=tmp_path / "scan.h5", **OPTIONS) + assert result.status.tolist() == ["done", "failed", "done"] + assert "deliberate capture failure" in str(result.point(1)["error"]) + assert len(made) == 2 + assert all(backend.closed for backend in made) + # The next point is what it would have been. + ours = np.asarray(result.point(2)["fields/values"]) + theirs = np.asarray(reference.point(2)["fields/values"]) + np.testing.assert_allclose(ours, theirs, rtol=0, atol=1e-9 * np.max(np.abs(theirs))) + + +def test_a_stop_closes_the_kept_backend_and_a_rerun_finishes( + tmp_path, made, on_a_device +): + flag = threading.Event() + shared = base() + + def builder(point): + # Set after the check before the point, so the point itself sees the stop. + if point.index == 1: + flag.set() + return with_pulse(shared, point) + + output = run_scan(SCAN, builder, output=tmp_path / "scan.h5", stop=flag, **OPTIONS) + assert output.status.tolist() == ["done", "todo", "todo"] + assert all(backend.closed for backend in made) + rerun = run_scan( + SCAN, partial(with_pulse, shared), output=tmp_path / "scan.h5", **OPTIONS + ) + assert rerun.status.tolist() == ["done"] * 3 + + +def test_a_scan_off_a_device_keeps_no_backend(tmp_path, made): + # A CPU's backend costs little beside its march, and its memory goes back to the + # next point's build (solprop #298), so a numpy_ref scan builds one a point. + assert not _scan_run.device_build("numpy_ref") + run_scan( + SCAN, + partial(with_pulse, base()), + output=tmp_path / "scan.h5", + executor=Local(), + **OPTIONS, + ) + assert len(made) == 3 + + +def test_a_pool_keeps_no_backend(tmp_path, monkeypatch): + # Each point's process holds its own, and what a pool admits assumes it's freed. + monkeypatch.setattr(_scan_run, "device_build", lambda name: True) + kept = [] + original = _scan_run._Work.__init__ + + def recording(self, *arguments, **keywords): + original(self, *arguments, **keywords) + kept.append(self.keeps_backend) + + monkeypatch.setattr(_scan_run._Work, "__init__", recording) + run_scan( + SCAN, _module_point, output=tmp_path / "scan.h5", executor=Pool(1), **OPTIONS + ) + assert kept == [False] + + +def _module_point(point): + return with_pulse(base(), point) From 7d60e8f57da99f1837c3a195489c329cc9585cd3 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 04:41:13 +0000 Subject: [PATCH 119/122] Load a kept backend in the place a new one is loaded Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/driver/_scan_run.py | 2 +- src/solprop/driver/simulation.py | 41 ++++++++++++++++---------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/solprop/driver/_scan_run.py b/src/solprop/driver/_scan_run.py index 85b9212e..3692bf34 100644 --- a/src/solprop/driver/_scan_run.py +++ b/src/solprop/driver/_scan_run.py @@ -527,7 +527,7 @@ def __getstate__(self) -> dict[str, object]: return state | {"stop": None, "events": None, "_kept": None} def close(self) -> None: - """Close the backend a point left, if one is kept; a second call does nothing.""" + """Close the backend a point left, if one is kept.""" if self._kept is not None: self._kept.close() self._kept = None diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 4e99a191..307d0b0c 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -3670,25 +3670,17 @@ def _run( # noqa: PLR0913 - run's controls, and the backend it may be handed _AtomicContext(handler, state, metadata), tdse_archive, ) - if kept is None: - backend = select( - self.settings.backend, - problem, - grid=self.grid, - modes=self.modes, - profiles=self.profiles, - tdse_binding=binding, - ) - start_plane = self._prime(backend, problem, state) - else: - backend = kept - # Its own noise, which the backend it was built for would hold. - backend.set_initial( - problem.initial.field0, - _realisation(problem), - z=problem.initial.z, - ) - start_plane = 0 + backend = kept or select( + self.settings.backend, + problem, + grid=self.grid, + modes=self.modes, + profiles=self.profiles, + tdse_binding=binding, + ) + start_plane = self._prime( + backend, problem, state, kept=kept is not None + ) self._consistency_gate(binding, problem, handler, metadata, backend) run_problem( problem, @@ -4004,6 +3996,8 @@ def _prime( backend: Backend, problem: KernelProblem, state: ResumeState | None, + *, + kept: bool = False, ) -> int: """Load the backend with the state the run starts from, and say where that is. @@ -4016,6 +4010,9 @@ def _prime( state : solprop.output.hdf5.ResumeState or None What a file the run is continuing holds, or ``None`` to start at the entrance. + kept : bool, optional + Whether the backend was built for another input, so that the noise of + this one has to be handed to it. Returns ------- @@ -4023,7 +4020,11 @@ def _prime( Index of the first save plane still to propagate to. """ if state is None: - backend.set_initial(problem.initial.field0, None, z=problem.initial.z) + backend.set_initial( + problem.initial.field0, + _realisation(problem) if kept else None, + z=problem.initial.z, + ) return 0 next_plane = _validate_resume(problem, state, self.settings.tdse_execution) logger.info( From ec7b5babaeb8f8aa417dd62163490a19e9e62358 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 05:08:18 +0000 Subject: [PATCH 120/122] Answer what CI and the NVIDIA A40 found in the kept backend `_device_build` stays private, as this work's new names do, and a test that unwraps the run to reach its refusal unwraps the body now. On a real device the first scan of the kept test kept a backend too, so its reference is now held to one a point. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- src/solprop/backends/registry.py | 2 +- src/solprop/driver/_scan_run.py | 4 ++-- tests/driver/test_scan_kept_backend.py | 9 +++++---- tests/driver/test_tdse_archive_run.py | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/solprop/backends/registry.py b/src/solprop/backends/registry.py index 4bb7a95c..46595d19 100644 --- a/src/solprop/backends/registry.py +++ b/src/solprop/backends/registry.py @@ -301,7 +301,7 @@ def resolve_name(name: str = "auto") -> str: """Why ``"auto"`` refuses with no native package (SEP 43 section 5).""" -def device_build(name: str) -> bool: +def _device_build(name: str) -> bool: """Return whether a backend steps on an accelerator in this installation. Parameters diff --git a/src/solprop/driver/_scan_run.py b/src/solprop/driver/_scan_run.py index 3692bf34..7367ff78 100644 --- a/src/solprop/driver/_scan_run.py +++ b/src/solprop/driver/_scan_run.py @@ -21,7 +21,7 @@ import numpy as np from solprop.backends import select -from solprop.backends.registry import device_build +from solprop.backends.registry import _device_build from solprop.driver._scan_context import current_executor from solprop.driver._scan_model import Scan, ScanPoint, name_seed from solprop.driver.executors import Batch, Executor, Local @@ -561,7 +561,7 @@ def _run_point( if ( not self.keeps_backend or simulation.translate().tdse is not None - or not device_build(simulation.settings.backend) + or not _device_build(simulation.settings.backend) ): simulation.run(capture, progress=progress, stop=stop) return diff --git a/tests/driver/test_scan_kept_backend.py b/tests/driver/test_scan_kept_backend.py index 8c71109e..d63448c0 100644 --- a/tests/driver/test_scan_kept_backend.py +++ b/tests/driver/test_scan_kept_backend.py @@ -99,7 +99,7 @@ def watched(*arguments, **keywords): @pytest.fixture def on_a_device(monkeypatch): - monkeypatch.setattr(_scan_run, "device_build", lambda name: True) + monkeypatch.setattr(_scan_run, "_device_build", lambda name: True) @pytest.mark.parametrize("backend", ["numpy_ref", "native"]) @@ -109,10 +109,11 @@ def test_a_device_scan_of_inputs_keeps_one_backend_and_the_same_points( if backend == "native": pytest.importorskip("solprop_native") builder = partial(with_pulse, base(backend)) + monkeypatch.setattr(_scan_run, "_device_build", lambda name: False) reference = run_scan(SCAN, builder, output=tmp_path / "alone.h5", **OPTIONS) assert len(made) == 3 made.clear() - monkeypatch.setattr(_scan_run, "device_build", lambda name: True) + monkeypatch.setattr(_scan_run, "_device_build", lambda name: True) result = run_scan(SCAN, builder, output=tmp_path / "kept.h5", **OPTIONS) assert result.status.tolist() == ["done"] * 3 assert len(made) == 1 @@ -216,7 +217,7 @@ def builder(point): def test_a_scan_off_a_device_keeps_no_backend(tmp_path, made): # A CPU's backend costs little beside its march, and its memory goes back to the # next point's build (solprop #298), so a numpy_ref scan builds one a point. - assert not _scan_run.device_build("numpy_ref") + assert not _scan_run._device_build("numpy_ref") run_scan( SCAN, partial(with_pulse, base()), @@ -229,7 +230,7 @@ def test_a_scan_off_a_device_keeps_no_backend(tmp_path, made): def test_a_pool_keeps_no_backend(tmp_path, monkeypatch): # Each point's process holds its own, and what a pool admits assumes it's freed. - monkeypatch.setattr(_scan_run, "device_build", lambda name: True) + monkeypatch.setattr(_scan_run, "_device_build", lambda name: True) kept = [] original = _scan_run._Work.__init__ diff --git a/tests/driver/test_tdse_archive_run.py b/tests/driver/test_tdse_archive_run.py index 34ac10bb..3396fba4 100644 --- a/tests/driver/test_tdse_archive_run.py +++ b/tests/driver/test_tdse_archive_run.py @@ -276,7 +276,7 @@ def test_archive_requests_the_run_cannot_honour_are_refused(fake, tmp_path): with pytest.raises(DriverError, match="must differ from the optical"): _run(natural_simulation(), optical, tdse_archive=TDSEArchive(optical)) with pytest.raises(DriverError, match="TDSEArchive"): - inspect.unwrap(Simulation.run)( + inspect.unwrap(Simulation._run)( natural_simulation(), progress="none", tdse_archive="x.h5" ) gnlse = Simulation.from_gnlse( From 9fd4ceabd00e6205c1fcfbb35e85aa0ba65db036 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 05:08:44 +0000 Subject: [PATCH 121/122] Ledger: the NVIDIA H200 at this branch's later commit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 38 ++++++++++++++++------ docs/dev/seps/sep-0044-native-edge-read.md | 10 +++--- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 86a2fe0d..43f9b047 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -875,23 +875,23 @@ The geometric mean is 0.85. A requested collar's read, at 384 x 384 x 600 on tho ### The native read on the data-centre cards -An NVIDIA H200 (driver 580.196.07, `nvcc` 13.1, an Intel Xeon Platinum 8592+ host, 24 cores) and an AMD Instinct MI325X (ROCm 10.0.0, `gfx942`, an AMD EPYC 9575F host, 20 cores), one rented machine each, on 2026-11-04 and 2026-11-05, at `9a5d2346`. That commit took a device's split collar a whole state at a time, which was [then dropped](#a-device-step-beyond-its-transforms): by that section's control the node chunk adds 0.4 % to a step on the H200 and 2.2 % on the MI325X, which changes no verdict here. "Start" is the same probe on [the default check's section](#the-default-check-on-a-device-reads-occupancy-and-sums)'s base, with no work from these changes. +An NVIDIA H200 (driver 580.196.07, `nvcc` 13.1, an Intel Xeon Platinum 8592+ host, 24 cores) and an AMD Instinct MI325X (ROCm 10.0.0, `gfx942`, an AMD EPYC 9575F host, 20 cores), one rented machine each. The H200's figures are at `8cd170e2`, on 2026-11-05. The MI325X's are at `9a5d2346`, which still took a device's split collar [a whole state at a time](#a-device-step-beyond-its-transforms): by that section's control the node chunk adds 2.2 % to its step, and it hasn't the threaded finiteness pass, which took 0.17 s of each point. "Start" is the same probe on [the default check's section](#the-default-check-on-a-device-reads-occupancy-and-sums)'s base, with no work from these changes. `python -m benchmarks.device_split`, field-free, a stated input identity, `held_plane=False`, the first of a runner's points set apart: | | H200, start | H200, now | MI325X, start | MI325X, now | Aim | |---|---:|---:|---:|---:|---| -| 768 x 768 x 600, 8 steps, 4 planes: check on, s | 70 | 3.24 | 29 | 2.68 | under 8, under 6 | -| The same, check off, s | 5.9 | 2.66 | 4.2 | 2.00 | | -| On against off | x11.9 | x1.22 | x6.9 | x1.34 | under x2 | -| 384 x 384 x 600, 51 steps, 17 planes: on against off | x12.6 | x1.21 | not run | x1.20 | under x1.25 | +| 768 x 768 x 600, 8 steps, 4 planes: check on, s | 70 | 2.58 | 29 | 2.68 | under 8, under 6 | +| The same, check off, s | 5.9 | 1.98 | 4.2 | 2.00 | | +| On against off | x11.9 | x1.30 | x6.9 | x1.34 | under x2 | +| 384 x 384 x 600, 51 steps, 17 planes: on against off | x12.6 | x1.23 | not run | x1.20 | under x1.25 | | State crossings of the 8-step point | 13 | 0 | 13 | 0 | 0 | -| A step at 768, ms | 239 | 220 | 224 | 208 | | +| A step at 768, ms | 239 | 223 | 224 | 208 | | | An edge read, ms | about 4,300 | 25 | about 1,000 | 31 | under half a step | | The temporal monitor's sums, each step, ms | | 24 | | 32 | | -| `field_to_host`, 5.27 GiB, ms | 2,929 | 162 | 506 | 106 | under 300 | +| `field_to_host`, 5.27 GiB, ms | 2,929 | 159 | 506 | 106 | under 300 | | `energy`, ms | 55 | 2.3 | 80 | 6.7 | under 10 | -| `on_axis_intensity`, ms | | 11.8 | | 14.1 | | +| `on_axis_intensity`, ms | | 11.7 | | 14.1 | | | `beam_radius`, `fwhm_r`, ms | about 1,000 | 43 | about 1,000 | 44 | | | `peak_intensity`, ms | 134 | 75 | 164 | 74 | | @@ -924,9 +924,27 @@ A requested collar at 384 x 384 x 600, one state, field-free, native against the | Radial 64, 256, Runge-Kutta | 17, 11 % | 10, 2.4 % | | Cartesian 64, radial 64, native Adams | 0.6, 1.1 % | 0.6, 0.6 % | -**A lockstep batch's observation** ([#299](https://github.com/LupoLab/solprop/issues/299)), `Batch(8)` of a fibre on native, 8 steps a plane, before a batch learned to leave a field where it was: an observation took 5.3 to 6.2 ms of a plane interval of 17 to 19 ms on the H200, 22 %, and 2.6 to 2.8 ms of 13 to 18 ms on the MI325X, 13 to 16 %, for `n_omega` from 768 to 32,768. On the CPU it was 1 to 4 %. The statistics at each plane took 0.9 to 2.1 ms and 0.6 to 1.6 ms. +**A lockstep batch's observation** ([#299](https://github.com/LupoLab/solprop/issues/299)), `Batch(8)` of a fibre on native, 8 steps a plane: with a member's field an observation took 5.2 to 6.6 ms of a plane interval of 23 to 28 ms on the H200, 22 to 23 %, and 2.6 to 2.8 ms of 13 to 18 ms on the MI325X, 13 to 16 %, for `n_omega` from 768 to 32,768. On the CPU it was 1 to 4 %. A plane no one reads now takes 0.23 ms on the H200, about 1 %, and its interval falls to 18 to 21 ms. The statistics at each plane, 0.8 to 2.1 ms, are unchanged. -**A scan worker's runner** ([#298](https://github.com/LupoLab/solprop/issues/298)), on the MI325X at 768 x 768 x 600: `from_free` 3.27 s, `translate` 1.62 s, the runner 0.29 s to 0.48 s, a first point's extra run 0.17 s, and 2.68 s for 8 steps. A worker that kept its runner would save at most 0.65 s of about 8.1 s, 8 %, less on a longer point, and under the issue's 10 %. A kept runner saves neither the build nor the translation, since a point's problem must be built to compare it. +**A scan worker's backend** ([#298](https://github.com/LupoLab/solprop/issues/298)), in a warm process on the H200, default check, the median of 3: + +| | Build the simulation | Translate | Point, new backend | Point, kept | +|---|---:|---:|---:|---:| +| 768 x 768 x 600, 8 steps, 4 planes | 4.80 s | 3.68 s | 4.69 s | 2.59 s | +| 384 x 384 x 600, 51 steps, 17 planes | 1.39 s | 0.93 s | 4.24 s | 3.26 s | + +So a `Local` scan on a device now keeps the backend while each point is the last but for its input, which is what `Simulation.with_input` gives. On those two windows that saves 45 % and 23 % of a point's own time, and a builder that shares its base skips the first two columns too. + +**The transverse size** ([#529](https://github.com/LupoLab/solprop/issues/529)). Each plan takes a work area, one state's each for the rank-3 pair and, at some sizes only, for the rank-2 pair. So the plans hold two or four states, and the point's memory and its seconds per GiB depend on the size. On the H200, 8 steps, check off: + +| N x N x 600 | State, GiB | Plans, GiB | Total, GiB | Point, s | Seconds per GiB of state | +|---:|---:|---:|---:|---:|---:| +| 720 | 4.64 | 18.56 | 34.49 | 1.905 | 0.411 | +| 750 | 5.03 | 10.06 | 27.18 | 1.774 | 0.353 | +| 768 | 5.27 | 21.09 | 38.96 | 1.987 | 0.377 | +| 800 | 5.72 | 11.45 | 30.67 | 2.078 | 0.363 | +| 864 | 6.67 | 26.70 | 48.80 | 2.515 | 0.377 | +| 900 | 7.24 | 14.48 | 38.30 | 2.373 | 0.328 | ### The transverse edge against 0.1.0 diff --git a/docs/dev/seps/sep-0044-native-edge-read.md b/docs/dev/seps/sep-0044-native-edge-read.md index f1a36088..9db1bc64 100644 --- a/docs/dev/seps/sep-0044-native-edge-read.md +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -501,20 +501,20 @@ SEP 40's four-way control, `python -m benchmarks.edge_overhead`, native at 18 th ### Phase 4 — 2026-11-05 -The read qualified on an NVIDIA H200 and an AMD Instinct MI325X, the first AMD card to run it, within a rental the maintainer approved. Each ran a CUDA or HIP build on the card, at `9a5d2346`. The tables are in the ledger, [the native read on the data-centre cards](../benchmarks.md#the-native-read-on-the-data-centre-cards). +The read qualified on an NVIDIA H200 and an AMD Instinct MI325X, the first AMD card to run it, within a rental the maintainer approved. Each ran a CUDA or HIP build on the card: the H200 at `9a5d2346` and again at `8cd170e2`, and the MI325X at `9a5d2346`. The tables are in the ledger, [the native read on the data-centre cards](../benchmarks.md#the-native-read-on-the-data-centre-cards). **§7's gates,** `python -m benchmarks.device_split 768`, 8 fixed steps and 4 planes, one read a step: | Gate | NVIDIA H200 | AMD Instinct MI325X | |---|---:|---:| | No state transfer | none | none | -| A read under half a step | 25 ms of 220 ms | 31 ms of 208 ms | -| The point under twice its check-off time | x1.22, 3.24 s against 2.66 s | x1.34, 2.68 s against 2.00 s | -| Context: 51 steps, 17 planes at 384 | x1.21 | x1.20 | +| A read under half a step | 25 ms of 223 ms | 31 ms of 208 ms | +| The point under twice its check-off time | x1.30, 2.58 s against 1.98 s | x1.34, 2.68 s against 2.00 s | +| Context: 51 steps, 17 planes at 384 | x1.23 | x1.20 | Before this work those points took 70 s and 29 s, so x11.9 and x6.9. A requested collar at 384 x 384 x 600 reads in 18.7 ms and 14.1 ms, against 12.9 s and 6.2 s for the host's reduction of a host copy, and its current takes 4.5 ms and 3.9 ms at each commit, against a step of 72 ms on both. -**Tests.** On each card, `pytest -m gpu` over `tests` 130 passed, 1 skipped, and over `native/tests` 31 passed; the edge's, collar's, widths' and statistics' modules with `test_transverse_backends.py` 269 passed, 3 skipped; `test_transverse_reads.py`, `test_transverse_monitor.py` and `test_stop.py` 66 passed; `tests:cand`'s targeted set 210 passed, 7 skipped; native's radial and backend modules 92 passed. +**Tests,** at `9a5d2346`. On each card, `pytest -m gpu` over `tests` 130 passed, 1 skipped, and over `native/tests` 31 passed; the edge's, collar's, widths' and statistics' modules with `test_transverse_backends.py` 269 passed, 3 skipped; `test_transverse_reads.py`, `test_transverse_monitor.py` and `test_stop.py` 66 passed; `tests:cand`'s targeted set 210 passed, 7 skipped; native's radial and backend modules 92 passed. At `8cd170e2` on the H200, with the collar back at the node chunk: the edge's, collar's, widths', statistics', split rows', batch's and transverse backends' modules 326 passed, 3 skipped; the scan batch, runner, stop and transverse modules 135 passed; and `pytest -m gpu` on the far field, widths and statistics, 177 passed and 3 failed, all one seed of a new test that drew only masked bins, since fixed. An NVIDIA A40 at `ccd701d5`: `pytest -m gpu tests` 130 passed, 2 skipped. **Deviations from Part I.** From 7c11633bb08f8d8dd998f36b8b5b1ed0c9c28cd5 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 5 Nov 2026 06:01:03 +0000 Subject: [PATCH 122/122] Ledger: a whole scan on a kept backend, on the NVIDIA A40 [skip ci] Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPtq9Gp7VCh5QmcDdzQ --- docs/dev/benchmarks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 43f9b047..7aca3d78 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -933,7 +933,7 @@ A requested collar at 384 x 384 x 600, one state, field-free, native against the | 768 x 768 x 600, 8 steps, 4 planes | 4.80 s | 3.68 s | 4.69 s | 2.59 s | | 384 x 384 x 600, 51 steps, 17 planes | 1.39 s | 0.93 s | 4.24 s | 3.26 s | -So a `Local` scan on a device now keeps the backend while each point is the last but for its input, which is what `Simulation.with_input` gives. On those two windows that saves 45 % and 23 % of a point's own time, and a builder that shares its base skips the first two columns too. +So a `Local` scan on a device now keeps the backend while each point is the last but for its input, which is what `Simulation.with_input` gives. On those two windows that saves 45 % and 23 % of a point's own time, and a builder that shares its base skips the first two columns too. A whole scan has only been timed on an NVIDIA A40, whose binary64 step is slow beside what it saves: 5 points through `with_input`, default check, 8 steps, two rounds of each, 37.3 s to 32.7 s at 384 x 384 x 600, x0.88, and 153.9 s to 141.7 s at 768 x 768 x 600, x0.92. **The transverse size** ([#529](https://github.com/LupoLab/solprop/issues/529)). Each plan takes a work area, one state's each for the rank-3 pair and, at some sizes only, for the rank-2 pair. So the plans hold two or four states, and the point's memory and its seconds per GiB depend on the size. On the H200, 8 steps, check off: