SEP 24 — A low-storage interaction-picture Runge–Kutta march for memory-bound fixed-step runs#
SEP |
24 |
Title |
A low-storage interaction-picture Runge–Kutta march for memory-bound fixed-step runs |
Author |
John Travers |
Drafting agent |
Opus 5.5 (2026-10-16) |
Status |
Final |
Review status |
Reviewed |
Reviewers |
John Travers (human); Opus 5.5 (AI) |
Type |
Feature |
Created |
2026-10-16 |
Resolution |
Accepted 2026-10-18 by John Travers after his final review, on the revisions of 2026-10-18 recorded in Part III; Part I is frozen from here |
Requires |
SEP 16 (the integrator family and the |
Supersedes |
— |
Superseded-by |
— |
Implementation |
phase 1: Opus 5.5 (AI), branch |
Manual pages |
Part I — Proposal#
1. Abstract#
Add a third integrator, LowStorageRungeKutta, beside the default Multistep() and
RungeKutta(). It is the classical fourth-order Runge–Kutta method in the interaction
picture anchored at the step’s midpoint: Lawson’s transformation [1] in Hult’s
arrangement [2], known as RK4IP. It runs as a replay-only march of a prescribed
FixedSchedule. Beside the source pipeline’s scratch it holds three field-sized
registers: the state (overwritten in place by its interaction-picture image, then by the
accepted step), one accumulator and one stage register. Each slope is consumed as it is
produced, and each step is accepted in place. The method
has no embedded error estimate. Its accuracy is controlled from outside, by solprop’s
existing three-level Richardson calibration (solprop.stepper.schedule.calibrate)
applied to the measured observable.
On a Cartesian free-space grid, where one state takes gigabytes, the march’s field-sized
volumes fall to four, against 15 (Kokkos) or 16 (numba_cpu) for the Runge–Kutta march
and 44 for the default Adams march. A tabulated half-step flow adds one volume, and the
modified noise another. The design is also the fast one. In a drafting measurement on a
Cartesian Kerr case, given as motivation only, a standalone prototype of this register
form cost a fifth of what the current IP-DOPRI5 replay costs per source evaluation.
Success is judged by:
an observed fourth order on the soliton, an analytic varying operator and a free-space Kerr case;
a linear limit exact to rounding;
agreement with the in-tree RK4IP to a round-off bound;
parity across the three CPU backends;
ledgers within the target;
ten-step benchmarks against IP-DOPRI5, at equal step and at equal Richardson error, with an in-tree target of at least 3 times less time per evaluation and 4 times less per step.
An embedded pair for adaptive stepping is an optional fourth phase.
2. Motivation and scope#
One state takes gigabytes. A Cartesian free-space run stores its state on
(n_batch, n_modes, n_pol, n_omega) with n_modes = n_x n_y
(conventions §6). The field-sized unit of
architecture §8 is therefore
\(F = 16\,n_\mathrm{batch}\,n_x n_y\,n_\mathrm{pol}\,n_\omega\) bytes. At \(768\times768\)
columns and 600 bins, with one polarisation and one member, \(F\) is 5.66 GB (5.27 GiB).
Doubling \(n_x\) and \(n_y\) for a transverse convergence check quadruples it.
What the marches hold. The backends’ ledgers count these field-sized volumes on the free kind:
backend |
Runge–Kutta march (DOPRI5) |
Adams history beside it (exponential, order 5) |
|---|---|---|
Kokkos ( |
15 F: state, accept buffer, seven stage rows, two accumulators, the weighted-norm residual, the recycled first-same-as-last (FSAL) stage, the whole-step propagator, the transverse scratch |
29 F, so 44 F in all |
|
16 F: state, seven stage rows, two accumulators, accept buffer, exponent, recycled stage, a propagator pair, the transverse scratch; plus one accept-buffer copy per trial ( |
16 F ( |
|
13 F counted: state, seven stages, two accumulators, a propagator pair, the transverse scratch |
7 F ( |
The history counts come from solprop.backends.memory.adams_memory (\(q+2=7\) slots at
order five). At the shape above the Runge–Kutta march takes 79 GiB on Kokkos and 84 GiB
(plus a 5.3 GiB transient) on numba_cpu. Adams takes 232 GiB on Kokkos, which
solprop.driver.memory._admit_history refuses, naming RungeKutta(). Even with a single
stage row, the free-kind pipeline on Kokkos would hold about seven
volumes: state, transverse scratch, the row, two accumulators, the propagator and the
accept buffer. No tableau gets below that floor.
What a fourth-order step needs. Hult’s midpoint arrangement [2] applies only forward half-step flows. In the accumulator organisation each slope is folded into a running sum and the next stage’s input as soon as it is produced. The step then needs three registers beside the evaluation’s scratch (§4.3). Families built for this exist [3–6]: two-register (2N), 2R and 3R schemes with embedded estimates, and minimum-storage classes that can restart a step or estimate its error. solprop has none; its marches were built for adaptive control.
What the registers cost in time. These are drafting measurements, given as motivation only. They were taken on a contended machine against an out-of-tree prototype, so they are not acceptance evidence; the acceptance target is set in-tree in §7.4. They also predate SEP 25’s phases 3 and 4, which fused the compiled passes and the Kokkos stage kernels and batched the stage scalars, so the IP-DOPRI5 replay of the current tree is faster than the figures below; §7.4 is measured against the current tree. The case was a Cartesian envelope grid of \(192\times192\times300\) (\(F=177\) MB) with Kerr only, no transverse collar and 0.5 µm fixed steps, timed over ten-step windows on one thread of an Apple-silicon laptop.
The IP-DOPRI5 replay on
numba_cputook about 7.0 s a step. It ran a spectral-only absorber underabsorber_scheme="split_step", which forgoes the first-same-as-last stage, so it made seven source evaluations a step, about 1.0 s each (an earlier reading divided by six and quoted 1.15–1.19 s); Kokkos CPU was within 2 % of that.A standalone prototype of §4.3’s register form (three registers, the half-step flow tabulated once, one fused elementwise pass per stage, transforms in place) took about 0.21 s per evaluation: about 4.8 times less, and about eight times less per step.
With 18 threads the ratio is 3.5–6: 22–30 ms per evaluation against 106–131 ms, with the same seven-evaluation accounting.
A profile of the numba_cpu march splits one DOPRI5 stage (about 470 ms in the profiled
run) as follows:
share |
where |
|---|---|
about 60 % |
|
about 21 % |
|
about 17 % |
the chunked temporal transform pairs ( |
about 3 % |
the Kerr response |
The accepted step’s _finish adds about 170 ms a step. Chunking is not the explanation:
a node budget of 1 GiB instead of 64 MiB gains 1.3 times, and no more once one chunk
holds every node. The response is a few per cent of a stage, and the rest is register
traffic and the propagator. Fewer volumes, each read once, mean fewer passes, so the
memory target and the speed target are the same design.
When the embedded estimate buys little. The target is a fixed-step run whose accuracy
is calibrated from outside, by Richardson comparison of the observable at \(h\), \(h/2\) and
\(h/4\) (calibrate, ADR-019). That suits a weak signal under a strong pump, such as a band
orders below the peak or a small difference of strong fields, where a whole-field norm is
blind. The norm is scaled by the whole state over the active band
(stepper.md §3), so a band holding a fraction \(\delta\) of the energy
contributes about \(\sqrt\delta\) of it; how to score such observables is open
(stepper.md §9). SEP 14’s band-norm study (phase 6) found weighting a
weak band equivalent to tightening
rtol: the step is shared, and the weak band is made where the strong field evolves.
Calibrating on the observable certifies what a per-step estimate cannot, and dropping the
estimate frees the storage the embedded pair costs.
Scope. It serves large Cartesian, line and hybrid planar runs, ensembles and scans on one schedule (ADR-013), and devices holding a few states but not fifteen. In scope are the integrator and its record; a replay-only, accept-in-place march and its seam; the three CPU backends with ledgers and admission; the semantics of a march without an estimate; refusals by name; documentation, a tutorial and benchmarks; and, optionally, an embedded pair (phase 4). Out of scope are changed defaults or changes to the existing marches’ arithmetic; adaptive stepping before phase 4; dense output; coupled flows; the TDSE response; a weak-observable norm (the roadmap item stays open); the pipeline’s own buffers and observation copies (SEP 22); reduced precision; exponential Runge–Kutta methods; and device qualification (Q7).
3. Context#
The integrators. SimulationSettings.integrator (solprop.driver.simulation) is
Multistep() by default (ADR-045), or RungeKutta(tableau=DOPRI5) (ADR-004).
Translation passes the tableau with no integrator, or Multistep with DOPRI5 unused.
StepperSettings.integrator is typed Multistep | None (kernel schema 28).
The Runge–Kutta march is the driver’s own (StepDriver._march_to, _replay_to). Its
abstract _trial_step(z, dz) returns a norm and a raw end state. _finish treats only
a kept step, and the accepted state changes only inside the commit (committing(),
ADR-047).
The Adams march (solprop.backends.multistep.AdamsMarch, ADR-037, ADR-041 to ADR-046) is
the precedent for a march that owns its buffers through an engine: AdamsEngine,
HostHistory, or a backend’s own from _adams_engine (ADR-043). Its source comes from
two hooks every CPU backend implements: _adams_lab_source (the complete source at a
lab-frame field and position) and _adams_propagator (the flow \(U\) between two
positions). It replays schedules itself (AdamsMarch._replay_to, replay_step), as a lockstep
member does (LockstepRunner._replay_to).
Fixed schedules. FixedSchedule.uniform lands on every plane and kink.
discover, calibrate and replay are ADR-019’s workflow
(fixed schedules, stepper.md
§8). calibrate refuses an observed order more than ORDER_MARGIN = 3 above the
scheme’s, and calls a ladder inconclusive within CONCLUSIVE_FACTOR of a declared
round-off floor.
A replay records each step’s norm and warns when ControlRecord.met_tolerance is false.
StepDriver._replay_to, AdamsMarch.replay_step and StepHistory
(solprop.backends.protocol) all refuse a non-finite norm; 0.0 passes all three.
StepperSettings refuses interpolate_planes beside a schedule, step_planes works
under replay (ADR-039), and a replay resumes at any edge (StepDriver._schedule_index_at).
What the backends allocate, whatever the integrator. NumbaCpuBackend._allocate
takes every tableau stage row, both accumulators, the accept buffer, the recycled stage
and a propagator pair. Kokkos’s GeneralWorkspace::allocate takes stages_,
solution_, error_, residual_, recycled_, advanced_ and whole_propagator_.
numpy_ref forms stages per step (none for Adams), but its ledger
(reference_base_memory) counts them for every problem. ADR-009’s “never materialised beyond what the next stage needs” is therefore not what
the backends do. DOPRI5’s Butcher matrix is full lower triangular, and dense output
(ADR-039) reads every stage.
A third kind is a seam change. Every site that reads the integrator assumes that a
non-None value is a Multistep:
Construction.
NumpyRefBackend.__init__,NumbaCpuBackend._adams_marchand solprop-kokkos’sKokkosBackend.__init__each build anAdamsMarchfrom it.NumbaCpuBackend._fsal_readytests it againstNone.Admission. Each backend’s
_refuse_adams(numpy_ref,numba_cpuand solprop-kokkos) readsintegrator.formulation.Validation.
KernelProblem._check_tdserefuses onlymethod is None, andKernelProblem._runge_kutta_adviceadvisesRungeKutta()for any non-Noneintegrator.Translation. The call in
solprop.driver.simulationmaps a non-RungeKuttato DOPRI5 and a non-Multistepto no integrator. Only theSimulationSettingstype check stops a third kind from running silently as DOPRI5.
Tableau checks that the error weights sum to zero and that 0 < embedded_order < order, so all-zero error weights pass, with a norm that is always zero.
An independent reference. solprop.driver.scaffold.propagate_fixed_rk4 is a
fixed-step RK4IP in Hult’s form [2]. It builds the mode-averaged GNLSE’s Kerr term from
the physics layer and shares nothing with the reference passes.
tests/backends/test_numpy_ref_soliton.py::test_the_reference_backend_and_the_scaffold_agree
uses it to check IP-DOPRI5 at the level of truncation error.
4. Theory#
4.1 Equation, flows and conventions#
SI units and the internal FFT convention of conventions §2 apply. The state \(\tilde A(z)\) is the stored spectrum: square root of watts on the modal routes, volts per metre on the free kinds. It obeys
with \(D\) complex and diagonal per bin. On the free kinds \(\beta\) is each column’s
longitudinal wavenumber, assembled per element from one-dimensional arrays (the
separable representation, stepper.md §4).
\(F\) is the complete source assembly (SEP 16 §4.1): the response, the in-stage absorbers,
and the modified noise draw \(\eta(z)\) added to the stage input at its position. \(z\) and
\(h\) are in metres, \(D\) in m⁻¹ and \(F\) in state units per metre. With
\(\Phi(z)=\int_0^zD\,dz'\) and \(U(z_2,z_1)=\exp(\Phi(z_2)-\Phi(z_1))\), diagonal values
commute. \(U\) is therefore the exact linear flow over any interval, and
\(U(z_3,z_2)\,U(z_2,z_1)=U(z_3,z_1)\) (stepper.md §1).
4.2 The midpoint interaction picture and RK4#
For a step \([z_n,z_n+h]\) let \(z_m=z_n+h/2\), \(P=U(z_m,z_n)\) and \(Q=U(z_n+h,z_m)\). On a constant operator, which includes every free kind, \(P=Q=e^{hD/2}\). Lawson’s change of variable [1], anchored at the midpoint, is
Classical RK4 [8, §II.1] from \(u_n=P\tilde A_n\), with \(U(z_m,z_m)=1\) at the midpoint stages, gives
The fourth slope in the picture is \(g_4=Q^{-1}F_4\), and the final \(Q\) cancels that inverse. The step applies four forward flows per element and no inverse: \(P\) to the state and to \(F_1\), \(Q\) to \(u_n+hF_3\) and to the sum. This is Hult’s RK4IP [2] and the scaffold’s stencil.
Anchored at \(z_n\), as solprop’s Runge–Kutta march is, the three later stages each take a forward flow on their input and an inverse on their output, and the final flow makes seven. The final flow cancels \(k_4\)’s inverse, leaving six applications, two of them inverse flows. An inverse flow multiplies a damped bin by \(e^{+\alpha c_ih/2}\); the midpoint form never does. The source is evaluated four times a step. No slope carries over, because \(F_4\) is taken at a predicted state, not at \(\tilde A_{n+1}\).
4.3 The register form#
\(A\), \(B\) and \(C\) are field-sized registers. \(S\) is the pipeline’s scratch: the transverse scratch on the free kinds, or the fine spectral buffer on the modal routes. The evaluation reads its input from \(S\) and leaves the source there, in frequency. A step is four passes:
pass |
input formed in \(S\) |
evaluation |
finish, per element |
|---|---|---|---|
1 |
\(A+\eta(z_n)\) |
\(F_1\) at \(z_n\) |
\(A\leftarrow PA\); \(g\leftarrow PS\); \(B\leftarrow A+\tfrac h6g\); \(C\leftarrow A+\tfrac h2g\) |
2 |
\(C+\eta(z_m)\) |
\(F_2\) at \(z_m\) |
\(B\leftarrow B+\tfrac h3S\); \(C\leftarrow A+\tfrac h2S\) |
3 |
\(C+\eta(z_m)\) |
\(F_3\) at \(z_m\) |
\(B\leftarrow B+\tfrac h3S\); \(C\leftarrow Q(A+hS)\) |
4 |
\(C+\eta(z_n+h)\) |
\(F_4\) at \(z_n+h\) |
\(A\leftarrow QB+\tfrac h6S\) |
After pass 1, \(A\) holds \(u_n\) and \(B\) accumulates the bracket of \(\tilde A_{n+1}\). \(C\)
holds the next stage’s lab-frame input, which at the midpoint equals the
interaction-picture value. Pass 4 leaves \(\tilde A_{n+1}\) in \(A\). The march then applies
_finish (the hard band mask, and the split-step collar where chosen) and moves the
position in one commit. The noise draw’s phase \(\exp(i\operatorname{Im}\Phi(z))\) is formed
in the same pass, so no rotated copy is stored.
The flows: tabulated or recomputed. On a constant operator \(P=Q=e^{hD/2}\) depends only on \(h\), and on a fixed schedule it repeats from step to step; Hult’s scheme uses this one factor [2]. Recomputing it costs a complex square root and an exponential per element, a fifth of a stage in §2’s profile. The march offers both treatments.
Tabulated. One volume holds \(e^{h_\mathrm{t}D/2}\), built from the first step that needs it after a load. It is reused while \(|h-h_\mathrm{t}|\le\)
LANDING_ULPS\(\cdot\mathrm{ulp}(h_\mathrm{t})\) and rebuilt otherwise.The slack.
FixedSchedule.uniformtakes a segment’s steps fromnp.diff(np.linspace(...)), which differ in their last bits, so an exact key would miss. Within the slack the phase error is below \(8\varepsilon|D|h/2\) per element and step.Other steps. A discovered schedule whose steps all differ rebuilds the table every step, which costs more than recomputing.
Resume. A resume may build its table from a different step of the segment than the uninterrupted run did. It therefore agrees with that run to rounding (§4.5), not bit for bit, as an Adams resume without a snapshot does.
Recomputed. The flow is formed per element in each finish from the operator’s 1-D arrays, as the Kokkos free-kind stage input forms its propagator. No volume is held.
The table pays on a CPU. On a device an extra field read costs more than the exponential. A varying operator (\(P\ne Q\), both changing per step) forms one or two flow volumes per step, on routes whose \(F\) is small.
The count is three registers and \(S\), plus the table when used. Each input reads one register, where an IP-DOPRI5 stage reads up to five earlier rows (the nonzero entries of its Butcher row), and no accumulator or accept buffer is needed, since the march never rejects.
Two evaluations per pass. Under an in-stage absorber the free kinds’ compiled stages
evaluate twice per stage: the material response, then the absorber term, each from the
same input re-formed in \(S\). Today they keep the material output in a stage row
meanwhile (NumbaCpuBackend._stage_free, the Kokkos free-kind stage). The march needs no
such row. Between the two evaluations one per-element exchange takes the material output
\(t\) out of \(S\), re-forms the input, and folds \(t\) into the registers. In pass 2:
After the absorber term lands in \(S\), the finish is \(C\leftarrow C+\tfrac h2S\) and \(B\leftarrow B+\tfrac h3S\). The other passes adapt it:
Pass 3 is the same, with \(C\leftarrow A+ht\) in the exchange and \(C\leftarrow Q(C+hS)\) in the finish.
Pass 1 re-forms \(A+\eta(z_n)\) first. It then sets \(A\leftarrow PA\) in the exchange and folds \(Pt\), then \(PS\).
Pass 4 sets \(A\leftarrow QB+\tfrac h6t\), then \(A\leftarrow A+\tfrac h6S\).
\(t\) is a per-element temporary, so the collar case keeps three registers and \(S\) and needs no fifth volume. A collar whose temporal rates are all zero crosses the seam as none once SEP 22’s phase 1 lands, so a spectral-only run makes one evaluation per pass; SEP 22 §3 measured the second evaluation at about a factor of two per step on the Runge–Kutta march. A pipeline that reads its input once can instead write the next input straight into \(S\) (for example \(S\leftarrow A+\tfrac h2S+\eta(z_m)\)), and then needs no \(C\) (Q4). Ketcheson’s counts [6] assume a right-hand side added into its own register. solprop’s evaluation transforms through a field-sized scratch, so every published count gains \(S\) here.
4.4 Order, and its range of validity#
Because \(D\) is diagonal, the change of variable is exact for any step, \(QP=U(z_n+h,z_n)\), and \(g\) is as smooth as \(F\) and \(\Phi\). RK4 therefore keeps its classical order four [1; 8, §II.2]: the global error is \(O(h^4)\) as \(h\to0\) on a fixed grid, with a constant set by the derivatives of \(g\). Under a coupled flow the half-step flows are not exact exponentials of commuting values, and their error lies outside the Runge–Kutta order; refused.
The constant is the interaction picture’s known weakness. Each \(z\)-derivative of \(U(z_m,z)\) brings a factor \(D\). In a driven bin, whose source does not follow the bin’s free phase, each derivative of \(g\) grows like \(|\operatorname{Im}D|\). The asymptotic range therefore starts only where \(|\operatorname{Im}D|\,h/2\lesssim1\) in populated driven bins (the primer’s chapter 12; SEP 15 §4). Beyond it the method converges, but its observed order can fall, as for Lawson-type methods on stiff problems [9, §2.7].
SEP 14 (phase 6) measured IP-DOPRI5 at order four, not five, on a soliton under fixed
steps. Empty high-\(k_\perp\) bins are not driven and do not limit the order. The schedule
lands on every hard kink (FixedSchedule.uniform, checked by KernelProblem.validate),
and \(\Phi\) is smooth inside a table interval.
4.5 The linear limit and invariants#
With no source the step is \(\tilde A_{n+1}=QP\tilde A_n\): the exact flow, with two rounded complex products per element instead of one. Over \(N\) steps the products contribute about \(4N\varepsilon\) per element, relative. The arguments are rounded too. Each \(e^{hD/2}\), and the reference \(\exp(\Phi(z_N)-\Phi(z_0))\), carries about \(\varepsilon|\Delta\Phi|\), and differencing the cumulative table adds about \(\varepsilon|\Phi(z)|\). The test’s tolerance is therefore about \(\varepsilon\,(4N+c\,(N|D|h+|\Phi(z_N)|))\) per element, with \(c\) a small constant. The argument term dominates in strongly dispersive bins: with 200 steps at \(|\operatorname{Im}D|h/2=300\) the measured difference was 655 times \(4N\varepsilon\). The hard band mask is a no-op, because the prefactor is exactly zero outside the active band, and the propagating field never contains the noise draw (ADR-008).
4.6 Stability of explicitly integrated damping#
Damping inside \(F\) — the in-stage temporal and spatial collars, plasma loss — is
explicit. RK4’s stability function is \(R(x)=1+x+x^2/2+x^3/6+x^4/24\). On the negative real
axis \(|R|\le1\) for \(x\ge-2.785\), the root of \(|R|=1\); the imaginary-axis interval is
\(2\sqrt2\). A replayed step must keep \(h\alpha_\mathrm{max}/2\le2.785\), against 3.3 for
DOPRI5 (solprop.boundaries.STABILITY_RADIUS) and 0.0877 for PEC5/6
(PEC_REAL_INTERVAL). KernelProblem._stability_radius and
_check_spatial_collar_stability take the new interval. With the default temporal
collar’s strength (\(AN = 252\), SEP 21 §4.3) the
interval sets a floor of about 45 steps over a run, against 38 for DOPRI5. SEP 21’s
implicit collar, if built, is for the multistep march; this march refuses
absorber_scheme="implicit" as the Runge–Kutta march does.
4.7 No local estimate: control from outside#
The march’s accuracy is its schedule’s, measured on the observable \(Q_w[\tilde A]\). In
the asymptotic range \(Q_w(h)=Q_w^\star+c_wh^p+O(h^{p+1})\), with \(c_w\) unrelated to any
whole-field constant. Nested replays at \(h\), \(h/2\) and \(h/4\) give the differences \(d_1\)
and \(d_2\). From them come \(p_\mathrm{obs}=\log_2(d_1/d_2)\) and the base error
\(e(h)\approx d_1/(1-2^{-p_\mathrm{obs}})\). This is calibrate with refinement two, whose
gate refuses \(p_\mathrm{obs}>7\); replay then rescales to a target.
For a weak observable the round-off floor matters. The strong field’s rounding,
\(O(\varepsilon\|\tilde A\|)\) per operation, leaks into the band and sets the floor that
calibrate(floor=...) needs. The parity difference between two backends on one schedule
is pure rounding and measures that floor.
4.8 Low-storage families for adaptive use#
Williamson’s 2N schemes [3] update two registers, \(\Delta\leftarrow a_i\Delta+hg_i\) and \(u\leftarrow u+b_i\Delta\); Carpenter and Kennedy’s fourth-order 2N schemes have five stages [4]. Kennedy, Carpenter and Lewis [5] give 2R and 3R schemes with embedded estimators, among them five-stage fourth-order pairs labelled RK4(3)5[2R+]. Ketcheson’s 2S* methods [6] keep the previous step in one of two registers, so a step can be restarted, and his 3S* methods add an embedded estimate in a third; such pairs now have step-size controllers for method-of-lines problems [10]. Balac and Mahé’s ERK4(3)-IP [7] is an embedded 4(3) pair that keeps RK4IP’s nodes and midpoint structure. In the interaction picture a stage at node \(c_i\) needs the flow to \(c_i\) forward on its input and back on its output. Only nodes at 0, ½ and 1 are inverse-free. An adaptive march also keeps the accepted state until the decision: one register beyond any estimate’s.
5. Proposed interface#
solprop.stepper.low_storage.LowStorageRungeKutta, exported from solprop.stepper, is a
frozen dataclass with one field, scheme: Literal["rk4ip"] = "rk4ip"; phase 4 may add a
scheme. Its name is "low-storage-rk4ip", its representation
LowStorageRungeKutta(scheme='rk4ip') and its real_stability_interval 2.785. The name
parallels RungeKutta. InteractionRK4 was rejected because it names one scheme, and
LowStorage because it says nothing of what is stored.
from solprop.driver import SimulationSettings, prop_free
from solprop.stepper import (FixedSchedule, LowStorageRungeKutta, PIController,
calibrate, replay)
planes = [0.01, 0.02, 0.03]
schedule = FixedSchedule.uniform(0.0, 0.03, 2e-4, save_planes=planes)
settings = SimulationSettings(
controller=PIController(rtol=1e-6, atol=1e-12), # recorded; see below
save_planes=planes,
dz_max=2e-4, # at least the schedule's longest step
dz_init=2e-4, # informational under a replay
integrator=LowStorageRungeKutta(), # RK4 in the interaction picture, 3 registers
schedule=schedule, # required: the march replays only
backend="numba_cpu",
)
# Calibrate on the measured quantity, not on rtol:
calibration = calibrate(schedule, weak_band_energy, order=4,
requested=1e-3, floor=parity_floor)
production = replay(schedule, calibration, target_error)
Admitted: a schedule, which is required; every route whose diagonal-flow pipeline the
backend executes (modal guides, the radial, Cartesian, line and hybrid planar kinds,
constant general guides); field and envelope grids; the in-stage collars within §4.6’s
interval, the split-step collar (first order where it acts, ADR-025) and the spectral
collar; the modified noise (§6.3); step_planes, statistics between planes and scans;
and lockstep batches where admitted, each member owning its march. A resume at a schedule
edge needs no history and no recycled stage. Because the flow table may be rebuilt from
a different step (§4.3), the resumed run agrees with the uninterrupted one to rounding,
within §4.5’s bound including its argument term, not bit for bit. SEP 14 gave up a
bitwise resume, and the Adams march does not provide one either. backend="auto" resolves
to numpy_ref until phase 2, then per problem.
The required settings keep their meaning. SimulationSettings still requires
controller, save_planes, dz_max and dz_init.
dz_maxanddz_initare informational under a replay. They are recorded inStepLimits, and the translation refuses a schedule whose longest step exceedsdz_max, so the recorded limit is true of the run.The controller’s
rtolfeeds onlyControlRecord’s requested tolerance and the spatial collar diagnostic’s threshold (StepDriver.observe). No step is controlled.
The propagator mode. SimulationSettings.propagator_mode picks §4.3’s treatment.
Under this march the free and planar kinds admit "tabulate", meaning the single volume
of \(e^{hD/2}\). They keep refusing it under the Runge–Kutta march, whose six distinct
fractions would need twelve volumes. "auto" resolves to "tabulate" on the CPU
backends for a constant operator, and to "recompute" on a device or a varying operator.
The choice is recorded in the backend metadata. The modes agree to rounding, not
bitwise.
Refused by name, before allocation, by a new _admit_low_storage beside
_admit_integrator and by StepperSettings.__post_init__: no schedule (“the low-storage
march replays a prescribed schedule; pass schedule=FixedSchedule.uniform(...), or step
adaptively with RungeKutta()”); interpolate_planes, which a schedule already refuses;
any coupled flow; the TDSE response; a step past §4.6’s interval; SEP 21’s "implicit"
collar scheme should it land; and a backend that does not yet execute the march, naming
one that does.
Records. StepperRecord.method is the representation above (the next free output
schema at landing, 19 at this writing), and
tableau keeps the unused DOPRI5, as for Multistep. The step history’s error_norm is
0.0 on every step, meaning “not estimated” (§6.3). ControlRecord.met_tolerance and
StepSummary.met_tolerance are then vacuously true, and the “too coarse” warning cannot
fire. The log lines that print a tolerance ratio say “not estimated (replayed schedule)”
instead (§6.3).
Source evaluations (four a step) go in the backend metadata under a physical key of
their own, source_evaluations, never under response_calls. driver.run._record_execution
saves response_calls as logical calls. On resume, Simulation hands those to
StepDriver.restore_call_ledgers, which raises “logical response calls require an Adams
run” on any backend without an Adams march. Keeping the count out of response_calls
leaves the logical ledger empty, so the restore path passes.
Tableaux. Tableau refuses an all-zero error-weight vector. Under the adaptive march
such a tableau scores every step zero and grows the step at its cap without end
(stepper.md §10, trap 11). A scheme without an estimate belongs to this
march, which takes no tableau. No shipped tableau is affected.
6. Implementation strategies#
6.1 Layers#
The specification goes in the stepper layer (solprop/stepper/low_storage.py). The kind
and its refusals enter translation (StepperSettings, KernelProblem.validate) and the
driver (settings, admission, records). The march and host engine go in
solprop/backends/low_storage.py, with compiled register passes in each backend. No
physics changes (architecture §3).
6.2 The march#
LowStorageMarch follows AdamsMarch: it owns its registers through an engine, and it
replays only. Its replay_to and replay_step mirror AdamsMarch._replay_to and
replay_step. Each step calls the engine’s step(z, dz), applies _finish, moves the
position inside committing(), and calls the driver’s _record, _take_step_stats and
_natural_stop, and after the commit the accepted-endpoint hook SEP 21’s phase 2 adds
for its accumulators, if that lands first. StepDriver.propagate_to gains one branch: a backend holding a march in
the protected attribute _low_storage, set at construction as _adams is, replays
through it. snapshot() returns None, as under RungeKutta().
Since the march never rejects, it writes the accepted state before the step completes. For a replay only, this relaxes the adaptive rule that a trial leaves the accepted state alone until the commit. The relaxation needs one guard, and it moves one check:
A step abandoned in flight. An exception or signal between pass 1 and the commit leaves the state holding neither \(\tilde A_n\) nor \(\tilde A_{n+1}\). The march marks the backend in flight from its first write to the commit (a protected flag beside
_low_storage).Where the guard reaches. Every entry that reads or advances the state refuses an in-flight backend with a
BackendErrornaming the step. That covers the driver’sobserve,propagate_toandmarch_snapshot, and each backend’sstats, through a driver check every override calls. A secondpropagate_toafter a caught exception must not march on from a corrupt state.LockstepRunnerchecks each member in its replay loop, itsstatsandobserve_members. Onlyset_initialclears the flag.What a failure writes. The run loop’s failure path (
solprop.driver.run) observes the backend to write its terminal checkpoint, so it writes none and says why. The file ends at its last plane, from which a resume continues to rounding, andstep_planes=kbounds the loss to \(k\) steps.The alternatives (Q6). Holding signals for the whole step keeps the checkpoint, but defers a scheduler’s stop by up to a step (tens of seconds at the target size on a CPU) and does nothing for an exception. A fourth register keeping the state costs 1 F.
Divergence is detected on the state. Pass 4 reduces \(\|\tilde A_{n+1}\|^2\) over the norm mask, and a non-finite value raises
ConvergenceErrorwith the replay’s message. That error is raised in flight, so it too loses the terminal checkpoint. Today’s replay keeps it, becauseStepDriver._replay_tochecks the norm before it writes the state. This is a deliberate cost of accepting in place, and the manual says so.
6.3 The error norm on a replay, and the noise#
option |
cost |
what the norm column then means |
verdict |
|---|---|---|---|
0.0 |
none |
“not estimated”; passes |
recommended, with §6.2’s state check |
increment proxy, such as \(|\tfrac h6F_4|/|\tilde A|\) |
one reduction |
the nonlinear change per step, \(O(h)\), not the local error \(O(h^5)\); no scaling makes “≤ 1” mean “met the tolerance” |
rejected |
stiffness indicator \(h\rho=2|F_3-F_2|/|F_2-g_1|\) |
two reductions; \(g_1\), \(F_2\) recovered from \(C\) and \(A\) at no memory, with cancellation where the increment is small |
an estimate of \(\lvert h\lambda\rvert\) for the dominant linearised source eigenvalue, which §4.6 bounds |
not an error norm; logged if it tracks the collar’s and plasma’s margins (Q1) |
NaN as “absent” |
relaxing |
conflates absence with divergence |
rejected |
The history’s five columns, ControlRecord and StepSummary keep their forms, and the
output schema bump documents the 0.0. Two log lines print the ratio as a number today:
the closing information line of solprop.log’s reporter (“achieved/requested tolerance
ratio”) and the driver’s debug line after each propagate_to. Under this march both
print “not estimated (replayed schedule)” instead of 0.0.
The modified noise is admitted. The draw is one counted volume, as for every march.
Its phase is formed per element in the stage-input pass. Kokkos’s free-kind stage input
already does this. numba_cpu currently rotates the draw into its exponent volume and
fuses the rotation in phase 2. A backend that would need a stored rotated copy on a free
kind refuses the noise by name (Q3).
6.4 The seam#
Phase 1 bumps SCHEMA_VERSION to the next free version (29 at this writing; SEP 19 took
27 and SEP 25 took 28, which added _stage_bundles to DRIVER_SURFACE):
StepperSettings.integratorbecomesMultistep | LowStorageRungeKutta | None. Widening the field, rather than adding one, makes a backend that misses the kind fail loudly where it builds itsAdamsMarch, instead of stepping the tableau silently. Every site of §3’s inventory then tests forMultistepexplicitly.The three
_refuse_adamsfunctions are reached only for aMultistep._check_tdserefuses anything but aMultistep, and_runge_kutta_advicespeaks only for one.Once the sites test for
Multistep, anumba_cpu(and a solprop-kokkos) backend that has no march of its own must refuse the new kind by name in phases 1 and 2. A non-Multistepintegrator would otherwise read asNoneand run DOPRI5 silently.
Two protected attributes,
_low_storageand the in-flight flag, joinDRIVER_ATTRIBUTESintests/backends/test_driver.py.StepDriver._low_storage_engine(method) -> objectjoinsDRIVER_SURFACE(tests/backends/test_driver.py::test_the_surface_a_backend_subclasses_is_frozen).Nonetakes the host engine.The engine protocol,
LowStorageEngine, is part of the seam:step(z, dz)returns the accepted state’s squared norm, andreset()clears the engine. A later change to it bumps the version again.
The host engine reuses _adams_lab_source and _adams_propagator, already generic in
meaning; renaming them would touch solprop-kokkos and SEP 12’s adapter for no behaviour
(Q2). solprop-kokkos follows the version in phase 1, refusing the kind, and implements it
behind the hook in phase 3.
6.5 numpy_ref (phase 1)#
The host engine is the oracle. \(A\) is the state array, updated in place, and \(B\) and \(C\)
are host arrays. Each pass calls _adams_lab_source once and takes \(P\) and \(Q\) from
_adams_propagator or the table, allocating transients as the reference always does.
The step’s three distinct stage positions are known before its first pass, so the engine
takes their host scalars together through StepDriver._stage_bundles (SEP 25 §6.3), and
the hooks’ own _stage_scalars calls then return the kept bundles.
reference_base_memory becomes method-aware: it counts the registers and the table beside
the pipeline rows, and no stage or accumulator rows. LockstepRunner._replay_to dispatches
to each member’s replay_step, as for Adams.
6.6 numba_cpu (phase 2)#
With the march selected, _allocate creates no stage rows, accumulators, accept buffer,
recycled stage, propagator pair or exponent. Each route’s stage splits into three parts:
its input formation (the combine kernel, or _stage_free’s input), the shared core
(transforms, responses, chunk loop) and its finish. The march adds register versions of
the input, which fuses the noise rotation, and of §4.3’s finish, which takes \(P\) and \(Q\)
from the table or from the separable arrays. FFTW runs in place on the transverse scratch.
§2’s profile says where the time is to be saved:
one register read per input, instead of up to five rows combined;
the propagator formed once while the step length holds, or fused into the finish, instead of once per stage;
no accumulators and no per-trial copy.
What remains is the transform pairs and the response. The acceptance target is §7.4’s
in-tree ratio to IP-DOPRI5. The drafting prototype is not a target. Fused kernels agree
across thread counts within a rounding bound fixed before measurement
(SEP 25), with two-stage reductions of
fixed shape. The ledger names the registers, and estimate_memory reproduces them to the
byte. Admission then uses this backend’s own count rather than the reference’s, whose
base rows are 0.975–1.046 times numba_cpu’s (module docstring of
solprop.backends.memory). _admit_history’s refusal also names the march when its
estimate fits.
6.7 Kokkos CPU (phase 3)#
KokkosBackend._low_storage_engine returns a resident engine whose step is one native
call. It makes four register passes and accepts in place. Each pass forms the input in
transverse_ (or the modal fine buffer), transforms in place, runs the existing chunked
core, transforms back, and updates \(A\) (current_), \(B\) and \(C\).
With the march selected, GeneralWorkspace::allocate creates no stages_, solution_,
error_, residual_, recycled_, advanced_ or whole_propagator_. The CPU build
tabulates by default. A device forms the flow per element, as separable_stage_input
forms its propagator. Kokkos CPU times within 2 % of numba_cpu on §2’s case, so the same
targets apply.
A signal does not interrupt the native call, but its handler runs at the first bytecode
after the call returns (solprop._interrupts), which is before the commit. So the
native step, _finish and the position move sit in one committing() block. That costs
nothing on Kokkos, since the signal could not be delivered during the call anyway, and it
leaves only a native exception able to abandon a step in flight. The ledger and estimate
agree to the byte, and scripts/check.sh passes. A device build
compiles the same passes, one call per step with no host synchronisation inside
(stepper.md §8); its qualification is separate (Q7).
6.8 Memory, time and the flow table#
backend |
registers |
scratch |
flow table |
noise |
total on the free kinds |
|---|---|---|---|---|---|
Kokkos CPU, |
\(A\), \(B\), \(C\) |
transverse |
+1 by default |
+1 |
5 F tabulated, 4 F recomputed; +1 with noise, −1 with Q4’s elision |
Kokkos device |
\(A\), \(B\), \(C\) |
|
recomputed |
+1 |
4 F |
|
\(A\), \(B\), \(C\) |
transverse |
+1 |
+1 |
5 F counted, plus per-call transients |
Node-chunk buffers stay bounded by the node budget (64 MiB by default, free-space model). At \(768\times768\times600\) the march holds 26.4 GiB tabulated or 21.1 GiB recomputed, plus 5.3 GiB with noise. That compares with 79 or 84 GiB for the Runge–Kutta march and 232 GiB for Adams on Kokkos. At \(1536\times1536\times600\) the march holds 84–105 GiB against 316 GiB.
A step makes four source evaluations against IP-DOPRI5’s six with FSAL (seven on
numpy_ref), applies four flows from one table against six formed per stage, and reads
one register per input against combinations of up to five rows. §2’s drafting
measurement suggests factors of five or more per evaluation. The acceptance target is
§7.4’s in-tree ratio: at least 3 times less time per evaluation and at least 4 times less
per step. Fourth order needs more steps than fifth asymptotically, but DOPRI5 measures
about fourth order at fixed step on stiff problems (§4.4). The ranking at equal
Richardson error is therefore measured, not assumed (§7.4). No new dependency is
proposed.
7. Verification and validation#
7.1 Numerical contract (phase 1 on numpy_ref, repeated per backend)#
Linear limit. With no response, the march must equal \(U(z_N,z_0)\tilde A_0\) within §4.5’s bound, \(\varepsilon\,(4N+c\,(N|D|h+|\Phi(z_N)|))\) per element. Cases: a constant modal operator, P2’s analytic varying operator (stepper.md §12, where \(P\ne Q\)) and the Cartesian operator. The Cartesian case reproduces the width and Gouy phase of
tests/backends/test_free_diffraction.py::test_cartesian_gaussian_width_gouy_exact_diffraction.Order four. Three-level
calibrateladders on the third-order soliton with its phase (P1), on P2, and on a 10–40-step Cartesian diffraction-and-Kerr case on the grids oftests/backends/test_free_ladders.py::test_cartesian_nonlinear_ladder.Steps. The ladder’s steps are chosen inside the asymptotic range of §4.4, \(|\operatorname{Im}D|\,h/2\lesssim1\) in the populated driven bins. The test states that bound for its steps, computed from the grid and the populated band.
Comparison. The same ladder is run with IP-DOPRI5. The march must read four within the spread IP-DOPRI5’s observed order shows about its own value on that ladder (at most 0.3), above the round-off floor.
The soliton is also compared with its analytic error.
The independent RK4IP.
numpy_refmultiplies the source bygrids.omega_window(passes.stage_output); the scaffold does not. The existing test also agrees only to 1.7e-3 of the scaffold’s truncation error. “Rounding only” therefore has to be established first:One evaluation. With the window all ones (the default), one source evaluation on
numpy_refand one in the scaffold must agree within the pass’s summation-length bound.Whole runs. The bound on whole runs is derived from that measured per-evaluation difference times the step count, and must sit orders below the truncation error the same test reports. The two are not bitwise equal: accumulation and source composition differ.
Lifecycle and refusals.
step_planesplanes and a lockstep batch must match the uninterrupted run, or the batch’s members run alone, on the same edges (exact) and within a rounding bound fixed before measurement (ADR-013’s batch identity, as SEP 25 restates it). A resume at a schedule edge, including a mid-segment edge under the table, must agree with the uninterrupted run within §4.5’s rounding bound, as SEP 16 qualified the Adams resume. Each refusal of §5 must fire before allocation and name its remedy. An exception injected between passes must leaveobserve,propagate_to,statsandmarch_snapshotrefusing, the file ending at its last plane, and the resume within the rounding bound. A run with an in-stage collar must hold no row beyond three registers, \(S\) and the table (§4.3’s exchange).Noise. With the draw and no response the state must be the pure flow; with Kerr the stage inputs must carry the draw at \(z_n\), \(z_m\) and \(z_n+h\), against a hand-formed one.
7.2 Parity (phases 2 and 3)#
numba_cpu and Kokkos CPU against numpy_ref, on one pass, one step and ten steps. Cases:
the soliton, a varying modal case with Raman and plasma, and Cartesian and line Kerr cases
with the in-stage collar and noise. Deviations are peak-normalised and bounded by the
summation length (testing §3). Repeats and runs at different thread
counts must agree within a rounding bound fixed before measurement.
7.3 Memory#
Each ledger must name the registers and the table. On a Cartesian case its field-sized
rows must total at most 5 F tabulated or 4 F recomputed, one more with noise. The stage,
accumulator, accept and recycled rows must be zero, and the propagator row too when
recomputing. estimate_memory must equal the ledger to the byte, as
tests/backends/test_kokkos_dispersive_routes.py::test_the_estimate_is_the_ledger_to_the_byte
checks for its own rows. A run that _admit_history refuses under Adams, but that fits
under the march, must be admitted with it.
7.4 Benchmarks#
Ten-step windows on numba_cpu and Kokkos CPU, against IP-DOPRI5 replaying the same
schedule, under the ledger’s rules (benchmarks §2: machine record,
quiet machine, same-process A/B comparisons labelled):
At equal step. On §2’s case (\(192\times192\times300\), Kerr, no collar, 0.5 µm) and on a larger Cartesian Kerr beam. Each run records time per source evaluation and per step, for both flow treatments, with the peak ledger, on one thread and on all threads.
The acceptance target is in-tree. On one thread of a quiet machine, at equal step, the march takes at least 3 times less time per source evaluation, and at least 4 times less per step, than IP-DOPRI5 on the same backend. §2’s drafting measurement motivates the target and does not count towards it.
At equal Richardson error.
calibrateruns both methods on one window.replaytakes each to a common target on the weak observable and on the field. The report gives steps per metre times time per step.A stage profile (input, transforms, response, finish, flow), beside §2’s DOPRI5 breakdown.
Points below the reference floor are not ranked. Where IP-DOPRI5 wins in time, the result is documented, and the memory saving still stands.
8. Documentation#
Choosing a stepper. When to take
LowStorageRungeKutta(), what it costs and what it refuses. Its memory table gains the march’s row, and its free-kind figures are corrected: 14, 30 and 43 copies against the ledgers’ 15 or 16, 32 and 44.Fixed schedules. Replaying with the march; calibrating on the observable rather than
rtol; a weak observable’s floor from backend parity;calibrate’srequestedfor a uniform schedule; the order gate at four.Elsewhere: the solver (three integrators), stepper.md §2 and §8, the kernel contract §7 and schema history, backends §8–§9, architecture §8; an ADR in phase 1 (replay-only, accept in place, no estimate, the 0.0 norm, the in-flight guard); docstrings; and a
CHANGELOG.mdline citing this SEP at Final.A tutorial in
docs/tutorials/free_space/. A line or small Cartesian run with a weak band under a strong pump: a uniform schedule, a three-levelcalibratewith a parity floor,replayto a target, and the whole-field norm’s blindness beside it, in seconds.
9. Compatibility and impact#
No default changes, and the existing marches are untouched; existing runs take the same
code path as before (an exact no-op contract), since backends allocate their usual buffers
unless the new kind is selected.
In phase 1 the kernel schema moves to its next free version (28 → 29 at this writing),
with DRIVER_SURFACE and its literal test, and solprop-kokkos follows on the same
revision. The output schema moves to its next free version (18 → 19 at this writing):
StepperRecord.method may name the march, whose runs record 0.0 norms meaning “not
estimated”, which older records never do. Tableau refuses all-zero error weights (no
shipped tableau has them), and the free and planar kinds’ refusal of "tabulate" is
lifted for this march only. A resumed run of the march agrees with the uninterrupted one
to rounding, not bit for bit (§5), the same resume semantics SEP 14 and the Adams march
already have. Statements found stale while drafting are corrected in phase 1:
ADR-009 gets an amendment note on “never materialised beyond what the next stage needs” (§3).
The kernel contract’s §7
integratorrow still describes SEP 13’s interaction-picture method on mode sets and says it “cannot replay a schedule”.AdamsMarch._replay_toandreplay_stephave replayed since SEP 16.backends §9’s Adams row says “every route but a coupled flow” on the compiled backends and calls devices “not qualified”. The Adams limitations say otherwise: the exponential formulation runs beside constant-Schur and frozen-residual flows, and SEP 16 qualified devices.
solprop-kokkos’s
README.mdnames kernel schema 25, wherebuild_info()reports 28, and says the free-space routes refuse Adams.
10. Alternatives considered#
RK4 as a
Tableauunder the existing march. It takes Kokkos from 15 F to 12 F, still three times the target, and its zero error vector fails silently under adaptive control. Rejected and guarded against (§5).A low-storage Runge–Kutta march. DOPRI5’s matrix, embedded pair and dense output need the stage rows. Reorganising the default’s arithmetic would change every recorded state and require its recapture. Rejected.
Carpenter and Kennedy’s five-stage 2N scheme [4]. It needs three volumes (\(u\), \(\Delta\), \(S\)) whatever the passes, against RK4’s four under a two-pass pipeline. It costs 25 % more evaluations, and inverse flows at its inner nodes. Kept as a fallback (Q4).
The host march on every backend. On
numba_cputhe existing hooks write stage row 0, use the propagator volumes and return copies. On Kokkos every pass would cross the host–device boundary twice. Used onnumpy_refonly.An embedded pair at once. It needs rollback and an estimate register (5–6 F), and a qualification the replay use does not need. Deferred to phase 4.
Symmetric split-step. It takes two volumes but is second order, and it needs a closed-form nonlinear flow that shock and plasma terms lack (stepper.md §2). Rejected.
Lower-order 2N schemes [3]. They save a volume but need several times the steps for a weak observable. Rejected.
Exponential Runge–Kutta methods [9]. They suit driven bins, but need \(\varphi\)-function weights and more registers. Out of scope.
11. Phases#
Each phase merges independently with its tests, documentation and report. The full gate runs once per phase, and the end-of-SEP lanes once before Final.
Specification, seam and
numpy_ref. DeliversLowStorageRungeKutta;solprop.backends.low_storage(march, engine protocol, host engine); translation, admission, refusals and the in-flight guard; both flow treatments; the kernel schema bump, followed by solprop-kokkos with a refusal by name; the output schema bump; lockstep members; theTableauguard;numpy_ref’s ledger and estimate; the ADR, manual sections and tutorial; the §9 corrections. Verifies §7.1 and measures Q1’s indicator.numba_cpu. Delivers register kernels on every admitted route with no Runge–Kutta buffers; the table and the per-element flow; the fused noise rotation; the ledger and an exact estimate;backend="auto"per problem; the admission message. Verifies §7.2 (thread counts within a rounding bound), §7.3 and §7.4. Decides Q4.Kokkos CPU. Delivers §6.7’s resident engine with no Runge–Kutta views, its ledger and estimate, and a passing
scripts/check.sh. Verifies §7.2–§7.4 on the CPU build. Decides Q5.Optional: an embedded pair. Starts if phases 2 and 3 show the march competitive and an adaptive use is wanted. It chooses the pair (Q8), adds adaptive control on a fifth or sixth volume, and qualifies it against the adaptive Runge–Kutta march, as SEP 16 §7.3 qualified Adams.
12. Open questions#
Q1 — phase 1: the norm column. Record 0.0 (recommended); log or warn on §6.3’s stiffness indicator if it tracks the collar’s and plasma’s margins, never in the column.
Q2 — deferred: hook names. Rename
_adams_lab_sourceand_adams_propagatorwith the next seam change touching solprop-kokkos, not here. Destination: a roadmap item (Performance and backends), taken up by the next seam-changing SEP.Q3 — decided here, for review: the noise. Admitted, draw counted, phase formed per element; a backend that would store a rotated copy refuses it by name.
Q4 — phases 2–3: the stage register. Drop \(C\) when the pipeline reads its input once, at the cost of a second finish variant per route. If instead the fourth register binds under a collar, add Carpenter and Kennedy’s 2N scheme as a second
scheme.Q5 — phase 3: Kokkos’s stage. Can the separable stage, which writes
stages_,solution_anderror_, be split into shared input, core and finish without moving the Runge–Kutta arithmetic? If not, the march takes a parallel path sharing the chunked core.Q6 — phase 1: in flight. Choose between the invalid-state guard (recommended), holding signals per step, and a fourth register (
in_place=False, 1 F).Q7 — after phase 3: devices. Qualification follows backends’ standing policy, is not required for Final, and until done a device refuses the kind. Destination: the roadmap’s device-qualification items (Performance and backends), where a line for this march is added at Final.
Q8 — phase 4: the pair. Start with ERK4(3)-IP [7], which keeps the phase 1 stencil, kernels and checks, once its coefficients, stages and FSAL structure are confirmed from [7]. Take 2R [5] or 3S* [6, 10] if one register decides; their inner nodes need inverse flows and fresh qualification.
Q9 — beyond this SEP: transfer. Does a schedule calibrated on a coarser transverse grid transfer to the production grid? Part of stepper.md §9’s question.
13. References#
J. D. Lawson, “Generalized Runge–Kutta processes for stable systems with large Lipschitz constants,” SIAM Journal on Numerical Analysis 4, 372–380 (1967). doi:10.1137/0704033
J. Hult, “A fourth-order Runge–Kutta in the interaction picture method for simulating supercontinuum generation in optical fibers,” Journal of Lightwave Technology 25, 3770–3775 (2007). doi:10.1109/JLT.2007.909373
J. H. Williamson, “Low-storage Runge–Kutta schemes,” Journal of Computational Physics 35, 48–56 (1980). doi:10.1016/0021-9991(80)90033-9
M. H. Carpenter and C. A. Kennedy, “Fourth-order 2N-storage Runge–Kutta schemes,” NASA Technical Memorandum 109112, NASA Langley Research Center (1994). No DOI; NASA Technical Reports Server 19940028444.
C. A. Kennedy, M. H. Carpenter and R. M. Lewis, “Low-storage, explicit Runge–Kutta schemes for the compressible Navier–Stokes equations,” Applied Numerical Mathematics 35, 177–219 (2000). doi:10.1016/S0168-9274(99)00141-5
D. I. Ketcheson, “Runge–Kutta methods with minimum storage implementations,” Journal of Computational Physics 229, 1763–1773 (2010). doi:10.1016/j.jcp.2009.11.006
S. Balac and F. Mahé, “Embedded Runge–Kutta scheme for step-size control in the interaction picture method,” Computer Physics Communications 184, 1211–1219 (2013). doi:10.1016/j.cpc.2012.12.020
E. Hairer, S. P. Nørsett and G. Wanner, Solving Ordinary Differential Equations I: Nonstiff Problems, 2nd ed., Springer (1993), §§II.1–II.2. doi:10.1007/978-3-540-78862-1
M. Hochbruck and A. Ostermann, “Exponential integrators,” Acta Numerica 19, 209–286 (2010). doi:10.1017/S0962492910000048
H. Ranocha, L. Dalcin, M. Parsani and D. I. Ketcheson, “Optimized Runge–Kutta methods with automatic step size control for compressible computational fluid dynamics,” Communications on Applied Mathematics and Computation 4, 1191–1228 (2022). doi:10.1007/s42967-021-00159-w
[1, 2] give the transformation and stencil; [3–6, 10] the low-storage families (counts assume an in-place right-hand side, §4.3); [7] the IP pair; [8] RK4’s order; [9] the stiff-order caveat. None certifies the march on a given problem: §4.7’s calibration does.
Part II — Implementation record#
One report per phase, appended in the pull request that merges the phase (SEP 0 §8).
Phase 1 report — specification, seam and numpy_ref#
Scope: phase 1 of §11, 2026-10-18, Opus 5.5 (AI). Branch
feature/sep-0024-phase-1in solprop (frommain62b0250) and in solprop-kokkos (from itsmain57f688c); branch heads at the merge: the commit carrying this report (solprop) and 24f6a4e (solprop-kokkos), merged into bothmains on 2026-10-18.Delivered:
The specification:
src/solprop/stepper/low_storage.py(LowStorageRungeKutta,LOW_STORAGE_REAL_INTERVAL2.785), exported fromsolprop.stepper; theTableauguard against an all-zero error-weight vector (src/solprop/stepper/tableau.py).The march and host engine:
src/solprop/backends/low_storage.py(LowStorageMarch,LowStorageEngine,HostLowStorage).The seam:
src/solprop/backends/driver.py(_low_storage,_in_flight,_low_storage_engine, thepropagate_tobranch, the guard in_require_stateandmarch_snapshot,_stage_bundleskeyed by anchor and position, the “auto” propagator rule,source_evaluations, the “not estimated” debug line);src/solprop/translate/problem.py(kernelSCHEMA_VERSION29, the widenedStepperSettings.integrator,_check_low_storage,_check_integrator, the tabulate refusal lifted,_check_tdseand_runge_kutta_advicetesting forMultistep);src/solprop/translate/tdse_source.py,src/solprop/backends/exponential.py,src/solprop/backends/memory.py(method-awarereference_base_memory,adams_memoryand the estimate’s propagator rule),src/solprop/backends/numpy_ref/backend.py(construction,_refuse_adams),src/solprop/backends/numba_cpu/backend.py(refusal by name),src/solprop/backends/batch.py(lockstep dispatch); solprop-kokkossrc/bindings.cpp(schema 29),src/solprop_kokkos/backend.py(refusal by name),src/solprop_kokkos/__init__.py,README.md,CHANGELOG.md.Driver and records:
src/solprop/driver/simulation.py(_admit_low_storage,LOW_STORAGE_BACKENDS, the integrator mapping, widened annotations),src/solprop/driver/interface.py,src/solprop/driver/run.py(no checkpoint from a step in flight, the reporter’sestimated),src/solprop/log.py(NOT_ESTIMATED,estimated=),src/solprop/output/schema.py(outputSCHEMA_VERSION19),src/solprop/driver/scaffold.py(gnlse_source).Tests:
tests/backends/test_low_storage.py(§7.1 and §7.3 onnumpy_ref),tests/stepper/test_low_storage.py,tests/driver/test_low_storage_settings.py; solprop-kokkostests/test_backend.py::test_the_low_storage_march_is_refused_by_name; the frozen literals oftests/backends/test_driver.py; schema pins rewritten intests/translate/test_problem.py,tests/translate/test_dispersive_kerr_terms.py,tests/backends/test_mixture_channels.py,tests/backends/test_numpy_ref_backend.py,tests/test_display.pyand sixteen output, driver and processing modules (output schema 18 → 19);tests/bounds.py(step_evaluationsmethod-aware); solprop-kokkostests/test_api_contract.py.Documentation:
docs/guide/numerics/choosing-a-stepper.md(the march’s section, the memory table, the free-kind figures corrected to 15 or 16, 32 and 44),docs/guide/numerics/fixed-schedules.md(the march’s section and a running example),docs/guide/numerics/solver.md(three integrators),docs/dev/stepper.md§2 and §8,docs/dev/kernel-contract.md(§7, the schema history, the header’s stale “27”),docs/dev/backends.md§8 and §9,docs/dev/architecture.md§8 and §13,docs/dev/decisions.md(ADR-050; the ADR-009 and ADR-047 notes), the tutorialdocs/tutorials/free_space/plot_05_low_storage.py,CHANGELOG.md.
Deviations from the proposal:
The in-stage exchange is not separate on
numpy_ref. §6.5 has each pass call_adams_lab_sourceonce; that hook returns the material response and the absorber term summed, through the reference’s own evaluation and its transverse scratch, so the host engine folds the sum. §4.3’s two evaluations per pass belong to the compiled stages (phases 2–3), which hold the material output in a stage row today. The collar case’s ledger holds three registers,Sand the table (tested)._stage_bundleskeeps several anchors. The hooks read each position as its own anchor (the lab source at(p, p), the flows at(z_n, z_m)and(z_m, z_n + h)), so the kept bundles are keyed by anchor and position until the next hoist; the engine takes its three anchors’ bundles, one call each. Recorded in the kernel contract’s row 29.The engine protocol carries
stiffness(Q1, below), besidestepandreset.The flag is set from the step’s start, by the march before it calls the engine, not at the engine’s first write: engine-agnostic and conservative (an exception in pass 1’s evaluation also leaves the backend refusing).
The failure path records the outcome without a checkpoint. It writes
failedat the last saved plane with a message naming the abandoned step, adds the note to the exception and logs it.The reporter’s closing line needed to know the run has no estimate:
LogReporter,BarReporter,default_reporterandresolve_reportertakeestimated(defaultTrue), whichrun_problemsets for this march.A varying operator under
"tabulate"holds the step’sPandQ(two volumes in the ledger), §4.3’s “one or two flow volumes per step”; the table reused across steps is a constant operator’s alone.The table is built from the step alone, as
U(o + h/2, o)from the operator’s origino(where a constant operator’s increment is the half step itself), not asU(z_n + h/2, z_n):fl(z_n + h/2) - z_nis off by up to half an ulp of the position, an error that the reused table carries into every step and that accumulates, where the recomputed flows’ differences telescope. Built this way, a reused flow is off by at most|D| |h - h_t| / 2 <= 4 eps |D| hwithin the slack,8 eps |D| ha step: twice §4.3’s “below8 eps |D| h / 2”, which counts one flow; the linear-limit test’sc = 16covers it (the worst element fell from 0.20 to 0.11 of the bound with the change).The
dz_maxrefusal is applied under this march only, where §5 states it, with a slack of four ulps of the run’s positions (a schedule’s steps are differences of rounded positions).gnlse_sourceis a new public function ofsolprop.driver.scaffold, so that one evaluation can be compared with the scaffold’s (§7.1).The order comparison rule of §7.1 is read as: the march must read four within
min(0.3, |p_DOPRI5 - 5|). The Cartesian ladder usestest_free_ladders’ grids with a 0.2 µJ beam over 1 cm (0.4 Rayleigh lengths): that test’s 0.1 µJ over 100 µm has a Kerr phase of about 1e-5 rad, whose truncation error sits below rounding.What the reference holds. The ledger is the reference’s peak per step, from
reference_base_memory, and the test also checks the arrays the backend and its engine actually hold after a run: the state, the two registers and the table, and nothing else of a state’s size (HostLowStorage.heldnames the engine’s). To make that so, the lab-frame source’s unit propagator became a one-element view broadcast to the state’s shape (it was a full array of ones, held by exponential Adams runs too),numpy_refkeeps its last propagator only for an Adams march, andclose()releases the engine’s registers and table (LowStorageEngine.close, added to the protocol). The transverse scratchSis a per-call allocation onnumpy_ref, counted at its peak.source_evaluationscounts committed steps: four a step, added in the commit, so a step abandoned in flight or diverged adds none.A lockstep runner refuses before stepping any member when one is in flight, so the members ahead of it are not advanced past the itinerary; its closing debug line keys “not estimated” on the run’s integrator, since the runner holds no march.
Decisions:
Q1 (the norm column): 0.0, “not estimated”; the two log lines print “not estimated (replayed schedule)”. The indicator
h rho = 2 ||F3 - F2|| / ||F2 - g1||is formed by the host engine from the step’s slopes (no recovery from the registers: the oracle holds them as transients) and kept asLowStorageEngine.stiffness, never in the column. Measured: it is|h lambda|exactly onu' = lambda u; on an ionising argon capillary (10 fs, 75 µm, 1 bar, 60 steps over 5 cm, split-step collar) it read 2.07 on the first step at 110 µJ, and the run survived, and 3.28 at 120 µJ, the step before the replay diverged; beside an in-stage collar it read 0.17 (pulse at the centre) to 0.65 (pulse in the collar) ofh alpha_max / 2, while a power iteration on the source Jacobian found the collar’sh alpha_max / 2 = 2.09exactly. It tracks the margin of the content the step integrates, and so the plasma’s, not the worst case the translation’s collar refusal bounds. Decision: the march logs a warning, once per replay, when it passes 2.785.Q3 (the noise): admitted; the draw is one counted volume; on
numpy_refits phase is formed per pass inside_adams_lab_sourceand no rotated copy is held. The compiled backends refuse the whole march in this phase, so no noise-specific refusal is needed yet.Q6 (in flight): the invalid-state guard, as recommended:
BackendErrornaming the step fromobserve,propagate_to,march_snapshotand every backend’sstats(through_require_state), and from a lockstep runner’s replay, statistics and observations through its members; onlyset_initialclears it.The “auto” flow resolves to
"tabulate"for a constant operator and"recompute"for a varying one on every backend that runs the march (onlynumpy_ref); the device rule waits for phase 3.ADR-050 records replay-only, accept in place, no estimate, the 0.0 norm and the guard; ADR-009’s stage clause and ADR-047’s premise carry notes.
Verification (on the branch tree; the full gate, the slow lane and the coverage lane were not run; they run once, at the end of the SEP):
tests/backends/test_low_storage.py(45 fast and medium tests and the slow P1 ladder, run by path with the marker expression lifted), the two other new modules and a targeted set of 63 modules touching the changed code under xdist: 3095 tests, 10 failures on two pinned version strings, fixed and rerun (430 passed).Linear limit (§4.5’s bound with
c = 16, fixed before measuring): worst element at 0.11 of its bound tabulated and 0.12 recomputed on the constant modal operator (200 steps,|Im D| h / 2up to 31.5), 0.013 on P2’s varying operator; the Cartesian beam withintest_free_diffraction’s own width, Gouy-phase and amplitude bounds.Order four, three-level
calibratewith a floor (two flow treatments’ difference, at least4 n eps): P1 3.96 at 200/400/800 steps, IP-DOPRI5 5.08,|Im D| h / 2 <= 0.80over the bins holding 1 % of the run’s peak amplitude, and the Richardson estimate the analytic error to 0.03 %; P2 3.96 at 40/80/160, IP-DOPRI5 6.24,<= 0.53; the Cartesian beam 3.98 at 10/20/40, IP-DOPRI5 4.55,0.14. At a base of 100 steps P1 reads 3.57 with|Im D| h / 2 = 1.6: §4.4’s range, observed.Independent RK4IP: one evaluation equal to the scaffold’s (asserted within
fft_bound); 400 steps of the N = 3 soliton 7.0e-12 apart against a derived bound of 9.8e-7 and a truncation error of 4.93e-4.Lifecycle:
step_planesstops on the schedule’s 7th, 14th, … edges; resumes at a plane and at a mid-segment edge differ by 0 (the same table, by coincidence of the steps) and 2.3e-16 against a bound of 1.5e-12, asserted as a bound; a lockstep batch matches its members, and a member left in flight stops the next step before any member moves; every refusal fires through its entry point (the translation, or the settings), naming its remedy – the TDSE one throughvalidate, the spatial collar’s 2.785 throughassemble, the first admitted schedule against the one before it, which DOPRI5 admits; an injected exception leaves the four readers refusing, the file ending at its last plane with no checkpoint and a failed outcome, and the resume within bound.Memory: a Cartesian ledger of five field volumes tabulated and four recomputed, six with the noise, matched by the arrays actually held (the state, the registers, the table) and released by
close(); no stage, accumulator or propagator-pair row; the estimate equal to the ledger to the byte (the noise, loaded after construction, is named among what it does not count); a run_admit_historyrefuses under Adams admitted.Lint:
ruff check,ruff format,basedpyright(no errors) andnumpydoc linton the changed modules;codespell,git diff --check,tests/test_seps.py.solprop-kokkos
scripts/check.sh(withSOLPROP_KOKKOS_SOLPROP_SOURCEat this branch): format, lint, REUSE, pyright, clang-format, the tidy and sanitizer native builds and tests, 314 Python tests passed and 5 skipped, and the distribution wheel’s smoke test, all green. The wheel built from the kokkos branch is installed in the solprop worktree’s environment and reports schema 29.poe docs(warnings as errors): build succeeded; the tutorial runs in about 1.1 s in the gallery.Not run: the full gate, the slow lane beyond the P1 ladder, the coverage lane, GPU tests, benchmarks (§7.4 is phases 2–3); no backend but
numpy_refexecutes the march, so §7.2 does not apply yet.
Gate (at the merge, 2026-10-18): after an independent numerics review (no blockers; six should-fix items resolved: the flow table built from the step alone, the lockstep pre-check, the held arrays named and released, the batch debug line, the evaluation count on commit, the refusals tested through their entry points), the lane set ran on a Linux cluster node (32 cores, GCC 16 toolchain, the Kokkos wheel built from the solprop-kokkos branch installed): the full check sequence (lint, format, types, docstrings, spelling, licences, then 15 558 tests passed), the coverage lane at 100 % (15 558 passed) and the slow lane (128 and 1 passed); the Kokkos-selected lanes on the pre-review head (the default suite and the slow lane, 146 passed), which no later commit’s Kokkos path touches; the documentation build on the final head. The lanes found, and the merge fixed: the kernel schema in two translation doctests, the SEP 12 harness admitting output schemas up to 18, the committed structure figures without the two new modules, a step-limits test reading an Adams field off the widened integrator type, and the formatter’s rewrite of the fixed-schedules snippet. One pre-existing margin, unrelated to this phase and failing identically on the unchanged
mainon that node, was widened with its measurement recorded:test_every_statistic_matches_the_reference’s sampled response bound.Sources checked: the interval 2.785 against RK4’s stability function (root −2.7853; the imaginary interval
2 sqrt 2), and the scheme againstR(h lambda)on a linear source. Lawson [1] and Hult [2] are cited as the SEP cites them; the papers were not re-read in this phase.Limitations:
numpy_refonly; the device rule for"auto"is not implemented; the table’s slack (Q10) makes the table rebuild often far from the origin; an exception in a step’s first evaluation also loses the checkpoint; no benchmark.Open questions:
Q10 — phase 2: the table’s slack. A uniform schedule’s steps differ by an ulp of the position, which far from the origin is more than
LANDING_ULPSulps of the step: on the lifecycle case (z / hup to 40) the table was rebuilt 5 times in a 20-step segment, and atz / hin the thousands it would be rebuilt nearly every step. A slack in ulps of the position keeps one table but lets the phase drift by up toeps |D| za step; keying on the segment’s mean step would not drift. Phase 2, which times the table, to choose.Q11 — phases 2–3: the indicator on a resident engine. Form
h rhonatively (two reductions,g1andF2recovered fromCandAwith the cancellation §6.3 names) or report NaN, which silences the warning.Q12 — the merge: resolved. This phase merged first, so it took output schema 19 (kernel schema 29) and SEP 22’s phase 1 takes 20; ADR-050’s number stands.
Q13 — phase 2:
_fsal_ready.numba_cpu’s first-same-as-last switch still tests the integrator againstNone, which is right while the Runge–Kutta march is the only one that recycles a stage; the low-storage march must keep it off when phase 2 runs it on that backend.
Handoff: phase 2 replaces
numba_cpu’s refusal (_refuse_adams) with_low_storage_enginereturning a resident engine, adds it toLOW_STORAGE_BACKENDSwithbackend="auto"per problem, names the registers and the table in its ledger (the rows ofreference_base_memory’s_stepping_rows) with an exact estimate, splits_stage_freefor §4.3’s exchange, and names the march in_admit_history’s refusal.tests/backends/test_low_storage.pyruns onnumpy_ref; its cases are the parity cases to parametrise. The output schema version enters the configuration hash, so any digest of a configuration recorded at schema 18 or 19 differs from this branch’s.
Phase 2 report — numba_cpu#
Scope: phase 2 of §11, 2026-10-19, Opus 5.5 (AI). Branch
feature/sep-0024-phase-2in solprop frommain8fc5e80 (kernel schema 31, output schema 20, with SEP 22 phase 2); no solprop-kokkos change and no schema change. Merged intomainon 2026-10-19 by the merge commit that follows the commit carrying this report.Delivered:
The resident engine and register kernels:
src/solprop/backends/numba_cpu/low_storage.py(NumbaLowStorage; the modal, Cartesian and planar inputs, finishes and flow tables;exponentiate_forward); the fold of §4.3’s table as one body,low_storage_fold, withFOLD_COMPLETE,FOLD_EXCHANGEandFOLD_SECOND(src/solprop/backends/bodies.py).src/solprop/backends/numba_cpu/backend.py: the march built before the buffers,_low_storage_engine, no Runge–Kutta buffer under it (_allocate_runge_kutta), the register rows inmemory_ledger, the Cartesian and planar cores factored out of the Runge–Kutta stages and shared (_free_pass,_slab_pass), the noise phase of an observation in a transient,_refuse_adamsno longer refusing the march,_fsalkept off for it (Q13, commented).The table key (Q10) on both engines:
table_stepsandschedule_keyinsrc/solprop/backends/low_storage.py,HostLowStoragekeyed on them.Memory:
low_storage_stage_registerandlow_storage_memoryand the estimate’s elided register (src/solprop/backends/memory.py); the admission names the march (src/solprop/driver/memory.py);LOW_STORAGE_BACKENDS = ("numba_cpu", "numpy_ref"),"auto"resolving tonumba_cpu(src/solprop/driver/simulation.py).Tests:
tests/backends/test_numba_low_storage.py(parity on every route, threads, ledger, noise, held plane and resume, the guard, the estimate),tests/backends/test_numba_low_storage_kernels.py(every kernel against the table, added topoe cov-kernels), updates totests/backends/test_low_storage.pyandtests/driver/test_low_storage_settings.py.Benchmarks:
benchmarks/sep24/phase2_costs.py; the ledger entry indocs/dev/benchmarks.md.Documentation:
docs/guide/numerics/choosing-a-stepper.md,docs/guide/numerics/fixed-schedules.md,docs/dev/stepper.md,docs/dev/backends.md(§9’s table), the structure figure (layers.svg,docs/dev/figures/build.py),CHANGELOG.md.
Deviations from the proposal:
The in-tree speed target is not met. §7.4 asks for at least 3 times less time per evaluation and 4 times less per step than the IP-DOPRI5 replay on one thread; measured 1.22 and 1.83 (shape A), 1.18 and 1.77 on all threads, and 1.15 and 1.73 at shape B. The target was set against a replay that SEP 25 has since made about six times faster per evaluation (§2’s 1.0 s against 0.176 s now); 69 % of a march step is the evaluation’s core, which both marches share. The memory saving stands: 4 volumes against 16.
The register input of passes 2 to 4 is fused into the finish before it where the stage register is elided (Q4, below), so those passes have no input traversal.
backend="auto"is not chosen per problem. The settings resolve the backend before any problem exists, as for every integrator;numba_cpuruns every route the translation admits the march on, so"auto"resolves to it.The exact estimate covers the march’s rows. The registers, the table, the state and the transverse scratch agree with
numba_cpu’s ledger to the byte (tested on every flow treatment, with and without the collar); the chunk-sized rest ofbasestays the reference’s count, as for every march (module docstring ofsolprop.backends.memory).The modal routes’ recomputed flows are transients, the step’s
PandQformed from_delta_phi, a modal state’s size; the separable kinds form them per element.The stiffness indicator is NaN on this backend (Q11, below), so the march’s warning is
numpy_ref’s alone.
Decisions:
Q4 (the stage register): elided on the Cartesian and line routes where a pass reads its input once (a response and no in-stage absorber): the finish of each of the first three passes writes the next input, noise included, into the transverse scratch. It saves one volume (4 tabulated, 3 recomputed, against 5 and 4) and the step’s input traversals (the input fell from 13 % to 3.6 % of a step, 0.591 to 0.546 s at shape A, one thread). Held under an in-stage absorber, where the exchange re-forms the input from
C, and on the modal and planar routes, whose inputs are a padded fine spectrum or a column volume. The 2N scheme is not needed:Cdoes not bind.Q10 (the table’s slack): keyed on the mean step of each run of equal steps,
table_steps, on both engines; a run of one step keys on its own value. Ten steps at 1 m from the origin, where a uniform schedule’s steps take two values 2.1 × 10⁶ ulps of the step apart: one build against seven for a per-step key, 0.159 against 0.161 s a step (within the spread). Chosen because the phase the table applies over a run telescopes to the run’s own, where a slack in position ulps drifts.Q11 (the indicator on a resident engine): NaN. Recovered from the registers,
2 (C - A) / h, it agreed with the exact slopes’ to 1e-7 relative at 0.98 and 98 W on a soliton and departed only on nearly linear steps (3.7 × 10⁻³ against 2.6 × 10⁻¹⁰ at 1 µW, far below 2.785); but on the Cartesian and line routesCis elided or the slope is split across the exchange’s two evaluations, and recovering it there would take a volume.Q13 (
_fsal): off for the march, as theNonetest already made it; tested.No new ADR: the decisions refine ADR-050’s engine, which stands.
Verification (on the branch; the full gate, the slow lane and the coverage lane not run):
tests/backends/test_numba_low_storage.py(47) andtests/backends/test_numba_low_storage_kernels.py(21, compiled and withNUMBA_DISABLE_JIT=1),tests/backends/test_low_storage.py,tests/driver/test_low_storage_settings.py,tests/stepper/test_low_storage.py,tests/test_seps.py,tests/backends/test_driver.py(44, alone); 725 tests of 17 neighbouring modules (the numba Cartesian, line, planar, radial, general, dispersive, memory and multistep ones), all passing.Parity within
parity_bound, fixed before measuring (four evaluations a step of 32 products and 8 roundings per transform level, the response’s addition depth, the flows’ argument term of §4.5 and, on the separable kinds, two ulps ofkover the run, with a stated growth of 2): measured 2.2e-17 against 1.1e-11 (Cartesian), 1.1e-15 against 2.3e-11 (Cartesian, in-stage collar and exchange), 7.8e-16 against 1.9e-11 (nitrogen gradient capillary, Raman and plasma, a varying operator), 1.1e-11 against 9.5e-10 (planar Kerr); every route and flow treatment passes.Threads: the parallel kernels against the serial ones at 1, 2 and 4 threads within 4 eps per element and
n_columns epson the norm; whole runs at 1, 2 and 4 threads (large regime) within the parity bound.Memory: the ledger’s field rows exact (4/3 volumes without a collar, 5/4 with one, one more each for the noise and a held field-free plane) and released by
close(); the estimate exact for them.Coverage of
numba_cpu/low_storage.pyandbackends/low_storage.pyat 100 % (the kernels interpreted, then the engine compiled,coverage-cpu.ini); every added line of the other changed modules covered but moved Runge–Kutta stage lines, which the lane’s existing tests run.Doctests of the changed modules;
ruff,basedpyright,numpydocon them;poe docs; the public-tree scan.At review, on Linux with the schema-31 Kokkos wheel:
poe check(16 060 passed, 29 skipped),poe covat 100 %,poe test-slow(147 passed, 1 skipped) and the Kokkos-selected default (16 060 passed) and slow (148 passed) lanes. The first check stopped at nine type errors in the new numba test module, fixed at review; the import-time budget missed once on a loaded node (0.28 s of CPU against a local 0.037 s on both this branch andmain) and passed on the re-run.Not run: GPU; equal Richardson error and the larger-beam one-thread timing of §7.4 (shape B on all threads only).
Sources checked: none beyond phase 1’s; the fold is §4.3’s table, checked element by element against it.
Limitations: the speed target (deviation 1); the indicator off on
numba_cpu; the planar and modal routes holdC; the benchmarks are same-process ratios on a loaded workstation (load 3.7 to 8.1). Found at review, for phase 3 or the roadmap: on the modal route with a varying operator under"recompute", two state-sized transients a step (and the phase-difference exponent with its contiguous copy, even when tabulating) are outside the ledger and the estimate, so a large multimode run’s peak exceeds the estimate by a few volumes; and the engine’s held-table count and the estimate’s use two predicates that agree only because a separable operator is always constant, which one shared predicate should replace.Open questions:
Q14 — phase 3: the speed target. Kokkos CPU’s fused stage may give a different ratio; whether §7.4’s target is revised to the current tree’s replay is the maintainer’s (destination: phase 3’s report).
Q15 — later: the evaluation’s core. Two copies per node chunk (
_free_material) and a zeroed projected buffer per chunk are common to both marches; trimming them speeds both (destination: roadmap, Performance and backends).Q16 — later: the planar route’s
C. Its finish could write the next input into the column volume as the Cartesian one does (one volume).
Handoff: phase 3 implements §6.7 on Kokkos behind
_low_storage_engine; the fold body, the exchange, the elision rule (low_storage_stage_register, extended to"kokkos"when its engine elides) and the table key (table_steps) are the reference forms;tests/backends/test_numba_low_storage.py’s cases are the parity cases to parametrise over the Kokkos backend.
Phase 3 report — Kokkos CPU#
Scope: phase 3 of §11, 2026-10-19, Opus 5.5 (AI). Branch
feature/sep-0024-phase-3in solprop (frommain9eef542, kernel schema 31, output schema 20) and in solprop-kokkos (from itsmain9ec03d5); kernel schema 32, output schema unchanged. Merged withmainat e9dd4b8 (SEP 27 drafted, documentation only) and merged intomainon 2026-10-19 by the merge commits that follow the commit carrying this report, solprop-kokkos alongside.Delivered:
solprop-kokkos, native:
GeneralState::low_storage_step(one call a step: the four register passes,low_storage_separable_passandlow_storage_modal_pass, their inputs and folds, the fold bodylow_storage_fold,register_traversalwith the final pass’s per-column norm), the flows (low_storage_flows_formed: a constant operator’s table from scalar row 2, a varying operator’s step pair, or none),hold_planeandheld_to_host;Problem::low_storage,low_storage_stage_register,low_storage_flows;GeneralWorkspace::allocateunder the march creating none ofstages_,solution_,error_,residual_,recycled_,advanced_,whole_propagator_orstage_inverse_, but the accumulator, the stage register (unless elided) and the flows;finishacting on the state in place; the ledger rowsregister_accumulator,register_stage,held_planeand the Runge–Kutta rows read off the allocated views (include/solprop_kokkos/core.hpp,src/general.cpp,src/bindings.cpp, schema 32).solprop-kokkos, Python:
KokkosBackend._low_storage_enginereturning_ResidentLowStorage(atomic, NaNstiffness, the table keyed bytable_steps),_holdand_held_fieldon the native state, the device rule of_propagator_mode, the refusal removed (src/solprop_kokkos/backend.py,__init__.py’sDRIVER_SURFACE,_core.pyi); README, CHANGELOG.solprop: kernel schema 32 (
translate/problem.py);LowStorageEngine.atomicand the march’s single commit for an atomic engine (backends/low_storage.py);StepDriver._held_fieldand the surface’s_hold/_held_field(backends/driver.py,tests/backends/test_driver.py); one shared flow predicatelow_storage_flowsused by both engines and the estimate, and the elision predicate extended to"kokkos"(backends/memory.py,backends/numba_cpu/low_storage.py);LOW_STORAGE_BACKENDSwith Kokkos and"auto"resolved as for any integrator (driver/simulation.py).Tests:
tests/backends/test_kokkos_low_storage.pyand its thread probekokkos_low_storage_thread_probe.py;test_numba_low_storage.py’s parity cases run on every compiled backend (compiled_backends,assert_compiled); the atomic commit on the host engine (test_low_storage.py); solprop-kokkostests/native/test_core.cpp(the passes against a host RK4IP, the refusals) andtests/test_backend.py(parity with the reference, both flow treatments).Benchmarks:
benchmarks/sep24/phase3_costs.py, the ledger entry indocs/dev/benchmarks.md. Documentation:docs/guide/numerics/choosing-a-stepper.md,fixed-schedules.md,docs/dev/backends.md(§8, §9’s row),docs/dev/kernel-contract.md(row 32, §7, the header’s stale schema),CHANGELOG.md.
Deviations from the proposal:
The in-tree speed target is not met on Kokkos either. One thread, shape A: 2.02 times less per step and 1.35 per evaluation than the Kokkos IP-DOPRI5 replay (target 4 and 3); all threads 1.81 and 1.21 (shape A), 1.85 and 1.24 (shape B). Against
numba_cpu’s march: 7 % faster on one thread, 1.6 (A) and 2.1 (B) times on all threads.Schema 32 adds two surface members and one protocol attribute where §6.7 names none:
atomic(so the shared march, not the backend, commits the native step with_finishand the move) and_hold/_held_field(so the held plane lives in the memory space, the decided Q15 treatment).The held plane is allocated at the first field-free plane, not at construction (the native state does not know the run’s retention); one volume, kept, in the ledger and the admission as before.
"auto"on a device build recomputes and the native state holds no table, butlow_storage_flows(a CPU count) still counts it: the estimate is conservative by one volume there.No stage profile on Kokkos: the step is one native call.
The march’s quadrature score is not taken on Kokkos (the embedded projection’s alarm is the Runge–Kutta and Adams paths’), as on
numba_cpu.
Decisions:
Q5 (Kokkos’s stage): split, without moving the Runge–Kutta arithmetic. The separable stage became input (
separable_stage_input), core (separable_core: the transverse pair, the chunk loop, the response or the collar term) and finish (separable_finish_transform, accumulation); the modal stage became entry, core (modal_core,general_core) and finish. The march forms its own inputs and folds around the same cores; the Runge–Kutta path is code movement only, and every existing Kokkos parity test of it ran unchanged.Q11 (the indicator): NaN, as on
numba_cpu:Cis elided on the routes the march is for, and recoveringg1andF2would cost a volume and two reductions.Q4 on Kokkos: the same elision rule (
low_storage_stage_register): free space without an in-stage collar; the modal and planar routes holdC.The review’s follow-ups: the shared predicate (
low_storage_flows) replaces the two that agreed by accident, in both engines and the estimate; Kokkos forms a varying operator’s recomputed flows per element from the phase terms, so it holds no transient outside the ledger.numba_cpu’s own modal transients are untouched (not this engine).Schema: 32 (no problem field moves; an older wheel refuses the march).
Verification (the full gate, the slow lane and the coverage lane not run):
Parity on every case of
test_numba_low_storage.pyfor Kokkos andnumba_cpuagainstnumpy_refwithin the phase 2parity_boundfixed before measuring (47 passed, medium included): soliton with and without noise, both flow modes; linear and collar-only modal; varying capillary with Raman and plasma; radial with its collar; general modal (pair, Raman, collar, linear); Cartesian with collar and noise; line (Kerr, Raman, plasma, collar); planar (seven cases); the dispersive response. Cartesian Kokkos departures 1.9e-17 to 1.2e-15 against bounds of 1.1e-11 to 2.3e-11. Two artificial linear cases now also drop the Kerr coefficients, which Kokkos reads from the problem rather than the response order.test_kokkos_low_storage.py(15): field rows exactly 4/3 volumes, 5/4 with a collar, no Runge–Kutta view, the estimate equal to the ledger for the march’s rows; a varying operator’s pair or none; the held plane natively, its noise phase and a resume within the parity bound (Cartesian, and the soliton against the reference); the in-flight guard after a raising step; a Ctrl-C during the native step landing after the commit;"auto"running the march on Kokkos; one thread twice and four threads in fresh processes within the parity bound.Native gtests: the passes against a host RK4IP over three steps (both flow modes, 4096 eps per element, fixed beforehand), the ledger, the held plane, the refusals.
solprop:
test_low_storage.py,test_driver.py,test_memory.py,test_low_storage_settings.py; coverage of the changed lines (low_storage.py 100 %); doctests of the changed modules;basedpyright src tests0 errors;numpydoc lint,codespell,ruff;poe docs; the public-tree scan.solprop-kokkos
scripts/check.shwithSOLPROP_KOKKOS_SOLPROP_SOURCEat this branch: format, lint, REUSE, pyright, clang-format, the tidy and sanitizer native builds and tests, 325 Python tests passed and 5 skipped, the distribution wheel and its smoke test, all green (Homebrew Clang 23, macOS arm64, OpenMP; Python tests at 4 threads). The wheel built from the branch is installed in the solprop worktree and reports schema 32.At review, on Linux with the schema-32 wheel built from the solprop-kokkos branch:
poe check(16 079 passed, 29 skipped),poe covat 100 %,poe test-slow(147 passed, 1 skipped) and the Kokkos-selected slow lane (148 passed). The first round found the kernel contract’s current-version line still at 31, the one-run figure not redrawn, and two branches untested (the held buffer’s reuse, a read-only accepted state); all four were fixed at review and the check and coverage lanes re-run green. The Kokkos-selected default lane failed only on the first two.Not run: GPU builds (not compiled here either); equal-Richardson-error timings; the one-thread shape B timing.
Sources checked: none beyond phases 1 and 2; the fold is phase 2’s body.
Limitations: the speed target; no indicator on the compiled backends; the device path compiled nowhere in this phase; Kokkos benchmarks on a loaded workstation (load 3.2 to 12.8). Found at review, native guards that solprop cannot reach as wired today (its elision rule excludes an in-stage absorber, a separable operator is constant, and the table key starts at NaN), for a follow-up:
allocateshould refuse an elided stage register beside an in-stage absorber (the exchange fold would read the null register) and a separable geometry with two flow volumes (its fold reads only the first flow), and atable_ready_flag should refuse a step on a table never built. The native test covers the modal route with one constant operator and equal flow rows; the separable fold, the exchange and the elision are covered by the Python parity tests only.Open questions:
Q17 — the maintainer: the speed target. Both compiled backends miss §7.4’s ratios against SEP 25’s fused replay (Q14); whether to restate the target is the maintainer’s (destination: the SEP’s Final review).
Q18 — device qualification (Q7). Compile the passes for CUDA and HIP and qualify them (destination: the roadmap’s device items).
Handoff: the three CPU backends run the march; phase 4 (the embedded pair) may reuse
low_storage_fold’s organisation and Kokkos’sregister_traversal.
Closing report — the native guards, the follow-ups and phase 4 not started#
Scope: the close of the SEP after phase 3, 2026-10-19, Opus 5.5 (AI). Branch
feature/sep-0024-closingin solprop (frommain7affe12, kernel schema 32, output schema 20) and in solprop-kokkos (from itsmain391ca7d); no schema change. The follow-ups phase 3’s review named, the decision not to start phase 4, the open questions’ destinations, and the device checks under HIP and CUDA.Phase 4 is not started (the maintainer, 2026-10-19). Phases 2 and 3 show the march competitive in memory (four volumes against fifteen or sixteen) and faster per step than the IP-DOPRI5 replay on both compiled backends, but no adaptive memory-bound use is waiting for it, and the fixed-step use is served by the replay with an external calibration. The embedded pair (Q8) is a roadmap item under Numerics, with the SEP’s recommendation (ERK4(3)-IP [7] first) carried over.
Delivered:
solprop-kokkos, native (
src/general.cpp):GeneralWorkspace::allocaterefuses, withstd::invalid_argumentand before any view, an elided stage register beside an in-stage absorber (the exchange fold re-forms its input fromC) and more than one flow volume on a separable geometry (its fold applies one, the table); atable_ready_flag, set when a step rebuilds the table and cleared byset_initialandclose, makeslow_storage_steprefuse (std::logic_error) a step that would reuse a constant operator’s table never built. Tests:tests/native/test_core.cpp(LowStorageRefusesRegistersItsFoldsCannotRead,LowStorageRefusesATableNeverBuilt, each with the accepted counterpart beside the refusal) and, through Python with a forged stale key since solprop’s engine keys the table from NaN after every load,tests/test_backend.py(test_a_low_storage_table_never_built_is_refused). CHANGELOG.solprop,
numba_cpu’s modal follow-up (phase 2’s review): recomputing on a modal or general route the engine holds one flow scratch volume, formsPin it for pass 1 andQover it before pass 3 (pass 2 reads no flow, passes 3 and 4 onlyQ), and exponentiates a modal flow per element from the terms ofPhi(exponentiate_terms,src/solprop/backends/numba_cpu/low_storage.py) or from the z table’s held difference, so neither the two state-sized flows nor the exponent array (with its contiguous copy, which tabulating also made) is allocated in a step. The scratch is counted in the ledger’spropagatorsrow and by the estimate (low_storage_flow_scratchinsrc/solprop/backends/memory.py, used byestimate_memoryandlow_storage_memory), to the byte.Tests (
tests/backends/test_numba_low_storage.py): the varying capillary’s flows held (two volumes tabulated, one recomputed), the reference’s count plus the scratch, the estimate’s base equal to the reference’s plus the scratch, the admission’s figure equal to the estimate from the march or the default stepper, and no state-sized allocation from the engine module and no_delta_phicall in a step; a z-table operator (a dispersion declared along z) against the reference, both flow treatments;exponentiate_termsagainstexp(tensordot)and, for one term of unit weight, the forward exponential exactly (test_numba_low_storage_kernels.py).Documentation:
docs/dev/backends.md(§9’s paragraph and the device column),docs/dev/roadmap.md(the SEP’s entry; the pair under Numerics; Q9 in the stepper’s open questions; Q2, Q16 and the prefactor under Performance and backends; the device item),docs/dev/stepper.md§9,CHANGELOG.md.
Deviations from the proposal: none beyond phases 2 and 3’s. A recomputing modal route on
numba_cpunow holds one volume where §6.8’s table says none for the recomputed flows; it was a transient of two volumes before, and Kokkos still holds none.Decisions:
The modal follow-up: one reusable scratch, in preference to forming the flows inside the fold (Kokkos’s way), which would need a second fold variant per route and an exponential per element in three passes.
Q14 and Q17 (the speed target): withdrawn. The maintainer set no fixed target: the march is to be as fast as possible, ranked by measurement against the other marches as §7.4 does, the memory saving standing in its own right. The measured ratios stay in phases 2 and 3’s reports and the benchmark ledger.
Q15 (the evaluation’s core): covered by SEP 28 (Draft), which takes the copies and clears inside each node chunk on every stepper; no separate item.
Q11 (the indicator on the resident engines): stays NaN, as decided in phases 2 and 3; not reopened.
No new ADR.
Device qualification (Q7, Q18):
build
device
date
result
HIP
AMD MI325X (gfx942, ROCm 7.14)
2026-10-19
compiled; kokkos
-m gpu(31), solproptest_kokkos_gpu.py(2) and the march’s device tests (15) passCUDA
NVIDIA H200 (CUDA 13.1, HOPPER90, GCC 13.4 host)
2026-10-19
compiled; the same three sets pass (31, 2, 15)
The march at 768x768x600 with statistics-only planes takes 610 ms a step on the MI325X and 674 ms on the H200 (high-water 46 and 34 GB, transform workspace included), against 1077 and 1092 ms for the IP-DOPRI5 replay; the gap to a single-transform engine on the same card is SEP 28’s subject. These checks build
main(solprop 29b3cbc, solprop-kokkos 95b7502), not this branch; the native guards here add refusals only.Closing lanes, on Linux (an AWS
c8a.24xlarge, the S3 build cache) on the closing branches merged withmain(solprop 95fa3ee, solprop-kokkos with SEP 23 phase 3):poe check(16 106 passed, 29 skipped),poe covat 100 %,poe test-slow(147 passed, 1 skipped) and the Kokkos-selected default (16 106 passed) and slow (148 passed) lanes, all green.Verification (targeted, before the closing lanes):
solprop:
tests/backends/test_numba_low_storage.py(50),..._kernels.py(22, compiled and interpreted),tests/backends/test_low_storage.py,tests/backends/test_memory.py,tests/driver/test_memory.py,tests/driver/test_low_storage_settings.py,tests/backends/test_kokkos_low_storage.pyandtests/backends/test_driver.py: 332 passed, with the closing branch’s distribution wheel installed. The z-table parity departure 7.8e-17 against a bound of 9.9e-13 fixed before measuring (the phase 2parity_bound). Coverage of the changed lines ofnumba_cpu/low_storage.pyandbackends/memory.pyat 100 % (kernels interpreted, then the engine compiled,coverage-cpu.ini). Doctests of both modules;ruff,basedpyright,numpydoc lint;poe docs; the public-tree scan.solprop-kokkos: the native low-storage gtests (4) on a debug build; the low-storage Python tests;
scripts/check.shwithSOLPROP_KOKKOS_SOLPROP_SOURCEat the closing branch, green: format, lint, REUSE, pyright (its first run found the new test’s untyped engine, fixed), clang-format, the tidy and sanitizer native builds and tests, 326 Python tests passed and 5 skipped, the distribution wheel and its smoke test (Homebrew Clang 23, macOS arm64, OpenMP). That wheel is installed in the solprop worktree and reports schema 32.Not run: the full gate and the end-of-SEP lanes (run before Final); device timings.
Sources checked: none new.
Limitations: the native guards are not reachable through solprop’s rules (they guard the native state against a caller that breaks them); the Kokkos device estimate stays conservative by one volume (phase 3, deviation 4);
numba_cpu’s varying modal prefactor is still a state-sized array per pass on both marches (found here, a roadmap item).Open questions (every one now decided or with a destination): Q1, Q3–Q6, Q10–Q13 decided in phases 1 to 3; Q2 (hook names), Q16 (the planar route’s
C) and Q7/Q18 (devices) to the roadmap’s Performance and backends; Q8 (the pair) to Numerics; Q9 (transfer across transverse grids) to the stepper’s open numerical questions on the roadmap andstepper.md§9; Q14/Q17 withdrawn (no fixed target); Q15 to SEP 28.Handoff: the closing lanes, Final and the archive move are the maintainer’s.
Part III — History#
2026-10-16: Drafted.
2026-10-16: Reviewed by Opus 5.5 (AI). Resolved in the text:
the flow table, reused within
LANDING_ULPSof the step that built it, with a resume that agrees to rounding;the in-stage exchange;
the dispatch inventory;
the guard’s reach;
the commit block;
the records;
the in-tree benchmark target.
2026-10-17: Restated for SEP 25. Thread-count invariance of the fused kernels (§6.6, §7.2, phase 2 in §11), repeats (§7.2) and the
step_planesand lockstep-batch identities with the uninterrupted run (§7.1) become agreement within a rounding bound fixed before measurement, the last on the same edges, which stay exact. The resume, flow-mode and independent-RK4IP statements already stated agreement to rounding, not bit for bit (§4.3, §5, §7.1, §9), and stay. Kept exact as structural: existing runs taking the same code path (§9) and the ledger andestimate_memoryto the byte (§6.6). §10’s rejection of a low-storage Runge–Kutta march now cites recapture of recorded states rather than moved digests.2026-10-18: Revised against SEP 25 Final and SEP 21 as reviewed. The schema numbers follow SEP 20’s 27 and SEP 25’s 28 (§3, §5, §6.4, §9, §11: the next free versions are 29 and 19 at this writing); the host engine takes its stage scalars through SEP 25’s
_stage_bundles(§6.5); the drafting timings of §2 predate SEP 25’s kernel work; the temporal collar’s floor under this march and the refusal of SEP 21’s implicit scheme are stated (§4.6, §5), and the march calls SEP 21’s accepted-endpoint hook (§6.2). The ledgers of §2 were re-measured on the current tree onnumpy_refandnumba_cpuand stand.2026-10-18 (later): §2’s drafting timing re-read: it ran under
split_stepwithout FSAL, seven evaluations a step, so the per-evaluation gap is about 4.8, not 5.5, and the per-step factor stands; §4.3 notes SEP 22’s zero-rate elision.2026-10-18: Accepted (John Travers) after his final review; Part I frozen.
2026-10-18: Phase 1 implemented (specification, seam and
numpy_ref); status In progress.2026-10-19: Phase 2 implemented (
numba_cpu).2026-10-19: Phase 3 implemented (Kokkos CPU).
2026-10-19: Phase 4 not started (the maintainer); the native guards and
numba_cpu’s modal flow scratch closed the SEP’s follow-ups; the march compiled and passed its device tests under HIP (MI325X) and CUDA (H200).2026-10-20: Final (John Travers). Phases 1–3 and the closing work merged with green closing lanes; phase 4 not started; device checks under HIP and CUDA passed; moved to the archive.