diff --git a/AGENTS.md b/AGENTS.md index 1cdb2822..49520b41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,9 +216,9 @@ 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 - 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 + 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` 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-068, `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 @@ -353,11 +356,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-068). 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 @@ -471,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/benchmarks/device_split.py b/benchmarks/device_split.py new file mode 100644 index 00000000..52c96f9a --- /dev/null +++ b/benchmarks/device_split.py @@ -0,0 +1,292 @@ +# 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 +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. + +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. ``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 + +import argparse +import cProfile +import json +import platform +import pstats +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, cartesian_fluence_widths +from solprop.stats import StatsSpec, default_spec +from solprop.stepper import FixedSchedule, LowStorageRungeKutta, PIController + +__all__ = ["main"] + +STEP = 0.5e-6 +"""The fixed step, m.""" + +NAMED = ( + "_read_transverse", + "_edge_reading", + "node_power", + "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", + 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 _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 = _minor_faults() + started = time.perf_counter() + value = call() + times.append(time.perf_counter() - started) + faults = _minor_faults() - 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", + "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(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() + }, + # 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, + ), + }, + } + + +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 # pyright: ignore[reportMissingImports] + + 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() diff --git a/benchmarks/edge_overhead.py b/benchmarks/edge_overhead.py new file mode 100644 index 00000000..df26d12d --- /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 asked for, and its build. + + 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 check's. + simulation._transverse_edge = lambda *_: None # type: ignore[method-assign, assignment] + 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) + 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) + 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) + 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) + 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) + + +if __name__ == "__main__": + main() 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/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/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/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/changelog.d/519-native-adams-stop.fixed.md b/changelog.d/519-native-adams-stop.fixed.md new file mode 100644 index 00000000..453e4525 --- /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). 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/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/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/changelog.d/device-split-collar.changed.md b/changelog.d/device-split-collar.changed.md new file mode 100644 index 00000000..9a9853ec --- /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 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/changelog.d/host-crossing.fixed.md b/changelog.d/host-crossing.fixed.md new file mode 100644 index 00000000..23753993 --- /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 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/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/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/changelog.d/native-source-digest.changed.md b/changelog.d/native-source-digest.changed.md new file mode 100644 index 00000000..985b5b25 --- /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-068). 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..7abb0b8f --- /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. 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/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/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/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/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/architecture.md b/docs/dev/architecture.md index abe354f4..3b79629d 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -255,12 +255,12 @@ 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) │ 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,9 @@ 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 @@ -597,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/dev/backends.md b/docs/dev/backends.md index 4cf8e165..a2ffb4cc 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). @@ -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 + 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 @@ -181,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. @@ -246,7 +283,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 @@ -371,7 +410,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. @@ -446,27 +485,34 @@ 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)). +**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'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 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 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 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 backend that does not set it reports `unwatched` and refuses a requested collar by name. ## 3. Compiled execution: the design `native` runs @@ -501,7 +547,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. @@ -665,7 +711,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. @@ -737,9 +784,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. @@ -1124,9 +1171,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 @@ -1164,11 +1212,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 @@ -1182,17 +1248,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-068), 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 @@ -1373,7 +1443,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' @@ -1825,8 +1897,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-068 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/benchmarks.md b/docs/dev/benchmarks.md index dcb4ae7e..7aca3d78 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -847,6 +847,105 @@ 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. 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: + +| 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 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. 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 | 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 | 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 | 159 | 506 | 106 | under 300 | +| `energy`, ms | 55 | 2.3 | 80 | 6.7 | under 10 | +| `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 | | + +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 | 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: + +| | 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,** 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.** 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: + +| 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 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 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. 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: + +| 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 [#453](https://github.com/LupoLab/solprop/issues/453) found the tracking set's `kerr_beam` @@ -882,7 +981,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 @@ -1192,6 +1292,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 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 @@ -2098,6 +2258,203 @@ 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, 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 machine +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. + +### 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 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 +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. + +### 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, tried and dropped | +|---|---:|---:|---:| +| 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. 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, 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 +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. @@ -2421,3 +2778,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. + diff --git a/docs/dev/contributing.md b/docs/dev/contributing.md index bd0f57a9..7cf696cc 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/decisions.md b/docs/dev/decisions.md index c0fcd564..cb581e58 100644 --- a/docs/dev/decisions.md +++ b/docs/dev/decisions.md @@ -1647,7 +1647,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-068 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 @@ -1911,3 +1912,51 @@ Withdrawn (development-process record, not a design decision). and on `numpy_ref` a Python interrupt can too. A stop between steps checkpoints as ever. A killed process never had the held state. To keep a resumable state, keep the field at some planes with `fields="planes"`. + +## ADR-068 — 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/figures/backends.svg b/docs/dev/figures/backends.svg index 68f7e314..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, 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 +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 +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 39364c3f..1917540d 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 @@ -759,6 +757,7 @@ class Layer(NamedTuple): "schema", "capability", "memory", + "callback", "hdf5", "tdse_archive", "snapshots", @@ -876,7 +875,7 @@ class Stage(NamedTuple): "translate.assemble(...) → KernelProblem", "backends.select(name, problem, grid=, modes=, profiles=)", ), - "validated arrays, schema_version", + "validated arrays", ), Stage( "B", @@ -935,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", @@ -1136,12 +1135,20 @@ 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"), + ("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"), ( "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"), @@ -1683,7 +1690,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",)), ( @@ -2468,7 +2475,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 ------- @@ -2516,9 +2523,9 @@ 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 " - "forks the trajectory." + "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 gap = 44.0 @@ -2535,7 +2542,9 @@ 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/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/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/figures/one_run.svg b/docs/dev/figures/one_run.svg index 45e6478d..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 46 +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 34557f1d..c01604d9 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. @@ -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 **46** -(`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-068). `KERNEL_SCHEMA_VERSION` ended at 47; +"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`), @@ -225,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 @@ -292,8 +304,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 +1112,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,10 +1143,11 @@ 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. +4. Implement in `numpy_ref` and `native`, in either order (ADR-068), 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 @@ -1203,7 +1214,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**: @@ -1219,11 +1233,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/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/roadmap.md b/docs/dev/roadmap.md index 8ecc1c3d..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. diff --git a/docs/dev/seps/index.md b/docs/dev/seps/index.md index fa76b40c..ab02210f 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) @@ -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 | 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 @@ -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-0000-process.md b/docs/dev/seps/sep-0000-process.md index c09bbd71..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: @@ -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-068](../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. @@ -293,7 +295,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. @@ -374,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). 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..9db1bc64 --- /dev/null +++ b/docs/dev/seps/sep-0044-native-edge-read.md @@ -0,0 +1,771 @@ +# 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** | In progress | +| **Review status** | Reviewed | +| **Reviewers** | John Travers (human); GPT-6 (AI) | +| **Type** | Change | +| **Created** | 2026-11-04 | +| **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** | — | +| **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 + +### 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 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 + +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 | + +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. 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 +[#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"). 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, 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), +[#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_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_{\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, 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 in §4. `memory_ledger` gains a `transverse_edge` row. + +```text +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 +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 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: 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 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. + +**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.** 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 +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.** 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 (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 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). + +**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, 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 | +|---|---:|---|---:|---:| +| `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. 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.** `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. + 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.** 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 + +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. 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 + +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`, `EdgeTables`, `HostEdgeReduction`; `READ_RESOLUTION` goes; clean column powers; + [#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, 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 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 + +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 + +### 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. + +### 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.** 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`; 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.** 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: + +| | 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`: `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.** `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. + +**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. + +### 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: 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 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,** 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.** + +- §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 + +- 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. +- 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 + +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? + + **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, + 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. + + **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 + 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. + + **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 +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. + +### 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. diff --git a/docs/dev/seps/template.md b/docs/dev/seps/template.md index 9568b404..36ce2e03 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.* @@ -103,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/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/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/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..4ca78377 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 @@ -157,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 @@ -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/docs/guide/results/output.md b/docs/guide/results/output.md index 2d882ba3..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: @@ -187,9 +189,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 diff --git a/docs/guide/results/resuming.md b/docs/guide/results/resuming.md index 65a8d6b5..cf794a73 100644 --- a/docs/guide/results/resuming.md +++ b/docs/guide/results/resuming.md @@ -187,14 +187,27 @@ 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 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, +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 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/docs/guide/setup/running.md b/docs/guide/setup/running.md index b29caef6..2d73cfb5 100644 --- a/docs/guide/setup/running.md +++ b/docs/guide/setup/running.md @@ -547,11 +547,92 @@ 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, 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 +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/docs/guide/workflows/scans.md b/docs/guide/workflows/scans.md index 58c747da..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 @@ -179,7 +181,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)): @@ -320,8 +324,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/AGENTS.md b/native/AGENTS.md index 63a2743b..b7652d6a 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. @@ -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-068). 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 @@ -149,9 +150,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 6e5aeca0..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() @@ -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 @@ -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) @@ -976,6 +994,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/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/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index 1ae779ec..5373977b 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" @@ -82,6 +81,39 @@ 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 { + 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, 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 @@ -95,12 +127,35 @@ 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; +// The buckets one reduction holds. A reduction's value has to fit the device's shared +// 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 = 64; +#else +inline constexpr std::size_t occupancy_reduction_slots = occupancy_bucket_limit; +#endif +struct OccupancySums { + Kokkos::Array values{}; + KOKKOS_INLINE_FUNCTION OccupancySums& operator+=(const OccupancySums& other) { + 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) <= 48 * 1024); +#endif + 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; @@ -444,6 +499,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 @@ -487,7 +545,6 @@ struct Problem { double self_factor{}; double conjugate_factor{}; double harmonic_factor{}; - bool cubic_real{}; bool take_real{}; bool plasma{}; bool envelope_plasma{}; @@ -499,7 +556,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{}; @@ -600,11 +656,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) @@ -716,66 +767,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: @@ -880,11 +874,32 @@ 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); + /** + * 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, @@ -904,13 +919,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(); @@ -921,7 +950,21 @@ 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 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. */ + /** 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; + 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. @@ -973,6 +1016,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/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/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/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/native/src/bindings.cpp b/native/src/bindings.cpp index cb7b8827..37682c1b 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" @@ -55,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; @@ -202,6 +208,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"); @@ -331,7 +339,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"]); @@ -339,7 +346,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"]); @@ -815,11 +821,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); @@ -1267,10 +1273,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); } @@ -1288,58 +1291,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, @@ -1351,8 +1335,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); @@ -1361,65 +1343,72 @@ 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 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 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, 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); } + 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) { + open_state().bind_edge(faces, clip, totals, synthesis, first_row); + } + native::CrossedBytes crossed_bytes() { return open_state().crossed_bytes(); } + 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 { + 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() { + native::GeneralState& open_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). 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. @@ -1429,7 +1418,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_ = {}; @@ -1437,18 +1426,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(); } @@ -1468,7 +1457,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) @@ -1484,7 +1473,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; @@ -1517,167 +1506,125 @@ 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(); - 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(); @@ -1694,7 +1641,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_; @@ -1921,7 +1867,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["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; @@ -2294,7 +2240,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); }, @@ -2307,7 +2253,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( @@ -2315,7 +2261,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( @@ -2328,7 +2274,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(), @@ -2336,7 +2282,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", @@ -2344,7 +2290,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( @@ -2364,7 +2310,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); }, @@ -2382,20 +2328,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 = @@ -2417,7 +2362,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] || @@ -2442,7 +2387,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), @@ -2458,7 +2403,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); }, @@ -2467,7 +2412,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); }, @@ -2475,7 +2420,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; @@ -2489,7 +2434,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; @@ -2518,7 +2463,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(), @@ -2576,7 +2521,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); @@ -2650,7 +2595,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); @@ -2672,7 +2617,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); }, @@ -2714,7 +2659,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( @@ -2745,7 +2690,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()) @@ -2822,6 +2767,41 @@ 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( + "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, @@ -2918,11 +2898,82 @@ 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", + [](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 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(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.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; + // 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(), + "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) { + 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( @@ -2934,13 +2985,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/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/crossing.cpp b/native/src/crossing.cpp new file mode 100644 index 00000000..e84f9b5c --- /dev/null +++ b/native/src/crossing.cpp @@ -0,0 +1,187 @@ +// 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 + +// 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)) { + // 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) + 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/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 1a9d7364..430eccbc 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" @@ -46,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; @@ -59,10 +60,29 @@ struct NormSums { return *this; } }; -} // namespace solprop_native::host +} // namespace solprop_native + +template <> struct Kokkos::reduction_identity { + KOKKOS_FUNCTION static solprop_native::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::host::NormSums sum() { return {}; } +template <> struct Kokkos::reduction_identity { + KOKKOS_FUNCTION static solprop_native::FluenceMoments sum() { return {}; } }; namespace solprop_native { @@ -244,6 +264,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. @@ -377,7 +400,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 @@ -387,7 +409,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 + 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 @@ -690,10 +738,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) { - const double magnitude = 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); @@ -860,16 +919,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)); @@ -1414,18 +1479,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()); @@ -1435,71 +1506,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; + 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.current[index]); - }, - old_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.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]); @@ -1513,14 +1525,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 @@ -2009,6 +2020,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)) { @@ -2151,6 +2165,12 @@ 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 axis_spectrum(const std::vector& matrix); std::vector aperture_statistics(std::uint64_t flags, const std::vector& apertures, int exponent, @@ -2173,9 +2193,34 @@ 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); + 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); + return total + edge_strip_.size() * sizeof(Complex); + } + // 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; + 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; @@ -2289,7 +2334,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; @@ -2331,7 +2377,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(); @@ -2344,8 +2390,11 @@ 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); + 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); void response(std::size_t stage, std::size_t first_node, std::size_t node_count); @@ -2500,8 +2549,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); @@ -2692,9 +2743,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 @@ -2778,6 +2831,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. @@ -2837,6 +2895,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_; @@ -2871,8 +2934,33 @@ 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; + 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_; + 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::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`). + mutable crossing::Ring crossing_ring_; [[nodiscard]] bool monitoring_split() const noexcept { return monitor_bound_ && problem_.split_step_absorber; } @@ -3169,9 +3257,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) @@ -3477,6 +3565,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_ = @@ -3510,7 +3601,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_ = @@ -4107,14 +4198,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_); @@ -4615,6 +4711,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_, @@ -4658,7 +4778,8 @@ 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); + crossed_.from_host += field.size_bytes(); if (noise.empty()) { if (problem_.modified_noise) graph::deep_copy(execution_space_, noise_, Complex{}); @@ -4667,7 +4788,8 @@ 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); + crossed_.from_host += noise.size_bytes(); has_noise_ = true; } mask(current_); @@ -5349,10 +5471,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; } @@ -5529,20 +5662,34 @@ void GeneralWorkspace::low_storage_separable_input(const std::size_t pass) { const bool add_noise = problem_.modified_noise && has_noise_; const double scale = inverse_transverse_count(); const auto row = low_storage_rows[pass]; + const auto input = KOKKOS_LAMBDA(const std::size_t column, const std::size_t omega) { + const auto mode = index.mode(column); + Complex value = + pass == 0 ? state(column, omega) : stage_register.data()[index(column, omega)]; + if (add_noise) { + const auto values = spatial.value(mode, omega, active(omega) != 0U); + const double angle = at(row) * values.linear.imag(); + value += noise(column, omega) * Complex(Kokkos::cos(angle), Kokkos::sin(angle)); + } + return value * scale; + }; + if (problem_.low_storage_tiled_input) { + // Read along the state's bins and written along the scratch's rows, a tile at a + // time. + tiled_crossing( + "solprop_native.low_storage.separable_input", execution_space_, + problem_.n_columns(), problem_.n_omega, false, input, + KOKKOS_LAMBDA(const std::size_t column, const std::size_t omega, + const Complex value) { + scratch.data()[index.cell(column, omega)] = value; + }); + return; + } crossing_for( "solprop_native.low_storage.separable_input", execution_space_, problem_.n_columns(), problem_.n_omega, KOKKOS_LAMBDA(const std::size_t column, const std::size_t omega) { - const auto mode = index.mode(column); - Complex value = pass == 0 ? state(column, omega) - : stage_register.data()[index(column, omega)]; - if (add_noise) { - const auto values = spatial.value(mode, omega, active(omega) != 0U); - const double angle = at(row) * values.linear.imag(); - value += - noise(column, omega) * Complex(Kokkos::cos(angle), Kokkos::sin(angle)); - } - scratch.data()[index.cell(column, omega)] = value * scale; + scratch.data()[index.cell(column, omega)] = input(column, omega); }); } @@ -5554,7 +5701,6 @@ void GeneralWorkspace::low_storage_separable_fold(const std::size_t pass, const auto stage_register = register_stage_; const auto flows = low_storage_flows_; const bool tabulated = problem_.low_storage_flows != 0; - const bool elemental = !tabulated || (problem_.modified_noise && has_noise_); const auto scratch = transverse_; const auto spatial = spatial_; const auto window = omega_window_; @@ -5585,6 +5731,7 @@ void GeneralWorkspace::low_storage_separable_fold(const std::size_t pass, const bool writes_register = stage < 3 && !forward; const auto own = low_storage_rows[pass]; const auto next = low_storage_rows[pass < 3 ? pass + 1 : 3]; + const bool linear_here = add_noise || (flows_here && !tabulated); const std::size_t flow_row = stage == 0 ? 0 : 1; const double step = dz; #if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP) @@ -5598,9 +5745,10 @@ void GeneralWorkspace::low_storage_separable_fold(const std::size_t pass, Complex& cell, Complex& held) { const auto column_mode = index.mode(column); const bool in_band = active(omega) != 0U; - // Tabulated flows without noise read only the prefactor of the element. + // Tabulated flows without noise, or a pass with no flow, read only the prefactor of + // the element. SpatialElement values{}; - if (elemental) + if (linear_here) values = spatial.value(column_mode, omega, in_band); else if (term == term_material) values.prefactor = spatial.prefactor(column_mode, omega, in_band); @@ -5990,7 +6138,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; @@ -6180,19 +6328,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_; @@ -6348,6 +6497,11 @@ 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; + } 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) @@ -8717,8 +8871,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); @@ -8971,7 +9126,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; @@ -8996,6 +9152,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( @@ -9520,11 +9678,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); @@ -13624,7 +13783,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; @@ -13674,7 +13834,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(); @@ -13687,7 +13847,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_; @@ -13699,30 +13858,38 @@ 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). + // 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) @@ -13736,21 +13903,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); @@ -13767,6 +13930,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; @@ -13821,43 +13988,337 @@ 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_for( - "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; - }); + 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) * Kokkos::sqrt(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]) * + Kokkos::sqrt(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.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::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; +} + +// 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( @@ -13867,13 +14328,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, @@ -13885,10 +14356,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; @@ -13904,12 +14378,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) { @@ -13918,7 +14393,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_; @@ -13956,15 +14431,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. @@ -13990,9 +14463,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"); @@ -14095,11 +14588,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", @@ -14120,6 +14621,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; } @@ -14345,14 +14851,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); @@ -14372,6 +14879,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]; @@ -14393,7 +14902,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); @@ -14409,7 +14919,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); @@ -14421,6 +14931,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 @@ -14430,10 +14966,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_; @@ -14451,13 +14990,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 = coarse_time_complex_; 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(complex_time(row, index))); update.values[0] += power * share(index); const double found = half(index) * power; update.values[1] += found; @@ -14549,10 +15087,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( @@ -14592,6 +15130,383 @@ 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 { + 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"); +} + +// 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"); + 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; + // 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; + 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 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(lines.positions.begin() + first, + lines.positions.begin() + last)); + copy_vector(execution_space_, line.window, + 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))); + } + 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_ = std::move(bound); + edge_batches_ = std::move(batches); + edge_k_min_ = lines.k_min; + 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; 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"); + 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 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 (!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; + 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_)); + } + 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; +} + std::vector GeneralWorkspace::monitor_reading() { require_open(); if (!initialized_) @@ -14675,47 +15590,34 @@ 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( + 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) { - auto access = scatter.access(); + 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) - 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)); - }); + 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)); } - 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; @@ -14723,23 +15625,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]; @@ -14752,6 +15652,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 @@ -14897,6 +15801,22 @@ 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 { + 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; + } + 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_) @@ -14904,29 +15824,36 @@ 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); + crossed_.to_host += destination.size_bytes(); + 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) { @@ -14974,6 +15901,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); @@ -15168,8 +16101,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}, @@ -15231,17 +16168,18 @@ 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)}, {"statistics_flux_magnitude", statistics_magnitude_.size() * real_bytes}, + {"host_crossing_ring", crossing_ring_.bytes()}, + {"transverse_edge", edge_bytes()}, {"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}, }; } @@ -15328,6 +16266,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(); @@ -15445,6 +16387,7 @@ void GeneralWorkspace::close() noexcept { chunk_projection_stacked_ = {}; tail_projection_stacked_ = {}; partials_ = {}; + pinned_partials_ = {}; quadrature_partials_ = {}; weighted_scale_ = {}; weighted_partials_ = {}; @@ -15474,9 +16417,18 @@ void GeneralWorkspace::close() noexcept { monitor_removed_ = {}; monitor_split_sum_ = {}; monitor_found_ = {}; - occupancy_scatter_ = {}; occupancy_buckets_ = {}; - occupancy_ = {}; + occupancy_count_ = 0; + crossing_ring_.release(); + edge_batches_.clear(); + edge_strip_ = {}; + edge_lines_.clear(); + edge_scale_ = {}; + edge_faces_ = {}; + edge_clip_ = {}; + edge_totals_ = {}; + edge_synthesis_ = {}; + edge_columns_ = {}; tableau_ = {}; omega_window_ = {}; omega_ = {}; @@ -15747,6 +16699,18 @@ 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::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) { @@ -15771,6 +16735,27 @@ 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); +} +CrossedBytes GeneralState::crossed_bytes() const noexcept { + return workspace_->crossed_bytes(); +} +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(faces, whole, totals, strip, current); +} void GeneralState::bind_occupancy(const std::vector& buckets, const std::size_t count) { const CpuTeamScope team(workspace_->cpu_team()); @@ -15813,10 +16798,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/__init__.py b/native/src/solprop_native/__init__.py index 8fec5d60..bca2717c 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 @@ -171,6 +172,7 @@ def _band_request() -> int: DRIVER_SURFACE: tuple[str, ...] = ( "_adams_adopt", + "_adams_block", "_adams_engine", "_adams_lab_source", "_adams_propagator", @@ -178,112 +180,63 @@ def _band_request() -> int: "_adams_source", "_adams_state", "_apply_mask", + "_axis_row_populations", + "_axis_statistics", "_begin_plane", "_bind_monitor", "_boundary_energies", + "_collar_changed", + "_delta_phi", "_dense_stages", + "_edge_reading", + "edge_memory", "_finish", "_hoist", "_low_storage_engine", - "_observed_field", + "_march_to", "_natural_stop", "_natural_stop_due", + "_observed_field", "_on_accepted_step", "_on_axis_synthesis", + "_poll_stop", "_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. 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``. - -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", @@ -494,12 +447,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 @@ -604,7 +560,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 @@ -710,7 +666,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"]), @@ -790,36 +746,87 @@ 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("*") + 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 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") @@ -873,13 +880,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: @@ -908,8 +908,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/src/solprop_native/_core.pyi b/native/src/solprop_native/_core.pyi index 733b0086..1a1330e5 100644 --- a/native/src/solprop_native/_core.pyi +++ b/native/src/solprop_native/_core.pyi @@ -50,6 +50,21 @@ 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 axis_spectrum( + self, matrix: npt.NDArray[np.float64] + ) -> npt.NDArray[np.complex128]: ... def aperture_statistics( self, flags: int, @@ -282,6 +297,36 @@ 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 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, 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]: ... @property diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index da27a213..703f8f80 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -21,9 +21,10 @@ 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 +from solprop._process import acquire_host_blas, thread_budget from solprop.backends.driver import StepDriver, reached, replay_tolerance from solprop.backends.exponential import ( CarrierReference, @@ -59,12 +60,17 @@ 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, far_field_partials, fluence_budget, + plane_fluence_widths, radial_absorber_matrix, signed_fluence_widths, ) @@ -75,7 +81,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 @@ -128,11 +134,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, @@ -141,6 +143,11 @@ require_device_ready, ) from solprop.translate.tdse import TDSEPurpose +from solprop.transverse_monitor import ( + TransverseEdge, + _EdgeReading, # pyright: ignore[reportPrivateUsage] - released together + _transverse_edge_bytes, # pyright: ignore[reportPrivateUsage] - released together +) from . import ( PROC_BIND, @@ -205,6 +212,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, @@ -264,7 +282,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 @@ -305,7 +323,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 @@ -329,10 +347,12 @@ 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()) ) + 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: @@ -1093,13 +1113,144 @@ 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 + edge = getattr(monitor, "transverse", None) + 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() + 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)), + ) + 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, # pyright: ignore[reportPrivateUsage] + ) + + 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. + """ + _, _, 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, + 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 state is not self._native_state: + raise BackendError("the read state does not belong to this backend") + 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=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: """Reduce the monitor's five numbers where the state lives (solprop SEP 21). @@ -1348,9 +1499,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( @@ -1368,6 +1519,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]: @@ -1429,6 +1608,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() @@ -1445,23 +1627,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; " @@ -1993,6 +2185,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. @@ -2401,7 +2598,8 @@ def _low_storage_payload(problem: KernelProblem, *, device: bool) -> 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 {} @@ -2412,6 +2610,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, } @@ -2456,21 +2655,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, @@ -2653,7 +2857,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) @@ -2704,6 +2908,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(): @@ -3207,11 +3412,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( @@ -3656,10 +3856,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), @@ -3779,7 +3978,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, @@ -3795,7 +3993,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_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), diff --git a/native/tests/native/test_core.cpp b/native/tests/native/test_core.cpp index ed7683d8..bc2f7bcf 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; } @@ -401,8 +400,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 +415,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 +431,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 +447,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 +2429,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 +2455,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 +2484,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); @@ -2720,8 +2719,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()); @@ -3079,13 +3078,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); 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_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/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..81c1ef35 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; @@ -505,6 +504,109 @@ 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); + } + // 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) { + 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/native/tests/test_api_contract.py b/native/tests/test_api_contract.py index 491ecc1c..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 == 46 + 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"] == 46 + 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 8e1f4c3e..8ad5df18 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( @@ -153,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: @@ -220,7 +229,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 +239,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 46.*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 @@ -484,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) @@ -781,7 +753,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"] == 46 + 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/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 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/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/driver.py b/src/solprop/backends/driver.py index d9351b2d..8beb79ee 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 @@ -76,6 +77,7 @@ terms, ) from solprop.translate.tdse import TDSEPurpose +from solprop.transverse_monitor import TransverseEdge, _EdgeReading if TYPE_CHECKING: from solprop.backends.low_storage import LowStorageMarch @@ -313,12 +315,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, @@ -358,6 +366,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 @@ -378,7 +387,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. @@ -908,6 +916,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``. @@ -1202,12 +1234,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 @@ -1262,12 +1299,16 @@ def set_progress(self, reporter: ProgressReporter) -> None: self._progress = reporter self._progress_started = False + _stop: StopFlag | None = None + """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 ---------- @@ -1278,15 +1319,14 @@ 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 ------ 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. @@ -1439,6 +1479,39 @@ def observe(self, *, field: bool = True) -> Observation: self._clock = time.perf_counter() return plane + @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) + # --- the step loop ------------------------------------------------------------- def _begin_plane(self) -> None: # noqa: B027 - optional by design @@ -1842,15 +1915,22 @@ 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 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), @@ -1866,19 +1946,131 @@ 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`). - 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 + reading = self._edge_reading( + self._require_state(), edge, faces=read, current=accumulate + ) + if accumulate: + assert reading.current is not None + edge.step(reading.current, self._z) + 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, + 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 + (``_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=current, + 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, 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 + ---------- + edge : solprop.transverse_monitor.TransverseEdge + The run's edge. + + Returns + ------- + numpy.ndarray or None + 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 or edge.design is None: + return None + if self._clean_columns is not None: + return self._clean_columns 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. - edge.step(state, self._z) - if plane or due: - edge.read(state, self._z) + return edge.reduction.column_power(state - np.asarray(floor[0])) + + 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). @@ -1898,7 +2090,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)) @@ -2312,7 +2504,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). @@ -2323,8 +2515,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. @@ -2348,7 +2541,6 @@ def _axis_statistics( raise BackendError(msg) from solprop.backends.populations import ( # noqa: PLC0415 - a cycle axis_node, - axis_populations, axis_statistics, ) @@ -2370,11 +2562,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"]: @@ -2475,6 +2702,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 + ``True`` when it can run no other input. + """ + return self._closed + def _check_open(self) -> None: """Refuse a backend that has been closed. @@ -2630,6 +2868,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/backends/multistep.py b/src/solprop/backends/multistep.py index a6cf4ffc..6bf9fec1 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.""" @@ -1955,6 +1959,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 +2310,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. @@ -2999,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, @@ -3075,6 +3104,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``. @@ -3481,7 +3530,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/numpy_ref/backend.py b/src/solprop/backends/numpy_ref/backend.py index bee255d5..c38942ea 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 @@ -2916,13 +2911,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/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/populations.py b/src/solprop/backends/populations.py index dfb4200c..a538c80a 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,10 +212,11 @@ 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 - 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, + 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, preionisation and running-integral rule are the node scan's. Parameters @@ -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/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py index ed481b83..36fe2ab5 100644 --- a/src/solprop/backends/protocol.py +++ b/src/solprop/backends/protocol.py @@ -27,17 +27,19 @@ 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 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.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__ = [ @@ -316,6 +318,49 @@ 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: + """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"): + 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 +439,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 " @@ -558,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] + ['edge_memory', '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_initial'] + >>> names[16:] + ['set_progress', 'set_stop', 'stats', 'timed_step'] """ name: str max_batch: int + reads_transverse_edge: bool def set_initial( self, @@ -625,8 +679,29 @@ def set_progress(self, reporter: ProgressReporter) -> None: """ ... + def set_stop(self, flag: StopFlag | None) -> None: + """Attach the flag the march 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 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 ---------- @@ -661,6 +736,17 @@ 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 stats(self, spec: StatsSpec) -> dict[str, np.ndarray]: """Compute the requested statistics in one fused pass. @@ -709,6 +795,82 @@ 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 edge_memory(self, space: object) -> int: + """Return what reading the transverse edge holds in the state's memory space. + + Parameters + ---------- + space : object + The run's transverse grid. + + Returns + ------- + int + The bytes, from the grid alone; zero for a read on the host. + """ + ... + + 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. @@ -735,6 +897,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 + ``True`` when it can run no other input. + """ + ... + @runtime_checkable class BatchBackend(Backend, Protocol): @@ -751,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:`solprop.backends.protocol.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/backends/registry.py b/src/solprop/backends/registry.py index 01155a2a..46595d19 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, ...]: @@ -303,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/backends/spatial.py b/src/solprop/backends/spatial.py index 9563df5c..d347aebd 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 ( @@ -20,6 +21,8 @@ FAR_FIELD_STATISTICS, OPERATOR_STATISTICS, ApertureTables, + _fluence_rings, + _ring_fluence_widths, aperture_statistics, cartesian_fluence_widths, radial_fluence_widths, @@ -38,7 +41,6 @@ __all__ = [ "AXIS_FOLDED", - "AXIS_SPECTRUM_ONLY", "FAR_FIELD_BAND_BUDGET", "LINE_BLOCK", "FarFieldLayout", @@ -50,6 +52,7 @@ "far_field_layout", "far_field_partials", "fluence_budget", + "plane_fluence_widths", "radial_absorber_matrix", "signed_fluence_widths", "slab_columns", @@ -230,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) @@ -296,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. @@ -308,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 ------- @@ -341,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 @@ -398,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 @@ -411,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 ------- @@ -430,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 @@ -751,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``. @@ -800,12 +788,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 +800,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/boundaries.py b/src/solprop/boundaries.py index 335adbcb..a67ad33b 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 @@ -1084,15 +1085,36 @@ 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. + + 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] + ) + 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/cli.py b/src/solprop/cli.py index 1b1b7633..6d9eeb3b 100644 --- a/src/solprop/cli.py +++ b/src/solprop/cli.py @@ -157,12 +157,29 @@ 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.exceptions import OutputError # noqa: PLC0415 - imports the physics + from solprop.output.hdf5 import open_file # noqa: PLC0415 - imports the physics + + # 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( + 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/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..7367ff78 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.""" + 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, @@ -910,25 +963,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 +986,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 @@ -1101,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): @@ -1138,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/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/driver/estimate.py b/src/solprop/driver/estimate.py index 3ec286b2..1f42dbb5 100644 --- a/src/solprop/driver/estimate.py +++ b/src/solprop/driver/estimate.py @@ -16,12 +16,14 @@ 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 +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 @@ -98,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. @@ -119,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: @@ -194,6 +186,13 @@ def estimate( ) try: memory = sum(backend.memory_ledger().values()) + # 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) + ): + memory += 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/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 3105e8fe..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 @@ -477,9 +478,7 @@ 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) started = time.perf_counter() clock = RunClock.start() logger.debug( @@ -564,8 +563,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( @@ -573,7 +571,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 @@ -645,7 +643,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( @@ -695,8 +693,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: @@ -1018,11 +1015,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" ) @@ -1171,7 +1167,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``. """ @@ -1179,7 +1175,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 = backend.march_snapshot + self._snapshot_nbytes = backend.march_snapshot_nbytes self._policy = policy self._kept_z: float | None = None @@ -1201,7 +1198,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: + if self._save is None: if required: raise OutputError( "snapshot requires a backend and output with Adams history" @@ -1209,24 +1206,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 @@ -1264,7 +1266,7 @@ def _persist_failure( if output is None: return None pending = failure.pending - in_flight = getattr(backend, "_in_flight", None) + in_flight = backend.in_flight if pending is None and in_flight is not None: _record_abandoned_step(backend, output, binding, failure, in_flight) return None diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index ed17c8cd..307d0b0c 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 @@ -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 @@ -3557,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: @@ -3617,7 +3670,7 @@ def run( # noqa: PLR0913 - independent execution and output controls _AtomicContext(handler, state, metadata), tdse_archive, ) - backend = select( + backend = kept or select( self.settings.backend, problem, grid=self.grid, @@ -3625,7 +3678,9 @@ def run( # noqa: PLR0913 - independent execution and output controls profiles=self.profiles, tdse_binding=binding, ) - start_plane = self._prime(backend, problem, state) + start_plane = self._prime( + backend, problem, state, kept=kept is not None + ) self._consistency_gate(binding, problem, handler, metadata, backend) run_problem( problem, @@ -3655,7 +3710,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 @@ -3935,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. @@ -3947,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 ------- @@ -3954,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( @@ -3995,9 +4065,6 @@ def _continue_march( Whether this run steps with an Adams method. Another integrator keeps no history, by its own choice, which needs no warning. """ - 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") @@ -4006,7 +4073,7 @@ def _continue_march( # to: say so once, as a warning (SEP 29 section 6.6). if not adams: 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}", @@ -4016,7 +4083,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", @@ -7207,12 +7274,61 @@ 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: 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/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/__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/memory.py b/src/solprop/output/memory.py index 6111e02e..1da7a87c 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"] @@ -290,6 +291,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, @@ -302,20 +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; ``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. """ - # 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 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/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/output/schema.py b/src/solprop/output/schema.py index 7d1fc0f4..64d2f01e 100644 --- a/src/solprop/output/schema.py +++ b/src/solprop/output/schema.py @@ -67,6 +67,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 @@ -122,6 +123,7 @@ "recorded_input", "resolve_metadata_write", "serialise", + "stored_spectrum", ] logger = logging.getLogger(__name__) @@ -533,6 +535,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. @@ -572,8 +578,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 ------- @@ -581,12 +588,28 @@ 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: + # 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"], + op_flags=["readonly", "contig"], # pyright: ignore[reportArgumentType] + 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), ) @@ -4340,6 +4363,48 @@ 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: + """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) + return target + + class PlaneStore(Displayed, ABC): """The shared half of an output handler: one save protocol for every backing store. @@ -4805,12 +4870,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: @@ -5136,6 +5201,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, @@ -5148,7 +5235,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/src/solprop/pulses.py b/src/solprop/pulses.py index 85261e5a..2401a1ff 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,38 @@ 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. + + 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])) + + 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) + 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 +1188,70 @@ def _hard_masked( "widen the waist or refine the transverse grid" ) raise PulseError(msg) - return field * live[:, 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. + + 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) + 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. + + Parameters + ---------- + rows : slice + The block of the first axis. + """ + total[rows] += part[rows] + + parallel_rows(total.shape[0], total.nbytes, work) def _members(pulse: Pulse) -> list[Pulse]: @@ -1243,15 +1334,26 @@ 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. + + Parameters + ---------- + rows : slice + The block of the first axis. + """ + 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 +1377,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 +1389,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 +1404,23 @@ 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. + + Parameters + ---------- + rows : slice + The block of the first axis. + """ + 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/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/src/solprop/spatial.py b/src/solprop/spatial.py index 84e67fd6..8b372ac3 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,33 @@ 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: + """Form one block of the chunks' admittance. + + Parameters + ---------- + rows : slice + The block of chunks. + """ + 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 +950,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: @@ -3096,24 +3183,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 +3201,92 @@ 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 (``_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. + + 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]) ) - return second, radius_e2, width + return radius_e2, width def _line_fluence_widths( 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/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 20abd89f..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,320 +206,6 @@ logger = logging.getLogger(__name__) -KERNEL_SCHEMA_VERSION = 46 -"""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. - -Version 46 removes the low-storage march's held plane: ``StepDriver.set_held_plane``, -``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. -""" - DEFAULT_COMPONENTS = ("x",) """Canonical component of an unrotated HE_1m mode. @@ -1241,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 @@ -1300,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', 46) + >>> problem.shape, problem.grid_kind + ((1, 1, 1, 3072), 'envelope') """ - schema_version: int grid_kind: GridKind shape: tuple[int, int, int, int] flags: Flags @@ -1368,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 @@ -1411,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() @@ -6982,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( @@ -7060,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 46: envelope grid, route modal + kernel problem: envelope grid, route modal """ linear, transform = problem.linear, problem.transform tables, metric = transform.modal_tables, linear.modal_metric @@ -7072,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..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 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/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py index a3ccd0c3..2acc10b2 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 ``_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 +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,6 @@ "GUARD", "NOISE_FACTOR", "READS", - "READ_RESOLUTION", "TransverseEdge", "TransverseStatus", ] @@ -64,19 +66,74 @@ _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 _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 + ---------- + 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. 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. + 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. + """ + + 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 + + @dataclass(frozen=True) class _Face: """One face of the transverse edge. @@ -209,6 +266,554 @@ def _guard_width(space: RadialGrid | FreeGrid | PlanarGrid) -> float: return GUARD * min(widths) +@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 ``_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 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 + ------- + int + The bytes. + """ + columns = int(np.asarray(space.spectral_weights).size) + if isinstance(space, RadialGrid): + 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 + # 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) + 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"]: + """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. + tables : solprop.transverse_monitor._EdgeTables + What a state's node energies are summed with. + 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, ...] + 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) + + def reading( + self, + state: Complex[np.ndarray, "n_modes n_pol n_omega"], + *, + faces: bool = True, + current: bool = False, + 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 : 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. + + Returns + ------- + solprop.transverse_monitor._EdgeReading + The reading. + """ + rate = self.current(state) if current else None + if not faces: + return _EdgeReading(current=rate) + nodes = self.node_power(state, full=full) + collar = bool(self.lines) + 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, + 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"], + *, + 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) + 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) + 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]) + rows = self.tables.rows + nodes[rows] = _row_energy(physical[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 _axis_values( + self, state: Complex[np.ndarray, "n_modes n_pol n_omega"] + ) -> dict[str, np.ndarray]: + """Return the field along each axis's nodes, line by line. + + Parameters + ---------- + state : numpy.ndarray + One member's spectral columns. + + Returns + ------- + 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): + 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) + return {"y": cube.reshape(space.n_y, -1)} + cube = np.fft.ifftn(flat.reshape(space.n_y, space.n_x, -1), axes=(0, 1)) + values = {"x": np.moveaxis(cube, 1, 0).reshape(space.n_x, -1)} + if not isinstance(space, LineGrid): + values["y"] = cube.reshape(space.n_y, -1) + return values + + 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. + """ + values = self._axis_values(state) + found = [] + for line in self.lines: + if not line.strip.size: + found.append((0.0, 0.0)) + continue + 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[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"]) -> 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. + + Returns + ------- + float + The current, unweighted by the launch. + """ + values = self._axis_values(state) + total = 0.0 + for line in self.lines: + if line.onset is None: + continue + 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(along[node]) * slope) / self.k_min + total += float(np.sum(np.maximum(flux, 0.0))) * line.measure + return total + + @dataclass class TransverseEdge: """The transverse edge of one free-space run: geometry, totals and record. @@ -274,9 +879,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) - _workers: int = field(default=1, init=False) + _kappa_order: np.ndarray = 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.""" @@ -292,120 +897,42 @@ 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) 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 + self.tables = self._edge_tables() + self.reduction = _HostEdgeReduction( + space, self.tables, _edge_lines(space, self.design), self.k_min ) - 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. + def _edge_tables(self) -> _EdgeTables: + """Return the weights a state's node energies are summed with. Returns ------- - int - The thread budget's threads, or 1 below ``2**18`` elements. + solprop.transverse_monitor._EdgeTables + The tables, over the grid's nodes (or rows). """ - return self._workers if cube.size >= _SMALL_TRANSFORM else 1 + 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) > _requested_size(self.space) + 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. @@ -415,15 +942,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. @@ -441,29 +966,41 @@ 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 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, @@ -472,12 +1009,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 @@ -491,22 +1031,27 @@ 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) - weighted = self._weights * nodes + assert reading.faces is not None and reading.columns is not None + columns = reading.columns 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)) + 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 @@ -514,7 +1059,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" @@ -528,9 +1076,8 @@ def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in orde ) 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 " @@ -540,7 +1087,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) @@ -549,7 +1096,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 = ( @@ -568,7 +1115,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 @@ -583,13 +1130,13 @@ def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in orde 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(state, noise, weighted) + self._launch_return(reading, noise) 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, *, @@ -599,8 +1146,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 @@ -613,7 +1160,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 @@ -642,23 +1188,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)``. @@ -675,7 +1221,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,15 +1274,34 @@ def due(self, z: float, proposal: float = 0.0) -> bool: """ return z + proposal >= self._next - 1e-12 * max(abs(z), 1.0) - 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 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 ---------- - state : numpy.ndarray - One member's spectral columns. + 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, reading: _EdgeReading, z: float) -> None: + """Take a reading at a committed position, credit the removals and judge. + + Parameters + ---------- + reading : solprop.transverse_monitor._EdgeReading + The state's reading. z : float Its position, m. @@ -745,13 +1310,10 @@ 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 + assert reading.faces is not None and reading.columns is not None 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. @@ -762,28 +1324,28 @@ def read( 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 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: # 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(state, z) + self._judge_return(reading, z) self._judge_ramp_removal() return 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) @@ -809,172 +1371,37 @@ def read( 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 _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. """ @@ -985,11 +1412,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: @@ -1018,44 +1445,35 @@ 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: """Record a collar's launch baselines and floors (section 5.3). 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] - 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 + self._strip_floor = 3.0 * self._strip_shares(self.reduction.strip(noise))[0] + assert reading.totals is not None + self._current_baseline = float(reading.totals[2]) / self._launch 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. @@ -1064,7 +1482,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 @@ -1262,6 +1681,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) @@ -1305,6 +1730,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/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/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_backend_protocol.py b/tests/backends/test_backend_protocol.py index 546000da..414fffc7 100644 --- a/tests/backends/test_backend_protocol.py +++ b/tests/backends/test_backend_protocol.py @@ -2,15 +2,19 @@ # # 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, Observation, StepStats, StepSummary, + protocol, ) from solprop.exceptions import BackendError from solprop.log import NullReporter @@ -151,6 +155,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( @@ -291,6 +320,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 @@ -301,6 +333,30 @@ def restore_call_ledgers(self, logical, atomic=None): def set_progress(self, reporter): self.reporter = reporter + def set_stop(self, flag): + return None + + def edge_memory(self, space): + return 0 + + def attach_monitor(self, monitor): + 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) @@ -347,3 +403,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/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_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 777a630f..ea947850 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 @@ -870,6 +869,13 @@ 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" + ") -> solprop.transverse_monitor._EdgeReading" + ), + "_next_step": "(self) -> float", "_observed_field": ( "(self, state: _StateT)" " -> jaxtyping.Complex[ndarray, 'n_batch n_modes n_pol n_omega']" @@ -943,14 +949,54 @@ 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_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'] | None, " + "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 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 = ( @@ -966,12 +1012,32 @@ def test_the_controller_keeps_only_the_norms_it_reads(driver): "_state", "_noise", "_closed", + "_accepted_norms", + "_accepted_steps", + "_clock", + "_collar_quiet", + "_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 = ( @@ -1076,78 +1142,18 @@ 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. Version 46 removes - ``held_observation``, ``_hold``, ``_held_field``, ``set_held_plane`` and - ``_drop_held``: a low-storage march holds no plane (ADR-067). + """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 == 46, ( - "the seam changed: rewrite the literals of this test" - ) 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(): @@ -1165,6 +1171,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 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_free_split_rows.py b/tests/backends/test_free_split_rows.py new file mode 100644 index 00000000..c427662a --- /dev/null +++ b/tests/backends/test_free_split_rows.py @@ -0,0 +1,206 @@ +# 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 + +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 +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. + + Parameters + ---------- + simulation : solprop.driver.Simulation + The run. + chunk : int or None + The node chunk, or ``None`` for the problem's own. + + 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), + ) + + +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, 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 + + +@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_a_devices_choices_on_any_build( + reference: tuple[str, np.ndarray, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # 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) + _check_block(reference, 5) + + +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 diff --git a/tests/backends/test_mixture_channels.py b/tests/backends/test_mixture_channels.py index 5032e549..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 == 46 assert problem.flags.n_channels == 2 assert problem.flags.n_raman == 0 assert problem.responses.labels == mixture.labels 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]]) 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)) diff --git a/tests/backends/test_native_edge_collar.py b/tests/backends/test_native_edge_collar.py new file mode 100644 index 00000000..5fb99bd8 --- /dev/null +++ b/tests/backends/test_native_edge_collar.py @@ -0,0 +1,855 @@ +# 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 contextlib +import math +import threading +import warnings +from pathlib import Path +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.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, 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 ( + FixedSchedule, + LowStorageRungeKutta, + Multistep, + PIController, + RungeKutta, +) +from solprop.transverse_monitor import TransverseEdge + +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.""" + + +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] = { + "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, + *, + 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), + waist / 2.0, + waist_x=0.735 * 63.5e-6, + ), + settings=settings, + ) + pulse = SpatialPulse(temporal, waist, propz=propz) + if route == "radial": + return Simulation.from_radial( + grid, + RadialGrid(300e-6, 48, collar_fraction=1.0), + medium, + responses, + pulse, + settings=settings, + noise=cast("Any", noise), + ) + 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, + 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() + 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("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, 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: + 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) + # 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: + # 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 + assert isinstance(space, LineGrid) + 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 + assert isinstance(space, LineGrid) + 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 + assert isinstance(space, LineGrid) + 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() + + +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, 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" or scheme == "nonlinear" else STEPS + schedule = FixedSchedule.uniform( + 0.0, LENGTH, LENGTH / steps, save_planes=[LENGTH] + ) + simulation = _case( + route, + **INWARD, + integrator=march, + schedule=schedule, + save_planes=[LENGTH], + controller=None, + absorber_scheme=scheme, + dz_max=LENGTH / steps, + ) + 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. 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) +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 + + +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", **INWARD, 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. 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, abs=0.0 + ) + + +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, + **INWARD, + backend=backend, + 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"] > 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 + + +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 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() + 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 + # 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: + """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() + + +def test_a_strip_of_too_few_nodes_reads_nothing_on_either_side( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # 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() + 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/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py new file mode 100644 index 00000000..1c4c340e --- /dev/null +++ b/tests/backends/test_native_edge_reading.py @@ -0,0 +1,741 @@ +# 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 +import warnings +from typing import Any, cast + +import numpy as np +import pytest + +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.memory import low_storage_stage_register +from solprop.backends.registry import available, select +from solprop.driver import Simulation, SimulationSettings +from solprop.exceptions import BoundaryError, BoundaryWarning +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, + PIController, + RungeKutta, +) +from solprop.transverse_monitor import GUARD, _EdgeReading, _transverse_edge_bytes +from test_transverse_reads import REREVIEW, packet_run + +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.""" + 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 _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() + + +@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) + 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: + # The principle of SEP 44: only numbers cross. Every state-sized read of the host + # goes through `_observed_field` on this route. + + 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", backend="native" + ) + problem = simulation.translate() + field = np.asarray(problem.initial.field0)[0] + with simulation.runner() as runner: + 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) + _, _, 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: + backend.close() + + +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" + ) + return simulation.estimate().memory + + 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 + ), + ) + medium = SpatialMedium("Ar", 1.0) + simulations = [ + Simulation.from_radial( + grid, + 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) + 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/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_far_field.py b/tests/backends/test_native_far_field.py index c770a3c0..86514cd8 100644 --- a/tests/backends/test_native_far_field.py +++ b/tests/backends/test_native_far_field.py @@ -41,14 +41,20 @@ 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 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,132 @@ 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) + # 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)) + 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 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] + + +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 @@ -488,23 +620,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_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/backends/test_native_gpu.py b/tests/backends/test_native_gpu.py index 4c83c1dc..f4808566 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,63 @@ 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") + + +# 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, +): + # 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) + # 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) + 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() + + +@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) + 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() 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") 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/backends/test_native_widths.py b/tests/backends/test_native_widths.py new file mode 100644 index 00000000..c164ba88 --- /dev/null +++ b/tests/backends/test_native_widths.py @@ -0,0 +1,686 @@ +# 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, + _fluence_rings, + _ring_fluence_widths, + cartesian_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), + ) + 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 + ) + 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, 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 + # 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) + 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], + allowed, + 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, "on_axis_intensity", "peak_intensity")) + want = _measure("numpy_ref", grid, problem, field, spec) + got = _measure("native", grid, problem, field, spec) + 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}" + 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 + # 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 +@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" + # 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)) + + +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 +@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", 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): + """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.""" + + 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=names)) + 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) + + +@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.""" + + 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) 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/backends/test_on_axis_statistics.py b/tests/backends/test_on_axis_statistics.py index 077e0e5e..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 @@ -21,6 +22,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 @@ -61,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): @@ -316,6 +318,153 @@ 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 == "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) + 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", + "rotated", + "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_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") + 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) 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 = [] 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/driver/test_estimate.py b/tests/driver/test_estimate.py index fbbe3bbc..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 @@ -52,6 +54,19 @@ 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 = 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 + + def test_the_output_size_counts_only_the_retained_fields(): simulation = _built("soliton", "numpy_ref") every = simulation.estimate().output_bytes 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/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) diff --git a/tests/driver/test_native_march_lifecycle.py b/tests/driver/test_native_march_lifecycle.py index 9dbf3122..f3a3d185 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,10 @@ 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.recipe import Recipe from solprop.stepper.multistep import Multistep pytestmark = [ @@ -174,6 +178,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 = "1075 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.""" @@ -256,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" diff --git a/tests/driver/test_resume_history.py b/tests/driver/test_resume_history.py index e8d70690..6eb03fdd 100644 --- a/tests/driver/test_resume_history.py +++ b/tests/driver/test_resume_history.py @@ -18,10 +18,9 @@ """ import dataclasses -import inspect import logging import math -from types import SimpleNamespace +from typing import Any, cast import h5py import numpy as np @@ -34,8 +33,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.driver import Simulation +from solprop.backends.registry import select from solprop.exceptions import OutputError, SolpropWarning from solprop.log import NullReporter from solprop.output import HDF5Output @@ -258,6 +258,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 @@ -555,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 diff --git a/tests/driver/test_run.py b/tests/driver/test_run.py index 73b17a19..a6852a9e 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,30 @@ 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 edge_memory(self, space): + return 0 + + def attach_monitor(self, monitor): + pass + + 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/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"): 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] diff --git a/tests/driver/test_scan_kept_backend.py b/tests/driver/test_scan_kept_backend.py new file mode 100644 index 00000000..d63448c0 --- /dev/null +++ b/tests/driver/test_scan_kept_backend.py @@ -0,0 +1,249 @@ +# 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)) + 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) + 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) 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 ): 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( diff --git a/tests/output/test_callback.py b/tests/output/test_callback.py new file mode 100644 index 00000000..19ab8ec9 --- /dev/null +++ b/tests/output/test_callback.py @@ -0,0 +1,666 @@ +# 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 +from typing import cast + +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 import run as run_module +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 +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") + +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, monkeypatch +): + """Fields on the first two planes and none after: one run of each kind of plane. + + 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 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)) + 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 + + +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 + + +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 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 diff --git a/tests/output/test_handlers.py b/tests/output/test_handlers.py index 095d816c..da106f1b 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 @@ -167,6 +168,51 @@ 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) + 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)) + 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) + assert plane.field is not None + 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 plane.field is not None + 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/output/test_snapshot_failures.py b/tests/output/test_snapshot_failures.py index 625b19b8..88a2ab6f 100644 --- a/tests/output/test_snapshot_failures.py +++ b/tests/output/test_snapshot_failures.py @@ -330,11 +330,11 @@ 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 keeper._snapshot = lambda: None + keeper._snapshot_nbytes = lambda z: None with pytest.raises(OutputError, match="accepted endpoint"): keeper.keep(0.125, required=True) diff --git a/tests/test_cartesian_precision.py b/tests/test_cartesian_precision.py index 855150f8..334d566d 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,42 @@ 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), + # 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 +): + # 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 diff --git a/tests/test_fourier.py b/tests/test_fourier.py index 4a6fd2b1..0b774482 100644 --- a/tests/test_fourier.py +++ b/tests/test_fourier.py @@ -86,6 +86,30 @@ 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) + 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 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)] 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() diff --git a/tests/test_transverse_monitor.py b/tests/test_transverse_monitor.py index ca07f719..938bef5f 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.every_step and edge.resumed, + ) + 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._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), 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 7f638d87..4bc7ae3b 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 _EdgeReading +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,89 @@ 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 ( # pyright: ignore[reportMissingImports] # noqa: PLC0415 + NativeBackend, + ) + + 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 +) -> 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 = {"readings": 0} + driver = cast("type[Any]", _driver_class(backend)) + edge_reading = driver._edge_reading + + def counting(self: Any, *args: Any, **keywords: Any) -> _EdgeReading: + calls["readings"] += 1 + return edge_reading(self, *args, **keywords) + + 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) + 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["readings"] == 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, diff --git a/tests/translate/test_dispersive_kerr_terms.py b/tests/translate/test_dispersive_kerr_terms.py index af981fe5..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 == 46 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 0b0d62b2..f1b4a42b 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-068): it names the last one as history. """ - assert KERNEL_SCHEMA_VERSION == 46 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(