SEP 39 — A native Adams march: the Multistep step loop in the compiled backend#
SEP |
39 |
Title |
A native Adams march: the Multistep step loop in the compiled backend |
Author |
John Travers |
Drafting agent |
Opus 5.5 (2026-10-28) |
Status |
Final |
Review status |
Reviewed |
Reviewers |
John Travers (human); GPT-6 (AI) |
Type |
Change |
Created |
2026-10-28 |
Resolution |
Accepted 2026-10-28 by John Travers, after his human review, the GPT-6 AI review and the response to it, his decisions Q1–Q14, R9 and R12, and the refresh of Part I against LupoLab/solprop#56 (Part III). The proposal lands with its implementation’s single pull request. Final 2026-10-30 by John Travers, after phase 5’s HIP qualification on an AMD Instinct MI325X, with the native performance work that followed (LupoLab/solprop#59). |
Requires |
SEP 16 (the march, its engine, ADR-043); SEP 25 (agreement to the error bound); SEP 29 (native z tables and the history export) |
Supersedes |
— |
Superseded-by |
— |
Implementation |
Opus 5.5 (AI) for John Travers, branch |
Manual pages |
Part I — Proposal#
1. Abstract#
Move the step loop of Multistep() from solprop’s Python AdamsMarch into
solprop-native. That loop is the step-size controller, the variable-order choice, the
proposal caps and landings, the coefficient tables and the stage scalars of every trial.
On the native backend a run of accepted steps would then cross the nanobind seam once
per host event: a save plane, a boundary-monitor check, a statistics row, the end of
a march. Today, on the head of LupoLab/solprop#56, it crosses \(k + 2\) to \(k + 5\) times
per trial, where \(k\) is the number of scalar tables read (typically four to six). It also
spends 40–50 µs of Python per trial on an Apple M5 Max and 170–210 µs on an AMD EPYC 7763.
At drafting, before LupoLab/solprop#55, those figures were 110–140 µs and 390–510 µs.
The recommended design (option B, §6.2) keeps every policy in solprop. solprop hands
the backend one record holding every controller and landing parameter, plus the events
at which it must return. Native then executes an algorithm that this SEP (§4) and the
kernel contract specify in full. The Python march stays: it is the reference, and it
still runs on numpy_ref and numba_cpu.
The case for it, from a profiling study of 2026-10-28 and its re-measurement at the refresh (§3.3):
At drafting the Python side of a trial cost 110–140 µs on an Apple M5 Max and 390–510 µs on an AMD EPYC 7763: 1.5 to 1.7 source evaluations on the EPYC for the cheap mode-averaged capillaries. LupoLab/solprop#55 has since removed more than half of it. What remains is 38–51 µs (M5) and 170–210 µs (EPYC), or 9–17 % and 15–22 % of a single-mode trial.
The native march would remove about nine tenths of what remains: 6–7 % of a single-mode run with phase 1 alone, and a further 2–11 % with the march, by case and machine (§6.1).
On device builds the host’s share of a trial is far larger (§6.7), and the march is what removes it.
Success means:
on the tracking set’s capillaries, the native march takes the same steps as the Python march on the same engine (a diagnostic, never a promise; SEP 25);
it meets each case’s accuracy envelope;
with a quiet boundary monitor, its host cost per trial is at most 15 µs on the M5 and 40 µs on the EPYC;
it is nowhere slower than the Python march beyond the A/B spread, including on expensive sources and runs that return every step (§7.6).
With the native-side work of §6.9, nearly all of which has landed, the estimates at one thread are:
the single-mode helium capillary comes within 1.15–1.2 times of
RungeKutta()on both machines. At sixteen threads on the M5 it is already level.the argon blue-shift capillary stays 1.8–1.9 times behind on the M5 and about three times behind on the EPYC. It takes more source calls than Runge–Kutta does. On the EPYC the native Adams passes, not the host, are now the larger part of the gap (§3.3): native work that needs no SEP.
This SEP changes no default, and it does not decide the capillary default.
2. Motivation and scope#
The use case is the default integrator of prop_capillary, which is Multistep()
at rtol 1e-6 (ADR-055). SEP 37’s
phase 0 found RungeKutta() faster on 8 of 14 capillary rows at one thread and on 11 of
14 at sixteen. Multistep won the four-mode rdw and the noiseless comb-to-continuum.
The maintainers kept the capillary default and asked where Multistep’s per-step time
goes.
The 2026-10-28 profiling study of the native CPU backend measured that time (§3.3, with hardware). Its findings, at drafting:
Host cost per trial. A Multistep trial spends 110–140 µs in Python on the M5 and 390–510 µs on the EPYC. The EPYC’s host side alone is 0.4–0.5 ms of a 1.1–2.1 ms trial.
Runge–Kutta’s host cost is 10 µs per call on the M5 and 40–50 µs on the EPYC, spread over six calls a step.
Native passes. Beyond its one source call, a trial’s native passes cost 0.9 to 3.9 source calls by machine and case. Part of that is being removed by ordinary optimisations (§6.9).
The Python pieces on the EPYC, per step:
the stage-scalar packing, twice per trial: 130–156 µs;
the coefficient tables: 126–134 µs;
the compensation tables: 34 µs;
the controller’s decision: 39–47 µs;
the accept: about 20 µs;
the rate weights: 22 µs.
Moving the step loop to native removes the largest remaining host item on every single-mode case.
native/AGENTS.mdkeeps the controller in solprop, which is why this is a SEP.
At the refresh (head of LupoLab/solprop#56, §3.3):
The tables are one native call, and the stage scalars are one bundle packed once.
Per helium trial on the EPYC, the largest remaining Python pieces are:
the stage arrays, about 113 µs instrumented, of which the bundle’s Clenshaw reads are 73;
the decision and the accept call’s wrapper, about 48;
the rate weights, 26;
the landing and the approach, 30;
the order choice and the stencil, 17.
The Python host time per trial, measured lean, is 212 µs for helium and 151–177 µs for argon on the EPYC, and 51 and 38 µs on the M5.
The native passes’ cost beyond the source has become the larger item on the EPYC. There, the predict and correct passes take 844 µs of a helium trial and 304 µs of an argon one, against a source of about 290 and 200 µs. On the M5 they take 269 and 85 µs, against about 155 and 110 µs.
Who needs it. Every native-backend run with Multistep() on a source cheap enough
for the host to matter. The method is the default of prop_capillary, prop_radial and
prop_planar. That covers:
the mode-averaged and single-mode capillaries;
scans of them, where the same small run repeats many times;
device builds, where SEP 16 found that “about 26 launches and 0.2 ms of host work” per trial is what a cheap source cannot hide.
The maintainer wants every bit of performance available here. This SEP is the largest host-side lever, and §6.9 places it among the native levers.
In scope:
a pure, data-driven controller core in solprop, which the Python march and the native march’s tests share (phase 0);
native coefficient tables and native stage scalars (phase 1). These are useful on their own and are the first half of the march;
the native march for the exponential formulation (the default) on the resident engine (phase 2), over:
diagonal operators, whether constant, an expansion or a z table, with the transported compensation;
the carrier frame;
the in-stage collar and the quiet collar;
dense observation and hard kinks;
step_planes,max_callsand statistics rows;the boundary monitor;
snapshots and resume;
the interaction formulation’s fused path, and replayed schedules of single runs (phase 3);
the resident coupled-flow sections and moving maps (phase 4). The aim is to cover every configuration that has a resident engine (Q2);
device builds (phase 5), qualified before
Final(Q6);the seam: a march-policy record, the scalar tables, the host events, one metadata field;
the amendment of
native/AGENTS.md’s policy rule and of ADR-043 (§6.3).
Not in scope:
Any change to the method, the controller’s algorithm, its constants or any default. The native march executes the Python march’s algorithm, and
native/AGENTS.mdforbids tuning the controller for speed.The capillary default, and every other default. Outside this SEP’s scope (decided, Q10).
Fewer steps on the argon blue-shift cases. Multistep takes 11 169 calls there against Runge–Kutta’s 10 105, so no overhead work wins that case (§4.1). That needs a method change, not an execution change.
Runge–Kutta’s host loop. Its cost per call is small. A native Runge–Kutta march is not planned, and can be taken up another time if it is needed (decided, Q8).
The fixed 90–190 ms per run that goes to metadata, provenance, output and plane observation. It is the same for both methods. This draft adds it to the roadmap as its own item.
The general Adams path (
ComparisonMultistep: PECE, explicit Adams–Bashforth, matched pairs), lockstep batches, and a compiled march fornumba_cpu(§6.6).A controller resident on a GPU. On device builds the controller runs on the host thread (§6.7).
The native optimisations of §6.9. They need no SEP and proceed independently. That includes the cost of the exponential Adams passes themselves, which on the EPYC is now larger than the host’s share (§3.3).
3. Context#
3.1 The march today#
solprop.backends.multistep.AdamsMarch is the step loop of every backend
(stepper.md, “Low-call Adams methods”). ADR-043
(decisions.md) split it from its arithmetic:
AdamsMarchkeeps the controller – the stencil, orders, rejection, restarts, the call budget, the ledger, schedules and lockstep – and hands every state-sized operation of its fused and exponential paths to an engine.
The reason it gave: “one controller for every backend keeps the order, rejection and lifecycle rules in one place, tested once.”
On native the engine is _ResidentAdams (native/src/solprop_native/backend.py). The
history, the predictor, the source, the corrector and the defect norms are resident.
GeneralWorkspace::adams_exponential_trial (native/src/general.cpp) evaluates the
source in C++ inside the same call: it runs the stage pipeline with the GIL released and
returns at most three norms. Since the drafting, the trial runs as two fused passes
around its source and the accept as one (LupoLab/solprop#55). What Python still does per
exponential trial, as of LupoLab/solprop#56:
hoists the anchor. This is normally a cached Clenshaw value, with no native call.
forms all of the trial’s coefficient tables in one native helper call (
_core.trial_tables, the strictlagrange::trial_tables; LupoLab/solprop#55). On a varying operator it also forms the endpoint’s rate weights, which is one more Clenshaw call, uncached (_rate_value).forms one stage-scalar bundle (
translate.stage.scalars): one native Clenshaw call per scalar table whose cache misses, typically four to six. The bundle is packed into one stage row and repeated into the Runge–Kutta-shaped stage arrays the trial binding takes (_repeated_arrays). The lab-frame sample reuses the step’s arrays with the \(\Phi\) increment zeroed (_resident_sample). z tables, the separable operator, SEP 6’s dispersive channels and moving maps keep the general packing (_trial_arrays,_weight_rows).forms the carrier shift from the reference element’s chord, on the host, through Numba’s Clenshaw.
makes the one native trial call, whose binding converts 28 arguments.
runs the controller (
_stencil,_decide,_choose_order,_reject,_factor,limit_step), the proposal caps (_capped,_quiet_capped,_collar_reentry) and the landing rule (stepper.schedule.landing,_approach), which now search float tuples withbisect.makes one native accept call when the trial is accepted.
adds a
table_evaluatecall, and atable_ratecall when compensated, on a z-table run.runs the driver’s per-step bookkeeping:
_recordon every attempt (history row, progress), whose backend hook calls the nativepromote_fsalon every accepted step;_take_step_statson every accepted step (the monitor’sdue/skip, statistics rows);_natural_stop;committing()around the accept.
So an exponential trial crosses the seam \(k + 2\) times when rejected and \(k + 4\) times when accepted, where \(k\) is the number of scalar tables it reads. A compensated trial adds one crossing for the rate.
The low-storage march of
SEP 24 already makes
a step one native call (_ResidentLowStorage.step). It only replays a prescribed
schedule and keeps no estimate (ADR-050), so it has no controller to move. It is the
precedent for the commit pattern (§6.5.7), not for policy.
3.2 What the earlier proposals measured and left#
SEP 14, phase 2. A Kokkos Adams attempt on the canonical capillary took 0.67 ms, of which 0.29 ms (43 %) was the host march. The native step was “left for a later change”.
-
On a device, compiled Clenshaw and Lagrange helpers took the canonical trial’s host work from 0.50 ms to 0.13 ms.
On the coupled wall, a quarter of a step (0.37 ms) was “the controller’s and the tables’ Python, which a device’s faster kernels will not shrink”.
SEP 25 §6.3 notes that Kokkos “still pays host table evaluation, controller work and records” per trial.
backends.md §3.5 rejected a compiled step driver because the per-call overhead was below the noise floor. That was measured on Runge–Kutta, where a step makes six calls and spreads the cost. An Adams step makes one call and pays all of it.
3.3 The measurements this SEP starts from#
Method. Measured on main at 6216178, as complete runs of three tracking cases,
timed by the benchmark worker after its warm-up, with the suite’s thread pools and the
passive OpenMP policy (ADR-056). The cases:
he_rdw_averaged: helium on a pressure gradient, shape (1, 1, 1, 2561). Its expansion operator gives the exponential trial the transported compensation.argon_blueshift_averaged: argon at constant pressure, shape (1, 1, 1, 1601).rdw: four modes on the helium gradient, shape (1, 4, 1, 2561).
How the costs were taken:
Native time per labelled kernel: a host-only Kokkos Tools timer.
Host time: timers wrapped around the march’s Python pieces. The host gap is wall time less Kokkos time.
Hardware:
Apple M5 Max: macOS, Homebrew Clang 23.1.3,
-mcpu=native, a shared workstation, so these numbers are indicative;AMD EPYC 7763: Linux, 16 cores of one NUMA domain, GCC 16.2, glibc 2.34, OpenBLAS 0.3.34, FFTW 3.3.11,
-march=native.
A/B comparisons: fresh processes, interleaved over three rounds, medians.
The run’s fixed host cost is excluded from the per-trial figures below. It is 90–190 ms per run (122 ms on the EPYC) for metadata, provenance, output and plane observation, and it is the same for both methods.
Per trial (µs), one thread. “Passes” are the exponential trial’s native work beyond its source: phase, moments and predictor (with the compensation), corrector, defect norms, history, mask and copies. RK’s figure is per source call, with its host share in brackets.
Case |
Machine |
Source |
Passes |
Host |
Trial |
RK per call |
|---|---|---|---|---|---|---|
|
Apple M5 Max |
174 |
306 |
140 |
623 |
219 (10) |
|
Apple M5 Max |
121 |
110 |
108 |
338 |
138 (9) |
|
Apple M5 Max |
2631 |
1352 |
195 |
4123 |
2505 (34) |
|
Apple M5 Max |
1382 |
971 |
228 |
2508 |
1083 (34) |
|
AMD EPYC 7763 |
323 |
1274 |
≈500 |
≈2100 |
441 (49) |
|
AMD EPYC 7763 |
239 |
475 |
≈405 |
≈1120 |
291 (39) |
Notes on the table:
The pieces come from separate profiles, so a row’s sum differs from its trial by up to 3 %.
The single-column cases do not thread at sixteen: they lie below the stage pool’s size threshold (8192 elements on macOS, 4096 on Linux). Their sixteen-thread host costs were 134 µs (helium) and 111 µs (argon) on the M5.
On the EPYC the host side is a quarter to a third of a trial.
The coefficient tables alone cost 126–134 µs a step on the EPYC and 15–34 µs on the M5.
Complete runs, baseline (s).
Case |
Machine, threads |
|
|
|---|---|---|---|
|
Apple M5 Max, 1 |
0.967 |
0.705 |
|
Apple M5 Max, 16 |
0.973 |
0.692 |
|
Apple M5 Max, 1 |
3.863 |
1.451 |
|
Apple M5 Max, 16 |
3.840 |
1.465 |
|
Apple M5 Max, 16 |
4.627 |
11.60 |
|
AMD EPYC 7763, 1 |
3.150 |
1.389 |
|
AMD EPYC 7763, 1 |
12.61 |
3.012 |
|
AMD EPYC 7763, 1 |
22.03 |
59.06 |
|
AMD EPYC 7763, 16 |
21.32 |
47.43 |
|
AMD EPYC 7763, 16, shared node |
3.392 |
1.654 |
|
AMD EPYC 7763, 16, shared node |
12.60 |
2.969 |
The rdw RK figure on the M5 at sixteen threads comes from a profile run and was not
interleaved with the others.
Steps and calls.
argon_blueshift_averaged:Multistep: 11 168 trials, 11 169 source calls.
Runge–Kutta: 1 684 steps, 10 105 calls.
he_rdw_averaged:Multistep: about 1 400 trials.
Runge–Kutta: about 2 700–2 900 calls.
rdw:Multistep: about 1 800 trials.
Runge–Kutta: about 10 500 calls.
The helium and rdw counts are inferred from each run’s time and its per-trial or
per-call cost, so read them as about ±5 %. Phase 0 replaces them with counted values,
in a public benchmark entry (§7.6). The counted values below show that the rdw
Runge–Kutta count was overestimated by about 2.5 times.
At the refresh: the head of LupoLab/solprop#56 (a500d2d), 2026-10-28. LupoLab/solprop#51,
#55 and #56 had landed: the φ-functions’ stopping test, Range2 tiling, the host-cost
work, the fused two-pass trial, and SLEEF vector maths on x86-64 (§6.9).
Method.
The benchmark cases at their registry definitions, run by
benchmarks.worker.timed_runafter its warm-up, with the suite’s thread pools and passive policy.Fresh processes, Multistep and Runge–Kutta interleaved in alternating order over three rounds, medians reported.
h2_raman_averagedruns at a length fraction of 0.4, andrdwat 0.25.Host time per trial is measured lean: the march’s wall time less its native
adams_*calls, with no other wrappers.Steps and calls are counted, not inferred.
The AMD EPYC 7763 machine is as at drafting (GCC 16.2, SLEEF’s AVX2 functions), on 16 cores of one NUMA domain of a node shared with other jobs.
The Apple M5 Max (Homebrew Clang 23.1.3, FFTW with NEON codelets, the system libm) was otherwise idle.
The EPYC’s sixteen-thread rows scattered by more than their effects on the shared node, several of them slower than at one thread, so only its one-thread rows are used.
Complete runs, one thread (s), and the host and native time per trial (µs).
Case |
Machine |
|
|
MS / RK |
Host |
Native |
|---|---|---|---|---|---|---|
|
Apple M5 Max |
0.815 |
0.650 |
1.25 |
51 |
448 |
|
Apple M5 Max |
2.920 |
1.419 |
2.06 |
38 |
213 |
|
Apple M5 Max |
1.755 |
1.311 |
1.34 |
40 |
263 |
|
Apple M5 Max |
1.711 |
2.736 |
0.63 |
129 |
3 730 |
|
AMD EPYC 7763 |
2.126 |
1.528 |
1.39 |
212 |
1 182 |
|
AMD EPYC 7763 |
9.17 (7.83–9.47) |
2.460 |
3.7 (3.2–3.9) |
177 |
632 |
|
AMD EPYC 7763 |
4.820 |
2.146 |
2.25 |
173 |
695 |
|
AMD EPYC 7763 |
4.157 |
5.440 |
0.76 |
372 |
9 245 |
At sixteen threads on the M5:
Case |
|
|
MS / RK |
|---|---|---|---|
|
0.636 |
0.650 |
0.98 |
|
2.474 |
1.381 |
1.79 |
|
1.697 |
1.652 |
1.03 |
|
0.862 |
1.256 |
0.69 |
Steps and calls, counted (accepted/rejected, then source calls):
Case |
|
|
|---|---|---|
|
1 434/3, 1 438 |
480/1, 2 887 |
|
11 166/2, 11 169 |
1 684/0, 10 105 |
|
5 417/2, 5 420 |
1 308/0, 7 849 |
|
393/1, 395 |
175/0, 1 051 |
Where a trial’s native time goes. Per trial, from a host-only Kokkos label timer (µs). “Source” is the rest of the Kokkos-active time.
Case |
Machine |
Predict pass |
Correct pass |
Source |
|---|---|---|---|---|
|
Apple M5 Max |
169 |
100 |
≈155 |
|
Apple M5 Max |
51 |
34 |
≈110 |
|
AMD EPYC 7763 |
557 |
287 |
≈290 |
|
AMD EPYC 7763 |
192 |
112 |
≈200 |
On the EPYC the two Adams passes now cost 1.5 to 2.9 source evaluations a trial, while the host costs 0.6 to 0.9. Their per-element cost is 2.9–3.8 times the M5’s, against about 1.9 times for the source. That is native work outside this SEP (§6.9): the march removes the host’s share, not the passes’.
Prototypes. Every prototype took the baseline’s accepted and rejected steps on every case, with final fields within 4e-10 of the baseline. They are listed with their results in §6.9.
3.4 The rules this SEP meets#
native/AGENTS.md, Product and architecture:The layers are solprop -> Python backend adapter -> nanobind seam -> a Python-free C++20 core. Native code never imports Python or solprop concepts and never owns user-facing policy.
The same file, Numerical and performance discipline: “never tune the step-size controller to gain speed”; and a trial “performs no allocation, planning, logging, Python call or unplanned fence”.
ADR-043: one controller for every backend (§3.1).
ADR-047: “Native code is not interrupted: a signal waits for a native call to return.”
ADR-054: the controller, scalars and schedules are binary64 on the host. A native controller keeps both properties: binary64 arithmetic, run on the host thread.
SEP 0 §7: backends in order (
numpy_ref, thennumba_cpu, then native), and parity across all three atFinalor an exception written into the accepted SEP.
4. Theory#
This section is the algorithm the native march executes, written so that it can be
implemented and checked without reading backends/multistep.py. The Python march is its
reference implementation, and a disagreement between the two is a defect in one of them.
Notation follows SEP 13 and SEP 16:
\(z\) is the position in m and \(h\) a step in m.
\(\theta = (z' - z)/h\) is a normalised position inside the step anchored at \(z\).
\(x = \Phi(z + h) - \Phi(z)\) is the step’s exponent, in the internal convention (conventions.md).
4.1 Why the host matters: the cost of a run#
A Multistep trial costs
where:
\(C_N\) is the source evaluation;
\(C_A\) is the Adams passes;
\(C_H\) is the host side.
A Runge–Kutta call costs \(T_{\mathrm{RK}} = C_N' + c_H\), where \(C_N'\) includes the stage’s entry propagator and \(c_H\) is the host’s share of one call. Multistep is faster when
with \(n\) the source calls of each method. The drafting measurements of §3.3 gave these ratios:
Case |
\(n_{\mathrm{MS}}/n_{\mathrm{RK}}\) |
\(T_{\mathrm{RK}}/T_{\mathrm{MS}}\), M5 |
\(T_{\mathrm{RK}}/T_{\mathrm{MS}}\), EPYC |
The same with \(C_A = C_H = 0\), M5 / EPYC |
|---|---|---|---|---|
|
≈0.50 |
0.35 |
0.21 |
1.26 / 1.37 |
|
1.105 |
0.41 |
0.26 |
1.14 / 1.22 |
|
≈0.17 |
0.43 |
— |
0.78 / — |
The refresh’s counted runs (§3.3, one thread) give these, with the fixed cost per run (0.09 s on the M5, 0.12 s on the EPYC) taken off before dividing:
Case |
\(n_{\mathrm{MS}}/n_{\mathrm{RK}}\) |
\(T_{\mathrm{RK}}/T_{\mathrm{MS}}\), M5 |
\(T_{\mathrm{RK}}/T_{\mathrm{MS}}\), EPYC |
The same with \(C_A = C_H = 0\), M5 / EPYC |
|---|---|---|---|---|
|
0.498 |
0.38 |
0.35 |
1.25 / 1.68 |
|
1.105 |
0.52 |
0.29 |
1.20 / 1.16 |
|
0.376 |
0.61 |
0.49 |
— |
Three conclusions follow, and they hold at the refresh.
Helium: Multistep makes half the calls and loses only through \(C_A + C_H\). At drafting the host was \(C_H/T_{\mathrm{MS}}\) = 22 % of a trial on the M5 and 24 % on the EPYC. At the refresh it is 10 % and 15 %, and the passes \(C_A\) are 53 % and 60 %.
Argon: even at zero overhead Multistep would be at most 4–9 % faster (1.105 against 1.16–1.20), and any real \(C_A\) loses that. That case needs fewer steps.
rdw: Multistep already wins, and cutting \(C_A\) or \(C_H\) only widens the margin.
4.2 The march’s policy and state#
The policy \(\mathcal{P}\) is everything the algorithm reads but never changes. solprop
forms it once per run, from the method (Multistep), the run’s stepper settings
(kernel-contract.md §7) and the march’s module constants:
Symbol |
Meaning |
Source today |
|---|---|---|
\(q_{\max}\) |
highest predictor order, 1–6 |
|
— |
variable order |
|
\(s\), \(g\), \(f_{\min}\) |
safety, growth cap, smallest factor |
|
\(g_{\mathrm{rej}}\) |
growth cap of a rejection’s factor |
the literal 0.9 in |
\(H\) |
order hysteresis |
|
\(R\) |
consecutive rejections before a history restart |
|
\(c\), \(P_k\) |
collar stability share; PEC negative-real interval of order \(k\) |
|
\(\rho\) |
the in-stage collar’s largest rate, \(\max\alpha/2\) in m⁻¹; 0 without one, outside the exponential formulation, or under the split scheme |
|
\(C_\kappa\) |
carrier phase cap, rad, when a carrier frame is active |
|
\(B\) |
the call budget, or none |
|
— |
equal approach; interval cap |
|
\(h_0\), \(h_{\min}\), \(h_{\max}\), \(\sigma\) |
first proposal, floor, cap, stretch |
|
\(z_{\mathrm{end}}\) |
end of the run |
|
\(\mathcal{Z}_s\), \(\mathcal{Z}_k\), \(\mathcal{Z}_b\) |
save planes, hard kinks, interval edges |
|
\(u\) |
the landing tolerance in units in the last place |
|
\(r_{\mathrm{tol}}\), \(a_{\mathrm{tol}}\), \(w\), region |
the norm’s scale and the bins it covers |
|
\(I_Q\) |
the quiet collar’s step cap |
|
The state \(\mathcal{S}\) is everything the algorithm changes:
the position \(z\) and the proposal \(h_p\);
the order \(q\);
the history entries \((z_j, \iota_j, \kappa_j)\), oldest first, with \(\iota_j\) the identity (accepted or predicted) and \(\kappa_j\) the engine row;
the consecutive rejections \(r\);
whether the last attempt was rejected, \(\lambda\);
whether the collar was quiet at the last attempt, \(w_Q\);
the call ledger:
startup,predictor,rejected,accepted_steps,rejected_steps,restarts,failure_restarts,dense_observations, and the order of every accepted step.
Two inputs come from the host at every event: the collar’s quiet flag \(Q\), set by the boundary monitor, and the event bounds of §4.8.
Comparison semantics. Wherever this section writes \(\min\) or \(\max\) of two values,
it means exactly what the reference does. \(\min(a, b)\) is \(b\) if \(b < a\), else \(a\).
\(\max(a, b)\) is \(b\) if \(b > a\), else \(a\). \(\arg\max\) over a list keeps the first element
unless a later one compares strictly greater. These forms decide what a NaN does, and
they equal C++’s std::min and std::max of two arguments. A comparison with a NaN is
false.
4.3 One attempt#
Given \(\mathcal{S}\) and a march target \(z_T\):
Budget. The attempt costs \(c = [\text{history empty}] + 1\) calls. If \(B\) is set and the calls charged so far plus \(c\) exceed \(B\), stop with the event budget. The host raises
ResponseBudgetErrorwith the last accepted position and state.Start. If the history is empty:
add one to
startup, before the evaluation, as the reference does: a call that fails has still been charged (R4);evaluate the source once at the accepted state;
set the history to \([(z, \text{accepted}, 0)]\);
set \(q = 1\) under variable order, otherwise \(q = q_{\max}\).
Proposal. Apply three caps in turn:
The carrier cap. With a carrier frame active, let \(e = \min(z + h_p, z_{\mathrm{end}})\) and let \(\phi\) be the chord phase of the reference element over \([z, e]\). If \(\phi > C_\kappa\), set \(h_1 = (e - z)\,C_\kappa/\phi\); otherwise \(h_1 = h_p\).
The quiet cap. \(h_2 = \min(h_1, I_Q)\) while \(Q\); otherwise \(h_2 = h_1\).
Collar re-entry. If the collar was quiet at the last attempt and is not now, and \(\rho > 0\), set \(h_3 = \min(h_2, c P_q/\rho)\); otherwise \(h_3 = h_2\). Then record the current \(Q\) as \(w_Q\).
Landing (§4.6). This gives the step \(h\), its end \(z_e\), and whether \(z_e\) is a hard kink.
Stencil.
Nodes \(\theta_j = (z_j - z)/h\) and rows \(\kappa_j\), newest first; \(n\) entries held.
The step’s order is \(q' = \min(q, n)\).
It may rise when the last attempt was not rejected and more entries are held than it uses: \(\mathrm{rising} = \neg\lambda \wedge n > q'\).
The scored orders are \(O = [q']\), followed, under variable order, by \(q' - 1\) and then \(q' + 1\), each kept when \(1 \le k \le q_{\max}\), \(k \le n\) and (\(k < q'\) or rising).
Trial.
Add one to
predictor, before anything is evaluated, as the reference does.Form the coefficient tables of §4.7 from the nodes.
Evaluate the source once.
Reduce the scaled defect norms \(\varepsilon_k\) for \(k \in O\) (SEP 16 §4.2; stepper.md). The scale is $r_{\mathrm{tol}},w,\lVert u_n \rVert
a_{\mathrm{tol}}\(, with \)u_n$ the accepted state before the step, over the norm mask.
Decide (§4.4), and record the attempt (§4.8).
Steps 6 and 7 are the engine’s and the controller’s; everything else is the march’s.
A prescribed schedule (phase 3) is AdamsMarch._replay_to, with its own stopping,
landing and kink rules (R6). The state adds the schedule’s cursor \(m\), which is
StepDriver._schedule_index; a load rebuilds it from the position
(_schedule_index_at), as today. The march uses:
the schedule’s precomputed edges \(E\) (
FixedSchedule.edges, the running sum of its steps);the slack \(\delta\) =
replay_tolerance(schedule, z_T). This is \(\max(u, n_{\mathrm{steps}})\) units in the last place of the larger of \(z_T\) and the schedule’s length, because a sum of \(n\) rounded steps drifts by about \(n\) ulps.
While \(m < n_{\mathrm{steps}}\) and \(z < z_T - \delta\), each attempt:
takes \(h\) =
steps[m]and \(z_e = E_{m+1}\), with no caps and no landing rule;treats \(z_e\) as a kink when some hard kink lies within \(\delta\) of it;
runs steps 1, 2, 5 and 6, accepts every finite norm, and appends it to the accepted norms as
_replay_todoes;advances \(m\) in the same commit as the position.
A non-finite norm is the event diverged, which the host raises as ConvergenceError
with the reference’s message. If \(z < z_T - \delta\) when the schedule is used up, the
event is exhausted, which the host raises as the reference’s BackendError. The
adaptive test reached is not used on a replay: the schedule ending 12 ulps short of
1e-3 in replay_tolerance’s own example must still count as arriving.
4.4 Acceptance, rejection and the step-size factor#
The factor an order-\(k\) defect allows is SEP 13’s elementary controller: the exponent is the defect’s own, \(1/(k+1)\) [1, 5].
The next proposal is limit_step:
If \(h' < h_{\min}\), stop with the event floor. The host raises ConvergenceError with
the reference’s message.
Let \(\varepsilon = \varepsilon_{q'}\). Reject when \(\neg(\varepsilon \le 1)\); a NaN rejects. On a rejection:
add one to
rejected,rejected_stepsand \(r\), and set \(\lambda\);if \(r \ge R\) and more than one entry is held, empty the history, set \(r = 0\) and add one to
failure_restarts;take the factor \(f = f_{\min}\) if \(\varepsilon\) is not finite, otherwise \(F(\varepsilon, q', g_{\mathrm{rej}})\);
set \(h_p = \)
limit_step\((h, f)\).
On an acceptance, in one commit (§6.5.7):
keep the \(q_{\max}\) newest entries;
give the new sample the lowest engine row the kept entries do not use;
accept in the engine: the step’s finish, boundary treatments included, and the store of the new sample with its state and rate on a compensated history;
append \((z + h, \text{predicted}, \text{row})\);
add one to
accepted_steps, append \(q'\) to the ledger’s orders, and set \(r = 0\);choose the stored order \(q_s\), which the next stencil starts from, and the control order \(q_c\) with its norm \(\varepsilon_c\), which set the next proposal’s exponent (§4.5);
set \(h_p = \)
limit_step\((h, F(\varepsilon_c, q_c, g))\), set \(q = q_s\), and clear \(\lambda\);move to \(z = z_e\). At a hard kink, empty the history and add one to
restarts.
A defect in the reference’s commit order. AdamsMarch._decide tests the floor in
step 7 before it moves the position in step 8. A floor raised there leaves the adopted
state at \(z + h\) with the position still at \(z\), and the stored order already changed
by _choose_order. The general path’s _commit has the same order.
It can happen. A landing may be shorter than \(h_{\min}\), since landing never consults
the floor, and the next proposal is formed from the step taken. So an accepted landing
shorter than \(h_{\min}/g\) always raises the floor.
The Runge–Kutta march does neither: it commits the state and position first, and grows its next proposal from the proposal rather than from the landed step.
Phase 0 moves the position into the commit before the floor is tested, in both paths, so that the state, the position, the history and the stored order always agree, and the native march follows that order. Before the floor is raised, the accepted attempt’s row, the kink restart and the ledger are published to the host like any other accepted step (§6.5.7, R3). The rule itself stays for now. If the implementation finds short landings in practice, it may change the rule, recording the change and its evidence in the phase report (decided, Q13).
4.5 Order choice#
Under a fixed order the two orders differ: \(q_s = q_{\max}\), but \(q_c = q'\) and
\(\varepsilon_c = \varepsilon_{q'}\). This is AdamsMarch._choose_order, which stores
method.order and returns the step’s own order.
At startup and after a restart, \(q'\) is below \(q_{\max}\) until the history fills. The
exponent is then the step’s own, not \(q_{\max}\)’s. With Multistep(order=5, variable_order=False), one sample held and \(\varepsilon = 0.25\), the factor is 1.6;
taking \(q_{\max}\) would give 1.0079 (the AI review’s probe, R1).
Under variable order \(q_s = q_c\) is the chosen order \(q_n\) below, and \(\varepsilon_c = \varepsilon_{q_n}\). Every scored order \(k\) has the weight
\(\varepsilon_k^{-1/(k+1)}\) is taken as \(\infty\) when \(\varepsilon_k \le 0\).
\(\Sigma_k\) is the step the in-stage collar leaves order \(k\) stable at (SEP 16 phase 6; ADR-057 for the quiet collar).
\(b = \arg\max_{k \in O} W_k\), taken in \(O\)’s order.
The march adopts \(q_n = b\) when \(b \ne q'\) and \(W_b > H\,W_{q'}\); otherwise \(q_n = q'\). The cap \(\Sigma_k\) weighs only the choice, and the step is the chosen order’s defect’s (SEP 16 phase 6). Without the cap, near the collar’s limit the choice was decided by rounding. That is why §4.9 treats order changes as knife edges.
4.6 Landings, caps and equal approach#
The landing rule is stepper.schedule.landing
(stepper.md §3). Its tolerance is
\(\tau = u\,\mathrm{ulp}(\max(|z|, |z_{\mathrm{end},T}|))\), where:
\(\mathrm{ulp}(y)\) is the spacing of binary64 at \(y\) (
numpy.spacing);\(z_{\mathrm{end},T}\) is the run’s end when planes are interpolated, and the march target \(z_T\) otherwise.
The rule:
Caps. \((h_3, \text{none})\), \((h_{\max}, \text{max step})\) and (the width of the interval of \(\mathcal{Z}_b\) containing \(z\), interval width).
Landings. The first hard kink, the first save plane, and \(z_{\mathrm{end},T}\), each the first at or beyond \(z + \tau\) (
bisect_left).Choice. Both sets choose the shortest, with ties going to the later of none < max step < interval width < hard kink < save plane < end.
If the shortest landing gap is within the shortest cap, land on it.
Otherwise, if it is within \(\min(\sigma \cdot \text{cap}, h_{\max})\), stretch the step to land on it.
Otherwise take the cap and land nowhere.
Equal approach. If the step lands nowhere and equal approach is on, find the next
forced landing \(a\): the nearest of \(z_T\), the next save plane and the next kink strictly
beyond \(z + \tau'\) (bisect_right), with \(\tau' = u\,\mathrm{ulp}(\max(|z|, |z_T|))\), or
none if that nearest is not beyond \(z + \tau'\). If \(h < a - z < 2h\), take
\(h = (a - z)/2\), end at \(z + h\), with no kink.
Kinks. Otherwise the end is the landing’s target, or \(z + h\). It is a kink when:
it is interior, meaning it lies below \(z_{\mathrm{end}} - \tau''\); and
either the landing was a hard kink, or some kink lies within \(\tau''\) of it.
Here \(\tau'' = u\,\mathrm{ulp}(\max(|z_e|, 1))\).
4.7 Coefficient tables#
Every table is a small dense computation on at most \(q_{\max} + 2 \le 8\) nodes.
Lagrange coefficients. For distinct nodes \(\vartheta_0, \dots, \vartheta_{m-1}\) they are the matrix \(L\) with \(\ell_i(\theta) = \sum_p L_{ip}\theta^p\) and \(\ell_i(\vartheta_j) = \delta_{ij}\). That is, \(L = V^{-\mathsf{T}}\) for the Vandermonde matrix \(V_{jp} = \vartheta_j^p\). The integrated weights are
They are exact for polynomials of degree below \(m\) on any distinct nodes [1, 2].
The exponential trial (SEP 16 §4.2) integrates the interpolant against the flow. The moments are
[3, 4]. The engine forms them per element. The host supplies:
the predictor’s \(L\) over \((\theta_0, \dots, \theta_{q'-1})\);
the corrector’s \(L\) over \((1, \theta_0, \dots, \theta_{q'-1})\);
for every scored order \(k\), the factored defect: the divided-difference coefficients \(\gamma_t = 1/\prod_{s\ne t}(p_t - p_s)\) over \(p = (1, \theta_0, \dots, \theta_{k-1})\), and the ascending monomial coefficients of \(\pi_k(\theta) = \prod_{j<k}(\theta - \theta_j)\).
The transported compensation (ADR-042) adds:
the spans \(1 - \theta_j\);
the integrated weights over the predictor’s nodes;
the integrated weights over the corrector’s nodes;
each scored order’s corrector weights less its predictor weights.
The interaction formulation’s fused path (phase 3) needs the predictor’s integrated
weights, the corrector’s, and the same defect rows (fused_tables).
Accuracy. No useful conditioning bound holds over the histories the march admits (R7):
a landing may shorten a step by any ratio while an ordinary save plane keeps the history;
rejections compound \(f_{\min}\);
max_growth,min_factorandequal_approachare settings.
With four unit steps followed by a trial shortened to \(10^{-10}\), the corrector’s nodes are \((1, 0, -10^{10}, \dots, -4\times10^{10})\). Their monomial Vandermonde matrix has an \(\infty\)-norm condition estimate of about \(2\times10^{53}\). That bound says nothing useful about the error of the algorithms actually used (products of roots and divided differences), or of the weights a trial applies.
The native tables use the algorithm of stepper.multistep (_basis_rows). Since
LupoLab/solprop#55 the native backend forms all of a trial’s tables in one strict C++
call, lagrange::trial_tables (bound as _core.trial_tables), which performs the plain
composition’s operations in the same order. The host march on native already reads its
tables from it, and the native march calls the same function in-process. The numpy_ref
and numba_cpu helpers still compose the tables from _basis_rows. None of them is held
to a condition-number budget. Instead, every helper record’s tables are compared with an
exact oracle: the same tables computed in rational arithmetic from the binary64 nodes,
with Python’s fractions.Fraction and no new dependency (§7.2).
This SEP proposes no new restart or admission rule for extreme node ratios. If the corpus shows the reference losing accuracy on histories the march admits, that is reported to the author as a question of numerical policy.
4.8 Stage scalars along z, and the host events#
Stage scalars. A trial evaluates its source at \(z + h\) and reads the stage scalars
there (kernel-contract.md; translate.stage): the weights of
\(\Phi\) and of the prefactor, the density, the taper scale, the Raman decay rates and
densities, the Kerr density, the plasma channels’ densities and pre-ionised fractions,
and the dispersive Kerr densities. The flow section and weights, and a moving guide’s
transform weights, are phase 4.
Each scalar is a piecewise Chebyshev table \(s(z) = \sum_n a^{(i)}_n T_n(\xi)\) on interval
\(i\), evaluated by Clenshaw’s recurrence. The weights of an expansion operator are
integrated Chebyshev tables, and the increment’s weights are \(W(z_1) - W(z_0)\). A
varying operator’s rate \(D(z)\) is the derivative of the same representation, taken on
the closing side at a kink (translate.linop.phi_rate, before=True). A z table is
already evaluated natively into term slots (SEP 29 §6.6).
The native march evaluates every table in C++ with the strict Clenshaw helper that the
native backend’s Helpers already use (_core.clenshaw_points). Its bundles therefore
equal the ones the backend forms today through translate.stage.scalars_at, to the bit.
That is a structural property, tested exactly (§7.1). The tables are uploaded once per
run.
One input is formed differently. The carrier frame’s chord is evaluated today on the
host through Numba’s Clenshaw (CarrierChord.exponent). Native forms it from its
resident \(\Phi\) terms, or from a z table’s slots, so the carrier cap and the carrier
shift may differ from the host’s by rounding.
Host events. The march returns to the host only when the host has work, and every return is drained completely (R5).
At entry, the host does what
march_todoes today before its loop:it restores a march held ahead of a dense observation;
it returns at once if a natural stop is due (
_natural_stop_due) or the target is reached;otherwise it passes the bounds below.
No call is made when no attempt is due.
Before each attempt, native tests the budget (§4.3, step 1).
After each attempt, accepted or rejected, it tests the call bound, and returns any error.
After each accepted attempt, it tests the accepted-step events, with the host’s own expressions:
Event |
Native test after an accepted attempt |
|---|---|
target reached |
|
crossed (dense observation) |
\(z > z_T\) with planes interpolated |
host step |
the accepted-step count since entry reaches \(n_e\), the smallest of: the steps to the next statistics row ( |
monitor distance |
\(z - z_{\mathrm{last}} \ge I_{\mathrm{check}}\), the monitor’s own expression, or its first check |
The other returns have no event step:
Return |
When it is tested |
|---|---|
call bound |
after any attempt: the attempts or the wall time since entry reach the call’s bounds (§6.5.7) |
budget |
before an attempt (§4.3, step 1) |
floor |
when a proposal is formed, after a rejection or an acceptance (§4.4) |
diverged, exhausted |
on a replay (§4.3) |
error |
wherever a native failure occurs. The steps committed before it stand, and the error is returned rather than thrown (§6.5.7) |
Every attempt writes one row into a host buffer: end, step, norm (with a non-finite norm
written as \(\infty\), as _record does), whether it was accepted, its order, and its wall
time since entry. A call can return:
with no rows (the budget, or an error, at its first attempt);
with rows but no acceptance (rejections up to the call bound);
with accepted steps followed by rejections.
Draining. Inside the commit of §6.5.7, the host ingests every row exactly once, in
order. It appends the step-history row, and for an accepted row applies that step’s
accounting exactly once: the monitor’s skip, the statistics and natural-stop counters,
and _accepted_norms on a replay.
Only one accepted row can have host work: the event step, the accepted attempt
after which native returned with an accepted-step event. By construction no earlier
accepted row had any. For the event step the host runs the reference’s own sequence,
_take_step_stats then _natural_stop, unchanged. So coincident events are serviced
in the reference’s order and exactly once: for example, a plane that is also a
statistics row and a monitor check, or a natural stop due on a dense crossing.
A return for any other reason has no event step, and its accepted rows take the skip path only. The other reasons are the call bound, the budget, the floor, divergence, exhaustion and an error.
After the commit, the host:
makes the progress callbacks, one per attempt in order, so a reporter’s display updates once per call (Q12);
writes the debug lines;
does the event step’s work;
raises the error last, if there is one.
So every statistics row, monitor check and natural stop falls on the accepted step it falls on today.
The backend’s accepted-step hook is not replayed. On native, _on_accepted_step
calls promote_fsal, which scores the quadrature alarm from the step’s buffers. The
native march does that scoring itself after each accepted step, where the buffers are
current, and the host’s ingestion does not call the hook.
4.9 What “the same steps” means#
On the native backend, both marches read:
their norms from the same native kernels;
their tables from the same strict
lagrange::trial_tables(§4.7);their stage scalars from the same strict Clenshaw helper (§4.8).
They differ only in:
the carrier chord, and so the carrier cap and shift (§4.8);
the order of the scalar operations that combine the norms, wherever the two implementations order them differently.
On one build, with deterministic transform plans (SOLPROP_FFTW_PLAN=estimate), the
two marches are therefore expected to take the same steps and to produce the same fields
bit for bit outside the carrier frame. Under the default measured plans, two runs of the
same march already differ by up to about 5e-10 on the helium gradient case, because
FFTW’s planner picks codelets by timing (LupoLab/solprop#54). This expectation is a
diagnostic that phase 2 records; it is never asserted (SEP 25).
How far the norms differ is measured per case, not assumed (R8). Phase 1 records, on every qualification case, the distribution of \(|\Delta\varepsilon|/\varepsilon\) between trials run with the host’s inputs and with the native ones. It is expected to be zero outside the carrier frame.
The controller’s own arithmetic is the same IEEE operations on the same inputs: strict,
with the platform’s pow (§6.5.1) [7, 8]. Its outputs are discontinuous functions of
the norms: acceptance at \(\varepsilon = 1\), adoption at \(W_b = H W_{q'}\), and the
landing and kink tolerances.
A decision flips only when the two marches’ inputs straddle a threshold, that is, when its margin is below their difference. Such flips are rare, but they are not impossible. After one flip the two trajectories differ by more than rounding, and they agree only within the case’s accuracy envelope (R10).
So, as SEP 25 §4.4 rules for adaptive runs, “the same steps” is:
expected: the same accepted and rejected sequence, orders and landings on every qualification case;
recorded: with the smallest decision margin of each run;
never asserted: the assertions are an accuracy envelope on the fields and exact structural contracts (§7).
A prescribed schedule with a fixed order has no decisions, so it is a matched computation that agrees to a rounding budget (§7.2).
5. Proposed interface#
No call or setting changes. A user’s script, configuration and output read as before. On
the native backend, Multistep() runs through the native march whenever the
configuration is admitted (§6.6). Otherwise it runs through the Python march as today
and warns once per run with a new SolpropWarning subclass in solprop.exceptions,
the one public addition, naming the reason (Q2).
The run’s backend record says which march ran, so that a timing or a rounding
difference can be traced. It is one more entry in the settings the native backend’s
metadata() reports, which BackendRecord stores outside the resume key, as it stores
the thread pools:
BackendRecord.settings["adams_march"]: "native" | "host" # absent for Runge–Kutta runs
The record is written when the run starts, so a resumed file names the march that started the run, as it names the backend.
A private keyword on the native backend’s constructor (adams_march="host") forces the
Python march. For the benchmark harness, which builds backends through
solprop.backends.select, the same switch is a private environment variable read once
at import. Its users:
the parity tests;
the benchmark harness’s A/B comparisons;
anyone developing the controller, who can change it in Python and run it on native without a rebuild.
It is not part of the documented interface.
6. Implementation strategies#
6.1 Three options, costed#
(A) The controller and the march fully in C++, with the Python AdamsMarch kept as
the reference. Native carries the algorithm and its constants. Its defaults (safety,
hysteresis, restart count, stability intervals, the landing rule’s tolerances) are
written in C++ beside the Python ones, and the host passes only the method’s fields and
the run’s settings.
(B) Policy as data, mechanism native. solprop forms one record \(\mathcal{P}\) per run (§4.2) holding every parameter and constant the algorithm reads. That includes those that are module constants today: the hysteresis, the restart count, the stability intervals, the carrier cap, the rejection’s growth cap and the landing tolerance. At every entry solprop also passes the event bounds and the quiet flag (§4.8).
Native holds no default and no constant of its own. It refuses a record that lacks a
field or holds one out of range, by name. It advances under the algorithm of §4 to the
next host event. The algorithm is written in the kernel contract and versioned by
SCHEMA_VERSION like every other part of the seam, and the Python march stays its
reference.
(C) No controller move. Python keeps the decision. Native does two things:
Forms the coefficient tables from the nodes inside the trial call.
Evaluates the stage scalars from tables uploaded once. Python passes only the trial’s two positions and its phase increment.
The trial and the accept stay separate calls. Fusing the accept into the next trial call was considered and dropped (R2). The accepted state would wait for a next trial that may never come: a save plane, a natural stop, a spent budget, the end of the run or an interruption can intervene, and a monitor verdict can change the next trial’s collar setting after the state must already have been adopted. Phase 2 removes those crossings anyway, because it decides and commits inside the native call.
What each option takes off a trial, per trial, from today’s host cost (§3.3).
Option C. Its parts:
the tables become a native helper (−15 to −34 µs on the M5, −126 to −168 µs on the EPYC with the compensation’s tables);
the stage scalars are evaluated natively (−130 to −156 µs on the EPYC);
the rate weights go native (−22 µs).
The decision (39–47 µs on the EPYC), the march loop, _record, the step bookkeeping
and two crossings per accepted trial stay.
Options A and B remove all of it but a residual:
the controller’s scalar work in C++, a few µs;
one crossing per host event. Events come every eighth accepted step or more often by the monitor’s distance on a quiet window, and at every step while an edge is busy.
the bulk records, about 1–2 µs an attempt.
Per-trial savings:
C |
A |
B |
|
|---|---|---|---|
Saving per trial, Apple M5 Max (helium / argon), µs |
50–80 / 40–70 |
125–135 / 93–103 |
125–135 / 93–103 |
Saving per trial, AMD EPYC 7763 (helium / argon), µs |
265–335 / 240–280 |
460–485 / 365–390 |
460–485 / 365–390 |
Host time left per trial, and the run-level effect:
C |
A |
B |
|
|---|---|---|---|
Host time left per trial, M5 / EPYC, µs |
40–90 / 125–235 |
5–15 / 15–40 |
5–15 / 15–40 |
Seam crossings |
two per accepted trial |
one per host event |
one per host event |
Run-level saving alone, helium (EPYC, of 3.15 s) |
0.38–0.48 s |
0.65–0.69 s |
0.65–0.69 s |
Run-level saving alone, argon (EPYC, of 12.6 s) |
2.7–3.1 s |
4.1–4.4 s |
4.1–4.4 s |
Ownership, rules and risk:
C |
A |
B |
|
|---|---|---|---|
Who owns the parameters |
solprop |
native, with solprop mirroring it |
solprop; native has none |
|
kept |
broken: native owns policy |
kept in intent; its wording amended (§6.3) |
ADR-043 |
kept |
replaced |
amended: one algorithm, two executions, Python the reference |
Risk |
leaves a third of the EPYC’s host cost |
two sets of constants that drift apart |
a specification and a trace test to maintain |
The M5 savings for C take the M5’s measured table cost (15–34 µs) and scale the other
EPYC pieces by the measured 3.5–4 times. The helium figures include the compensation’s
tables and rate weights; argon has a constant operator and needs neither. The residual
for A and B assumes events every eighth accepted step or so. Busy monitors,
step_planes = 1 and row_period = 1 return every step, which phase 2 measures (§7.6,
R12).
What B adds over C. Accepting a second implementation of the controller rests on B’s gain over a completed phase 1, not over today (R12). From the same pieces:
Case |
Apple M5 Max |
AMD EPYC 7763 |
|---|---|---|
|
45–85 µs per trial, 0.06–0.12 s per run |
125–220 µs per trial, 0.18–0.31 s per run |
|
25–65 µs per trial, 0.28–0.73 s per run |
85–150 µs per trial, 0.95–1.7 s per run |
That is 7–14 % of the helium run and 9–23 % of the argon run once the items of §6.9 have landed. These are estimates; phase 2 measures them with N1–N7 held fixed (§7.6).
The same costing at the refresh. LupoLab/solprop#55 took the tables, most of the packing and the landing searches out of the Python per trial, so the remaining host cost is smaller and both phases save less. The pieces come from §3.3’s per-piece profiles, scaled to the lean host time:
C (phase 1) removes the stage arrays (bundle, packing, sample), the rate weights, the tables call’s crossing, the hoist and the carrier shift. It adds back the native evaluation, a few µs.
B over C (phase 2) removes the rest but the residual of 5–15 µs (M5) or 15–40 µs (EPYC).
C |
B over C |
B over today |
|
|---|---|---|---|
Saving per trial, Apple M5 Max (helium / argon), µs |
25–29 / 15–19 |
10–20 / 6–16 |
36–46 / 22–33 |
Saving per trial, AMD EPYC 7763 (helium / argon), µs |
90–110 / 60–80 |
70–95 / 65–90 |
170–195 / 135–160 |
Run-level, Apple M5 Max, one thread (helium / argon) |
4–5 % / 6–7 % |
2–3.5 % / 2.5–6 % |
6.5–8 % / 8.5–12.5 % |
Run-level, AMD EPYC 7763, one thread (helium / argon) |
6–7.5 % / 7–10 % |
5–6.5 % / 8–11 % |
11.5–13 % / 16–20 % |
The case for B over C is therefore about half what it was at drafting: 2–11 % of a single-mode run on the CPU, by case and machine. The author decided to build every phase with this figure in hand (2026-10-28). The no-regression rule of §7.6 is the guard against the march costing more than it saves. On device builds the comparison is different in kind (§6.7).
6.2 Recommendation: B, built on C#
B and A cost the same in time and in C++, since both put the whole loop in native code. They differ in who owns the numbers:
Under A, a change to a constant, or a default chosen in C++, can drift from the Python march. A reviewer has to read C++ to know what a run did.
Under B, there is one source of every number (solprop’s record), one specification of the algorithm (the kernel contract), and a test that drives both implementations over the same norm sequences and compares every output exactly (§7.1).
C is not an alternative to B. It is B’s first phase: the tables and the stage scalars must be native before the loop can be. As a stopping point, C is weaker for three reasons:
On the EPYC it leaves 125–235 µs of Python per trial, still 0.5–0.7 source calls on the cheap cases. At the refresh it would leave about 100–115 µs, half a source call.
It keeps two crossings per accepted trial.
On a GPU it keeps the Python decision between the norm read-back and the next launch.
Recommended: B, built in the phases of §11 with C’s pieces first.
6.3 Does B satisfy native/AGENTS.md?#
The rule has two parts.
“Never imports Python or solprop concepts.” B satisfies this. The record is plain
data validated at the seam, as a KernelProblem is. The march calls no Python, and the
events return to the host instead.
“Never owns user-facing policy.” By intent, B satisfies this too:
Every choice a user can see or set is made in solprop: the method and its fields, the tolerances, the defaults, the landings, admission and refusal, warnings, the monitor’s verdicts, and the error messages.
Native executes an algorithm that the contract specifies in full, as it already executes the stage arithmetic the contract specifies.
The rule’s wording is ambiguous, though. The controller’s structure is behaviour a user observes in the step counts: that the order never rises after a rejection, that three rejections restart the history. A later reader could take B as a breach.
This SEP therefore amends the rule, in phase 2, to:
Native code never imports Python or solprop concepts and never owns user-facing policy: it may execute an algorithm the kernel contract specifies in full – the Adams march’s controller, order choice and landings among them (SEP 39) – with every parameter handed over by solprop and solprop’s implementation as the reference, and it never chooses or defaults a parameter itself.
“Never tune the step-size controller to gain speed” stays as it is. B tunes nothing.
ADR-043 is amended by a new ADR in phase 2. Its reason, “one controller for every backend … tested once”, becomes:
one specification of the controller, in the kernel contract;
one reference implementation, solprop’s;
one native execution, held to the reference by exact decision-trace tests and by accuracy envelopes on whole runs.
Option A would break the rule and need its removal. That is the main reason it is not recommended.
6.4 Layers and the seam#
Layer |
What it holds |
|---|---|
Physics (P) |
Nothing changes. |
Translation (T) |
Uploads the scalar tables once per run, as a new leaf of the native problem (phase 1). |
Backends (B), solprop |
|
Backends (B), native adapter |
|
Backends (B), C++ |
The march, beside |
Driver (D) |
|
The seam changes:
Phase 0. Output schema +1, for the collar flag in
MarchSnapshot(§6.5.9).Phase 1. Kernel schema +1:
the scalar tables leaf;
the trial call takes the positions, the nodes and the scored orders instead of the tables and the stage arrays, and writes one stage row instead of the Runge–Kutta tableau’s rows (§6.5.6).
A contract tuple
SCALAR_TABLES_FIELDSis added.
Phase 2. Kernel schema +1:
MarchPolicyand its tupleMARCH_POLICY_FIELDS;the driver’s bulk bookkeeping hooks, which join
DRIVER_SURFACE(for example_record_attempts, and a counted form of_take_step_stats; the names are the implementer’s).check_build()checks the new tuples at import, as it does the others.
Phase 3. The interaction formulation’s fused trial needs no seam change. A replay passes the schedule’s steps and edges, which the stepper settings already carry.
The exact schema numbers are those current at each phase. At the refresh of
2026-10-28 the kernel schema is 38 and the output schema 27. ADR-058 restarts the output
schema at 1 and renames the kernel’s constant KERNEL_SCHEMA_VERSION before the first
tag; this SEP’s bumps are relative to whatever is current.
The step-loop protocol. ADR-058 reserves two seams for change before 1.0: “the two step loops that one march protocol would replace”, and the split of the integrator from the controller. This SEP touches both, and is meant to make them easier:
The policy record is that split for the Adams march. It holds every number the algorithm reads, separate from the norm’s scale (
PIController.scale).The native march returns to the host only at events. A later protocol shared by the Runge–Kutta and Adams loops should express those events, rather than the per-trial calls of today.
A redesign of the protocol that changes the event set is that later change’s work. It would amend the kernel-contract section this SEP writes.
6.5 The native march, item by item#
6.5.1 The controller#
§4.3–§4.5, in binary64 on the host thread (ADR-054), as scalar C++ in a host-only translation unit of its own. Its inputs are the norms the engine already reduces.
Strict on every build (R8). That unit is compiled with contraction off and without
fast-math on every build, through a per-source compile option rather than fp.hpp’s
macros. The macros do not suffice here:
SOLPROP_NATIVE_STRICT_FUNCTIONalone is empty under Clang, which needsSOLPROP_NATIVE_STRICT_BLOCKin the body;both macros are empty under CUDA and HIP, including their host pass.
A test in the style of tests/native/test_fp.cpp checks, on each supported compiler,
that a product and a sum in that unit round separately. The compilers are GCC, Clang,
the Windows toolchain, and in phase 5 the CUDA and HIP host compilers.
One elementary function. The controller calls only pow, from the platform’s C
library, as Python’s float power does.
The norm’s scale must be the engine’s own: adams_scale from PIController.scale,
which today’s _device_scale path already forms natively. A controller with another
scale runs on the host march. The PI gains are Runge–Kutta’s; the Adams march has
always used the elementary factor of §4.4.
6.5.2 Variable order#
§4.5. The collar weighting needs only \(\rho\), \(c\), \(P_k\), \(Q\) and \(h\), all of which the
march holds. The carrier cap needs the reference element’s chord phase over
\([z, e]\). Native already evaluates the reference element’s \(\Phi\) from its resident
terms, or from a z table’s slots, so the chord is formed natively. Today the host forms
it for ExponentialWeights.
6.5.3 Startup, rejections, hard kinks, restarts#
§4.3–§4.6:
A failure restart and a kink restart empty the history. The next attempt starts it again, paying one startup call.
A rejection leaves the accepted history untouched, as the engine’s held trial already guarantees (SEP 13 §6.1).
6.5.4 Landings and dense observation#
§4.6, with the save planes, kinks, interval edges and run end in \(\mathcal{P}\), and the march target passed per call. Under interpolated planes:
the march returns crossed when an accepted step passes \(z_T\);
the host holds the engine, observes and records as today (
_ahead, the engine’shold,observe,restore; ADR-036, ADR-039);the next call resumes from the held state.
6.5.5 Varying operators#
z tables. Their native slots (SEP 29 G1) are read in-process instead of through
_ZTables.load, which today makes atable_evaluatecall per trial, and atable_ratecall when compensated. The least-recently-used slot set moves into the march (phase 2; phase 1 keeps_ZTables).Rate weights. For an expansion, the closing-side derivative of the integrated weight tables. The host precomputes the derivative tables at translation and uploads them with the scalar tables (phase 1). Today this is an uncached Clenshaw call per compensated trial (
_rate_value). For a z table, the rate slot native already evaluates.The transported compensation. The engine’s existing passes (ADR-042), with the spans and weights of §4.7 formed natively. The sample’s rate row is stored by the accept from the endpoint rate the trial formed.
6.5.6 Stage scalars along gas profiles#
Three ways were weighed:
Evaluate natively (recommended). §4.8: the same strict Clenshaw pass, so the bundles are identical.
Tabulate on a fine grid at translation. Rejected: the values would differ beyond rounding, the grid would need its own error control, and memory grows with profile detail.
Keep on the host. Rejected: since LupoLab/solprop#55 it is the largest single host item (the stage arrays were 94 of a helium trial’s 233 instrumented µs on the EPYC, the bundle’s Clenshaw reads 58 of them), and it would need a host callback per trial, which
native/AGENTS.mdforbids from native code.
One stage row. The trial binding still takes Runge–Kutta-shaped stage arrays: the
Adams passes read row n_stages of the stage buffer it shares with Runge–Kutta, and the
step and the lab-frame sample are uploaded separately. LupoLab/solprop#54 left the
one-row form to this SEP, because it is a seam change. In phase 1 the native evaluation
writes the one row the Adams passes read straight into the stage staging. z tables keep
_ZTables until phase 2. The separable operator, SEP 6’s dispersive channels and moving
maps keep the general packing until phase 4.
The Raman decay rates already drive a native rebuild of the transformed kernel spectrum
when they change (prepare_raman_stage), so nothing moves there.
6.5.7 Commits, signals, errors and the monitor#
Commits. Each accepted step commits in native code, before the next attempt: the
state, the history, the position and the ledger change together. On the host, one
committing() guard spans the whole exchange (R3):
the native call;
the synchronisation of the host’s position, proposal, order, history and ledger with the native march;
the ingestion of every returned row (§4.8, draining).
A signal that arrives during the call is held until all three are done, and only then
raised (ADR-047). Without the guard around the call itself, solprop._interrupts would
raise as soon as Python resumed, with native several commits ahead of the host’s
records. SEP 24’s atomic step guards its native call the same way.
User code runs only after the guard: the progress callbacks, the debug lines and the event step’s work (§4.8). A callback that raises therefore finds the host’s records complete and in step with the native state, and the run loop’s terminal checkpoint writes a consistent state, position, history and ledger. The floor (§4.4) follows the same rule: the accepted attempt, its kink restart and its ledger are published before the floor is raised.
Signals. A signal waits for the native call to return (ADR-047), so a call is bounded:
by its attempts: the size of the row buffer, 256;
by its wall time, from a monotonic clock read once per attempt: 0.1 s (decided, Q3).
Neither is a user setting. A crossing costs an estimated 20–40 µs.
In default runs the monitor’s cadence returns at least every eighth accepted step, so the call bound rarely binds.
When it does, 256 attempts of a trial of 0.3–1 ms take 0.08–0.3 s, and the crossing is at most 0.05 % of that. For a 50 µs trial it would be 0.3 %.
Phase 2 measures the crossings actually made and their cost (§7.6, R12).
A scheduler’s SIGTERM or a keyboard interrupt is then served within that bound, plus
one trial.
Errors. A native failure is caught inside the march: in an evaluation, a reduction, an accept or a scalar-table evaluation. The march stops at the last commit and returns an error record (R4) that holds:
the stage: the startup evaluation, the trial’s evaluation, the trial’s reductions, the accept, or the stage scalars;
the category: a code for the exception class the host march would raise there. These are the classes the adapter raises today:
ConvergenceErrorfrom an envelope failure (_envelope_errors),BackendError, and the built-in errors of the bindings. The class is never inferred from the message.the message.
The ledger returned with it keeps the charges already made. A startup or trial call is charged before it is evaluated (§4.3), so a failed call is counted, as the reference counts it. The host:
synchronises the ledger;
drains the committed rows;
raises the class the code names.
An exception thrown through nanobind in the middle of a call would lose the records of steps already committed.
ConvergenceError and ResponseBudgetError are raised by the host from the floor,
diverged and budget events, with the reference’s messages. The budget error carries
the last accepted state.
The boundary monitor keeps its policy in Python (solprop.monitor): its cadence, its
thresholds, the quiet verdict and the stop.
The march returns when a check is due (§4.8). Busy edges therefore return every step, and quiet ones every
CADENCEsteps or by distance.The backend reads the monitor’s numbers itself (LupoLab/solprop#49): it hands the host five numbers and the split’s removal (
monitor_reading), so a check costs one crossing and a few µs of Python.The quiet flag \(Q\) is passed at every entry. A change of verdict reaches the stages through
_collar_changed, as today.
6.5.8 The call ledger, records and metadata#
The ledger. The ledger counters accumulate natively and are read at every event into
CallLedger. The orders of the accepted steps come in the attempt rows, so the ledger counts the calls actually made (SEP 25 §4.4).The step history’s rows. These are
_record’s rows. Each attempt’s wall time is the call’s start, read on the host withperf_counter, plus the native monotonic offset.Progress. The reporter receives one
stepcall per attempt, in order, in bulk after each native call. Its display therefore updates once per call and lags by at most one call (Q12).The per-step debug log of the Python march is kept. When the
DEBUGlevel is enabled, the host writes one line per attempt from the rows at each event, guarded byisEnabledFor, so the lines cost nothing otherwise (decided, Q12).The record and the configuration hash are unchanged. The backend record’s settings add
adams_march(§5), outside the resume key.
6.5.9 Resume snapshots#
MarchSnapshot keeps its fields (ADR-046; SEP 29 §6.6) and gains one: the collar’s
quiet flag at the last attempt, \(w_Q\) (R9). At an event the host reads the march state:
the position and the proposal;
the order and the rejection count;
\(\lambda\) and \(w_Q\);
the entries’ positions, identities and rows.
It reads the engine’s rows through the existing adams_rows_to_host, losslessly. A load
installs both, restores \(w_Q\), then calls reset on the held trial. A snapshot is
taken only at an event with nothing ahead of it, as today.
Why \(w_Q\). It decides the collar’s re-entry cap (§4.3). Today’s snapshot does not
hold it, so a resumed run starts with it false and skips a cap the uninterrupted run
would take. And begin_run does not reset it, so a backend reused for another run
starts with the last run’s value. Phase 0 adds the field (output schema +1) and resets
it in begin_run, in the Python march first (decided, R9).
Where a snapshot continues.
Snapshot from |
Native march |
Host march on native |
|
|---|---|---|---|
Native march |
yes |
yes: the same engine and rows |
yes, except a frozen-residual coupled flow’s history ( |
Host march on native |
yes |
yes |
the same as the row above |
|
yes, except a frozen-residual flow’s history ( |
the same as the column to the left |
yes |
The existing named refusals stay. No conversion between the two frozen-residual forms is proposed.
What a resume promises (R10).
The transfer is exact. The rows, positions, identities, slots, order, controller state and \(w_Q\) equal the stored ones after a round trip (SEP 29 R17).
The continuation is an adaptive run. A resumed run meets the case’s accuracy envelope against a converged reference, as SEP 25 §4.4 rules. It need not take the uninterrupted run’s steps.
A rounding budget applies only to matched comparisons: a fixed-order schedule with matched history and plane handling.
6.6 Backend order, exceptions and refusals#
The method is not new. Multistep() already runs on numpy_ref, numba_cpu and
native, and the Python AdamsMarch is its oracle on all three. This SEP adds a native
execution of the existing march, as SEP 16 added the native engine. The backend order
of SEP 0 §7 is met as follows:
numpy_reffirst (phase 0). The controller is factored into a pure, data-driven core and the policy record, with no change in behaviour. The decision-trace corpus of §7.1 is generated from it.numba_cpukeeps the Python march. This is the exception this SEP records:Its per-trial Python cost is of the same kind.
But a compiled march there would have to call FFTW plans and the stage pipeline from nopython code, which is the compiled step driver backends.md §3.5 decided against.
Compiling only the decision would save about a tenth of the host cost and keep every crossing.
numba_cpuis being demoted, and backend parity of the march is not needed. No roadmap item is added (decided, Q9).
Native gets the march (phases 1–5).
Admission (R11). One matrix decides how each configuration runs on the native
backend at Final. It covers CPU and device builds alike, since phase 5 comes before
Final (Q6). A configuration is matched on its formulation, its corrector and
interpolant, whether its engine is resident, its norm scale and the build, never on the
method’s type alone. ComparisonMultistep is a subclass of Multistep, and its
one-order-higher PEC variants take the same resident path.
Configuration |
At |
Phase |
|---|---|---|
|
native march |
2 (CPU), 5 (device) |
|
native march |
3, 5 |
|
native march: the resident fused path, whose observation already takes the Hermite rule |
3, 5 |
A prescribed schedule, single run |
native march |
3, 5 |
A coupled flow whose sections all lie on the eigenbasis route, at most 16 channels; a moving transform map |
native march |
4, 5 |
|
warned host march (host arrays, as today) |
— |
A coupled flow off the eigenbasis route or wider than 16 channels (the host engine, ADR-044) |
warned host march |
— |
A norm scale other than |
warned host march |
— |
Lockstep batch replay ( |
warned host march |
— |
The untransported compensation (a private test witness); the private |
host march, no warning |
— |
Everything |
refused by name, unchanged |
— |
The warning (decided, Q2). A run that takes the warned host march warns once, when
the backend is constructed, with a SolpropWarning subclass, for example
HostMarchWarning. It names the reason and the native march’s coverage, and the choice
is recorded in adams_march.
This is not a fallback between backends: both marches execute the same method on the
same backend, and each meets the case’s accuracy envelope (R10). The same already holds
when _adams_engine returns the host engine for a flow off the eigenbasis route
(ADR-044). Warnings are errors in the test suite, so a test of a warned configuration on
native asserts the warning.
While the phases are under way, the rows of later phases run the warned host march,
devices until phase 5 included. That is a state of the branch, not of Final.
Inside the march, a record with a missing or out-of-range field is refused by name with
a BackendError before anything is allocated.
6.7 GPU builds#
On a device build the march runs on the host thread. For each trial it:
forms the tables and scalars in C++ (a few µs);
uploads them in one copy, from a pinned host buffer instead of today’s two unpinned ones;
launches the trial’s kernels;
reads back at most three norms (one planned synchronisation, already there today);
decides, then launches the accept.
The divisor is already formed on the device (adams_upload_divisor), so the only
read-back is the norms.
SEP 16 measured 0.13–0.2 ms of host Python per trial on a device, against kernels of 3–9 µs. LupoLab/solprop#55 has since cut the host’s share by more than half on the CPU, and the march removes nearly all of what is left.
The fused trial of LupoLab/solprop#55 also runs on devices, with blocks of one element. The trial’s own work is two launches around the source, and the accept is one. The source’s own launches remain; phase 5 counts them. Graph capture would address them later. A controller resident on the device is out of scope (§10).
Device qualification follows the standing policy (backends.md): in one
pass, in a GPU segment with its own budget, as phase 5 and before Final (Q6). Until then
a device build runs the host march, records it and warns (Q2).
6.8 Cost: time, memory, code#
Memory. The attempt-row buffer (256 × 7 doubles), the march state (a few hundred bytes) and the scalar tables (kilobytes) are all listed in
memory_ledger(). Nothing state-sized is added.Time. This is the per-trial saving of §6.1. It scales with the number of trials, not with the grid, so it matters most on cheap single-mode sources and least on expensive multimode ones.
Code.
About 1 500–2 500 lines of C++: the march, the tables, the scalar evaluation, the events, the bindings and the C++ tests.
About 500 lines of Python: the controller core, the policy record and the adapter.
A parity test matrix.
The lasting cost is two implementations of one algorithm. Any change to the controller becomes a seam change: a schema bump, and both halves changed in the same commit. The host march stays available on native for controller research.
6.9 The native work that needs no SEP, and where the march sits#
These items change no algorithm and leave results unchanged to rounding, or exactly.
They follow the working agreement of AGENTS.md, each with a benchmark ledger entry.
At the refresh of 2026-10-28 all but N6 had landed as ordinary changes, three of them
together in LupoLab/solprop#55. The table keeps the draft’s description and its
prototype’s figures, with the status and the merged change’s own measurements beside
them.
Item |
Results |
Prototype’s gain |
Status and the landed change’s measurement |
|---|---|---|---|
N1. Fewer threaded regions. The trial as three passes: phase, magnitude, moments and prediction as one reduction; the source; correction and defect norms as one reduction, with the divisor inline. The restore copy becomes a view swap, and the accept’s mask and store become one pass. Cheap passes stay separate where they do not thread. |
rounding |
|
Landed with N2 (LupoLab/solprop#55): two passes around the source, blocked by 32 elements. At 16 threads |
N2. A threading size class for the Adams passes. Sized by elements × moments: per element the passes cost about ten times a stage entry, so the stage pool’s threshold leaves them serial on every single-column case. |
rounding (reduction order) |
On top of N1 at 16 threads: helium −24 points (M5, a tie with RK), −8 (EPYC); argon 0 (M5), −7 (EPYC). |
Landed as |
N3. |
exact |
|
Landed (LupoLab/solprop#55) with tiles of about 64 samples: |
N4. The φ-functions’ stopping test and bookkeeping. Squared magnitudes, exact |
rounding (bit-identical on |
One thread: −4 to −6 % (M5), −8 to −11 % (EPYC). |
Landed (LupoLab/solprop#51): one thread on the M5, argon −7 %, helium −9 %. |
N5a. The newest sample’s carry is the flow. \(e^{(1-\theta_0)x}\) with \(\theta_0 = 0\) is \(e^{x}\), already formed, so one of the compensation’s six exponentials per element is free. |
exact |
about 1/6 of the compensation’s exponentials (≈40 µs of the helium trial on the EPYC; estimated) |
Landed in N1’s change, bit for bit. |
N5b. Vector |
the draft expected a few units in the last place per exponential, requalified under SEP 25 |
Estimated −0.25 to −0.35 ms per helium trial on the EPYC. |
Landed differently (LupoLab/solprop#56, ADR-059): SLEEF’s 1.0-ulp functions on x86-64 only; Arm keeps the system libm, since SLEEF’s AdvSIMD was 11.6 % slower on the M5. Within the 1-ulp bound the budgets already assume. One thread on the EPYC: −8.8 % geometric mean over eleven tracking cases. |
N6. |
not value-identical under GCC: with the same steps the final helium field moved by 1.9e-10 relative and argon’s by 9e-14 (GCC 16.2), within SEP 25’s rules but to be stated when adopted |
−1.9 to −2.7 % alone, −12 to −14 % with N4 (EPYC, one thread) |
Not adopted; a roadmap item. |
N7. Host micro-work. The factored defect rows without |
rounding / exact |
−19 and −10 µs a step (M5) |
Landed and extended (LupoLab/solprop#55): every table in one native call, one stage row repeated, the sample reusing the step’s arrays, the landing searches on tuples. Host per trial 471 → 208 µs (helium) and 374 → 151 µs (argon) on the EPYC, 179 → 65 and 131 → 51 µs on the M5. |
Where the march sits. The table below estimates complete runs with everything done:
the column “N items” applies N1–N7 (§3.3’s measured prototype combinations where they exist, plus the estimates above for N5);
the next column adds the native march (B), whose saving is the host cost left after N7, less a residual of 5–15 µs (M5) or 15–40 µs (EPYC) per trial, times the trial count of §3.3.
Ranges carry the spread of the measurements and of the unmeasured items. These are estimates, not measurements.
Case |
Machine |
Threads |
MS today |
MS, N items |
MS, N + march |
RK today |
MS / RK after |
|---|---|---|---|---|---|---|---|
|
M5 Max |
1 |
0.967 |
0.86–0.90 |
0.69–0.75 |
0.705 |
0.98–1.06 |
|
M5 Max |
16 |
0.973 |
0.665–0.69 |
0.50–0.54 |
0.692 |
0.72–0.78 |
|
M5 Max |
1 |
3.863 |
3.18–3.30 |
2.31–2.54 |
1.451 |
1.6–1.75 |
|
M5 Max |
16 |
3.840 |
3.25–3.50 |
2.35–2.70 |
1.465 |
1.6–1.85 |
|
M5 Max |
1 |
≈7.5 |
≈6.9–7.2 |
≈6.6–6.9 |
≈26.5 |
≈0.25–0.26 |
|
M5 Max |
16 |
4.627 |
3.08–3.28 |
2.73–2.94 |
11.60 |
0.24–0.25 |
|
EPYC 7763 |
1 |
3.150 |
2.21–2.35 |
1.64–1.81 |
1.389 |
1.2–1.3 |
|
EPYC 7763 |
16 |
3.392 |
2.12–2.46 |
1.55–1.93 |
1.654 |
0.95–1.15 |
|
EPYC 7763 |
1 |
12.61 |
9.8–10.0 |
6.1–6.6 |
3.012 |
2.0–2.2 |
|
EPYC 7763 |
16 |
12.60 |
8.45–9.1 |
4.8–5.7 |
2.969 |
1.6–1.9 |
|
EPYC 7763 |
1 |
22.03 |
16.9–18.3 |
15.8–17.4 |
59.06 |
0.27–0.29 |
|
EPYC 7763 |
16 |
21.32 |
12.7–13.8 |
11.6–12.9 |
47.43 |
0.24–0.27 |
Notes on the table:
The M5
rdwrows at one thread are derived from the per-trial profile; no complete one-thread run was timed.The EPYC sixteen-thread rows for the two averaged cases come from a node shared with another job, so read them against their own baseline.
The
rdwhost cost on the EPYC was not measured cleanly. It is taken as 1.4 times helium’s, the M5’s ratio.
What the table shows:
On the Apple M5 Max, everything together brings the helium mode-averaged case level with Runge–Kutta at one thread, and about 1.3 times ahead at sixteen. Argon stays 1.6–1.9 times behind.
rdwis four times ahead.On the AMD EPYC 7763, helium is 1.2–1.3 times behind at one thread and about level at sixteen. Argon is about twice as slow.
rdwis 3.5–4 times ahead.The native march is the largest single item on the single-mode cases on Linux. It takes 0.54–0.57 s from helium’s run (17–18 % of today’s time) and 3.4–3.7 s from argon’s (27–29 %). On the M5 it takes 0.15–0.17 s and 0.76–0.90 s.
Runge–Kutta gains too, and the table does not count it. It would gain from N3 (multi-row cases at sixteen threads), N6 (a few per cent) and N5b on its propagators. A native Runge–Kutta march would save its own 10 µs (M5) or 40–50 µs (EPYC) per call: about 0.13 s of helium’s 1.39 s and 0.39 s of argon’s 3.0 s on the EPYC. Such a march is not planned (§10, Q8).
On short runs the fixed 90–190 ms per run is then a large share of both methods’ time: 0.12 s of a 0.7 s helium run.
At the refresh. With the N items landed, measured, and the two phases of this SEP estimated from §6.1’s refreshed costing (“B over today”):
Case |
Machine |
Threads |
MS measured |
Drafting’s estimate for N items |
MS, + phases 1–2 |
RK measured |
MS / RK after |
|---|---|---|---|---|---|---|---|
|
M5 Max |
1 |
0.815 |
0.86–0.90 |
0.75–0.76 |
0.650 |
1.15–1.17 |
|
M5 Max |
16 |
0.636 |
0.665–0.69 |
0.57–0.59 |
0.650 |
0.88–0.91 |
|
M5 Max |
1 |
2.920 |
3.18–3.30 |
2.55–2.67 |
1.419 |
1.80–1.88 |
|
M5 Max |
16 |
2.474 |
3.25–3.50 |
2.10–2.22 |
1.381 |
1.52–1.61 |
|
EPYC 7763 |
1 |
2.126 |
2.21–2.35 |
1.85–1.88 |
1.528 |
1.21–1.23 |
|
EPYC 7763 |
1 |
9.17 |
9.8–10.0 |
7.4–7.7 |
2.460 |
3.0–3.1 |
What it shows:
The landed items beat their estimates on every row.
Runge–Kutta gained as well. The vector maths took 18 % off argon’s Runge–Kutta run on the EPYC. So the remaining gap there is wider than the drafting estimated.
What the march takes. Both phases of this SEP together take 11.5–13 % of helium’s run and 16–20 % of argon’s on the EPYC, and 6.5–8 % and 8.5–12.5 % on the M5.
The largest single item on the EPYC is now the Adams passes, 844 µs of a helium trial (§3.3). That is native work outside this SEP.
rdw. Its host time is 129 µs per trial on the M5 and 372 µs on the EPYC, of trials of 4 and 10 ms, so the march takes 2–3 % from it. It stays 1.4–1.6 times ahead of Runge–Kutta.
7. Verification and validation#
Tolerances follow SEP 25 and testing.md. Exact equality is used only
for structural contracts, and every rounding budget is fixed before the comparison. All
runs are ten-step windows or short guides that finish in seconds, as AGENTS.md
requires.
7.1 Exact structural contracts#
Decision traces (R1, R8). The controller is tested on its decisions, separately from the arithmetic that feeds them.
The oracle. The corpus is generated in phase 0 from the reference as it stands before the refactor. The current
AdamsMarchis driven through a stub engine that returns prescribed norms. The refactored core and the native march must both reproduce it, so a mistake in the extraction cannot be blessed in both halves.Decisions on supplied scores. Each decision is exercised on identical scalar inputs: an acceptance on its norm, an order adoption on its weights, a landing on its gaps and caps, a restart on its counts. On these the two implementations agree exactly:
the accept;
the stored and control orders;
the restart;
every ledger counter;
the landing (step, end, kind, kink).
The threshold witnesses live here, with no
powbetween input and comparison: norms at \(1 \pm\) one ulp, \(0\), \(\infty\) and NaN; weights tied, and at \(H\) exactly; gaps within \(\tau\) of a plane, a kink, a kink on a plane, and at the stretch limit.The scalar formulas. \(F\) and \(W_k\) are compared over the corpus’s inputs. They are exact where both halves call the same C library’s
pow(Linux, macOS). Elsewhere the bound is one ulp, stated in the test.Whole traces. Sequences through startup, growth, rejection and restart are compared with margin-aware expectations. A step whose decision margin is below 8 ulp of its threshold is reported, not compared.
The traces cover:
fixed-order startup and post-restart growth at orders 2–6 (R1);
variable order with the collar cap binding, and quiet;
three and four consecutive rejections;
the budget at its edge;
the floor, including an accepted landing shorter than \(h_{\min}/g\);
equal approach;
the carrier cap;
non-default
max_growth,min_factorandequal_approach.
Stage scalars. The native bundles equal
scalars_at’s field for field on constant, gradient, profile, multi-gas, Raman and plasma problems, because they use the same strict Clenshaw helper (§4.8).Events (R5). Each event falls at the accepted step where the host march acts, and each accepted step is accounted exactly once. Checked by counting rows, checks and stops on runs that take the same steps:
statistics rows at
row_period1, 3 and 7;natural stops at
step_planes1, 2 and 5;monitor checks, busy and quiet;
dense crossings, including one where a natural stop stays due;
a plane that is also a statistics row and a monitor check;
max_calls = 1;returns with rejections only, and with accepted steps followed by rejections;
a return for the call bound.
Lossless state (R9, R10). After a round trip in either direction, a snapshot’s rows, positions, identities, slots, order, controller state and \(w_Q\) equal the stored ones exactly (SEP 29 R17), and a load does not take the restart path. The frozen-residual refusals of §6.5.9 stay, with their names. A backend reused for a second run starts with \(w_Q\) and \(\lambda\) cleared.
The ledger (R4) counts the calls actually made, checked against the source’s own counter. That includes a startup evaluation that fails, a trial evaluation that fails, and a failure after the source has returned, each also through a snapshot and resume of its ledger.
7.2 Rounding budgets and the table oracle#
Coefficient tables (R7). The native tables (
_core.trial_tables) and the reference’s (the composition thenumpy_refandnumba_cpuhelpers use) are both compared with exact tables. These are computed in rational arithmetic (fractions.Fraction) from the same binary64 nodes, forexponential_tables,compensation_tables,factored_defect_rowsandfused_tables.The node sets come from real histories, not only random ones: landings that shorten a step by \(10^{-1}\) to \(10^{-10}\) with
equal_approach=False, rejection cascades up to the restart, non-default growth and smallest factors, orders 1–6.The error is measured in the weights a trial applies: per row, the sum of absolute errors over the sum of absolute exact weights.
The bound is finite and informative: the native error must not exceed the larger of four times the reference’s error on the same node set and 16 ulp.
The report. The reference’s own error is reported for every node set. Where it is large, that is a finding for the author (§4.7), not a test to loosen.
Matched marches. A prescribed schedule at a fixed order, with matched startup and restart history and matched plane handling, on the native march and on the host march over the same engine. The fields at every plane must agree to a budget from the step count and the per-step rounding of SEP 25 §4.3. This is the R-comparison SEP 25 asks for (phase 3, when replay is native).
The norms’ spread (R8). Phase 1 records, per qualification case, the distribution of \(|\Delta\varepsilon|/\varepsilon\) between trials with host-formed and natively formed inputs, under deterministic transform plans. It is expected to be zero outside the carrier frame (§4.9). These are the inputs of §4.9’s margins.
7.3 Accuracy envelopes for adaptive runs and resume#
The native march against the host march on the same native engine. Across the rows of §6.6’s matrix, the cases are:
a constant operator;
an expansion gradient with the transported compensation;
a z table;
the carrier frame;
the in-stage collar near its stability limit;
the quiet collar;
interpolated planes;
a hard kink;
max_calls;tolerances tight enough to reject;
the restart after three rejections.
Adaptive resumes (R10) run the same cases stopped part-way, including a stop and resume across a quiet-to-active collar transition (R9).
Each case’s envelope is set in its test against a tighter or stored converged reference,
times a safety factor fixed before measuring (SEP 25 §4.4); rtol and atol alone do
not bound the global error. Step and call counts are recorded, together with the
smallest decision margin of each run. A difference in steps is reported in the phase
report, never asserted against.
7.4 The existing suite#
The resident-Adams tests run once under each march:
tests/backends/test_resident_adams.py;test_exponential_varying.py;test_exponential_routes.py;test_exponential_march.py;test_carrier_frame.py;the resume, monitor and signal tests;
in phase 4,
test_exponential_coupled.py.
Each is parametrised by adams_march, so every check the host march passes on native
is also a check on the native march. Tests that patch the Python march’s internals keep
the host march.
7.5 Lifecycle#
Signals (R3). Signals are injected:
during a long native call;
at the return boundary, after native has committed several steps and before the host has ingested them.
Each is honoured within the call bound. The terminal checkpoint’s state, position, step history and ledger are checked together, and a resume continues the history.
Exceptions in user code (R3). A progress reporter that raises on the first ingested row, and an accepted landing that hits the floor, leave the same consistent checkpoint.
Native errors (R4). A failure in the middle of a call leaves the committed steps recorded, and raises the class its error record names. Startup, trial, reduction and accept failures are each injected.
Replay (R6, phase 3):
the 160-step schedule that ends 12 ulps short of its length, which must count as arriving;
kinks on edges;
a schedule too short for its target (exhausted);
a resume part-way through, with the cursor rebuilt from the position.
7.6 Benchmarks#
The A/B harness, python -m benchmarks ab, compares the two marches on the same build
and revision, with N1–N7 held fixed and the march chosen per arm (R12). The harness
gains a per-arm environment, or the private switch is read from the environment once at
import.
What each phase reports:
end-to-end time;
attempts, source calls and host events per run, counted, not inferred;
residual host time per trial.
Cases:
he_rdw_averaged,argon_blueshift_averaged,h2_raman_averaged,rdw;a short window of
ctc, for an expensive source;frequent events: a busy monitor,
step_planes = 1androw_period = 1.
Each is run at one thread and the production width, interleaved in fresh processes, on an Apple M5 Max and an AMD EPYC 7763 (or the machines then current, recorded by hardware model).
Lengths. Runs use --length fractions chosen so that each arm finishes in seconds
on the slower machine, and named in the ledger entry: short guides, as AGENTS.md
requires. Complete tracking runs stay with SEP 38’s release procedure and are not part
of this SEP’s qualification.
Baseline. Phase 0 records the baseline in benchmarks.md: the invocation, the lengths, the counted attempts and calls per case, and the host cost by piece. The refresh’s measurements of §3.3 are that baseline. The lean host timer they used is committed with it, so that the invocation can be repeated. The decision to go on to phase 2 then rests on public numbers. Each later phase adds its own entry.
Acceptance rule (decided, R12). On every case above, the native march must not be
slower than the host march by more than the A/B’s own measured spread, about 2 %. That
includes the expensive-source and frequent-event cases. A regression beyond that blocks
Final until it is understood and fixed. The rule is judged where the A/B’s own spread
is of that size: an idle machine, or one NUMA domain held alone. A shared node’s rows,
such as the EPYC’s sixteen-thread rows at the refresh (§3.3), are reported but do not
decide.
8. Documentation#
stepper.md, “Low-call Adams methods”. Where the march runs on each backend, the policy record and the events.
kernel-contract.md. A new section, “The Adams march”: §4’s algorithm as the normative text, the policy record’s fields, the events, the attempt rows, the scalar tables leaf. §7 links to it.
backends.md §4. The native march, its admission, and the GPU behaviour.
decisions.md. A new ADR amending ADR-043 (§6.3).
native/AGENTS.md. The amended rule (§6.3).The manual page the Adams integrator. A short paragraph under its limitations or costs. On native the march runs in the compiled backend and
adams_marchrecords it; the warning says when it does not; and results meet the same accuracy envelope as the host march’s. Its resume paragraph, which says a resumed run agrees with the uninterrupted one “to rounding”, is restated as SEP 25 §4.4 rules: the transfer is exact, and the continuation meets the accuracy envelope (R10).The manual page choosing a stepper. Re-measured rows once the phases land.
CHANGELOG.md. A line citing this SEP.
No tutorial or worked example is needed: nothing a user writes changes.
9. Compatibility and impact#
Defaults. None changes.
Multistep()stays the method, with its fields and constants.Results.
On the native backend, the two marches read the same tables and stage scalars. Only the carrier chord, and the order of the controller’s scalar operations, can differ by rounding (§4.9; measured, §7.2).
A decision can flip at a knife edge, after which the run meets the case’s accuracy envelope rather than the host march’s steps (§4.9, R10).
Validated results elsewhere are unchanged:
numpy_refandnumba_cpurun the same Python march, refactored without behaviour change in phase 0.
Seam. Kernel schema +1 in phase 1 and +1 in phase 2. New contract tuples
SCALAR_TABLES_FIELDSandMARCH_POLICY_FIELDS, and additions toDRIVER_SURFACE.Output. Output schema +1 in phase 0, for the collar flag \(w_Q\) in
MarchSnapshot(R9). The march’s name goes into the backend record’s free-form settings and needs no bump. A snapshot written before phase 0 lacks the flag. It is treated as any older schema is: breaking changes are allowed before the first release, and there are no compatibility shims (ADR-051, ADR-058).Rules.
native/AGENTS.md’s policy rule is amended, and ADR-043 is amended by a new ADR (§6.3). The step-loop protocol and the integrator–controller split that ADR-058 reserves for change are touched as §6.4 describes.Warnings. A run on native that the native march does not cover warns (
HostMarchWarningor similar, §6.6).What it breaks:
ADR-043’s single executed controller;
Python-level introspection of a native run’s march: the debug lines arrive per call rather than as each step is taken, and monkeypatching
AdamsMarchmethods applies only to the host march;signal latency, now up to one call bound (0.1 s) instead of one trial;
progress granularity, now per call.
Luna.jl. No difference to record.
10. Alternatives considered#
Option A, the controller owned by C++ (§6.1). The same speed as B, but it puts the policy in native code and keeps two sets of constants.
Option C alone (§6.1). At drafting it kept a third of the EPYC’s host cost; at the refresh it keeps about half of what is left, and two crossings per accepted trial. It is kept as phase 1.
A native loop that calls back into Python for each decision. It saves nothing, since the crossings stay. It also holds the GIL across native work, and
native/AGENTS.mdforbids calling Python from native code.A Numba-compiled controller called from the Python loop. It saves the decision’s 39–47 µs on the EPYC and nothing else.
A lagged decision. ADR-004’s lagged controller lets a device run ahead of the host by deciding on the previous trial’s norm. For Adams it changes the algorithm: a trial would start before its predecessor is accepted, which wastes a source call on every rejection and changes the step sequence. That breaks the “no change to the method” scope.
A controller resident on the device, in a persistent kernel or graph. This is the next step for device builds, but the launches and the norm read-back are a separate problem, and it would be its own SEP.
Tabulating the stage scalars on a fine grid (§6.5.6).
Moving the Runge–Kutta march native. RK’s host cost is 10 µs (M5) and 40–50 µs (EPYC) per call, and its controller (the lagged PI controller, the stretch, the start rule) is another algorithm to specify. Not pursued: it can be taken up another time, reusing this SEP’s policy-record pattern, if it is needed (Q8).
Retuning the controller for fewer steps on the argon case. That is a method change, outside this SEP, and
native/AGENTS.mdforbids it as a speed measure.
11. Phases#
Every phase is on one branch, feature/sep-0039-native-adams-march, and reaches main
in one pull request at Final (SEP 0). Each phase runs targeted checks, and the full
gate runs in CI.
Phase 0: the controller core in solprop.
MarchPolicyandmarch_policy.The general path (
_attempt,_correct,_commit,_next_order), which serves onlyComparisonMultistep, moved out ofAdamsMarchinto its own class (Q7).A pure core of the controller (
stencil,decide,reject,choose_order, the caps,approachandlanding) over a policy and a state.AdamsMarchcalls it.No change in behaviour, except two fixes:
the commit order of §4.4, with a regression test for an accepted landing shorter than \(h_{\min}/g\);
the collar flag \(w_Q\), kept in
MarchSnapshotand reset bybegin_run(§6.5.9; output schema +1).
The existing Adams tests pass unchanged on
numpy_refandnumba_cpu.The decision-trace corpus, generated from the reference before the refactor (§7.1, R1), and the table oracle’s corpus (§7.2, R7).
The baseline benchmark entry: invocation, lengths, counted attempts and calls, host cost by piece (§7.6, R12).
The kernel-contract section, drafted.
Verified by the existing suite, and by the refactored core reproducing the pre-refactor corpus exactly.
Phase 1: native tables and stage scalars (option C’s pieces).
The scalar tables leaf, with derivative tables for the rate weights.
Native coefficient tables formed from the nodes inside the trial call. Since LupoLab/solprop#55 this is the existing
lagrange::trial_tablescalled in-process.Native stage scalars, the rate weights and the carrier chord.
Lean trial arguments: the positions, the nodes and the scored orders, with one stage row written into the staging the Adams passes read (§6.5.6).
The trial and the accept stay separate calls (R2).
Kernel schema +1.
The Python march still decides.
Verified by §7.1’s scalar equality, §7.2’s table oracle and the norms’ spread, the existing suite, and the benchmarks. Expected, at the refresh, −90 to −110 µs per helium trial and −60 to −80 µs per argon trial on the EPYC, and −25 to −29 and −15 to −19 µs on the M5 (§6.1). At drafting the expectation was −265 to −335 µs per helium trial on the EPYC. Phase 1 stays in this SEP and lands with the rest in its one pull request (decided, Q11).
Phase 2: the native march, for the exponential formulation on diagonal operators, with everything of §6.5:
the events;
commits, signals and errors;
snapshots;
metadata;
admission and the host march elsewhere.
Also in phase 2: the new ADR, the native/AGENTS.md amendment, the documentation of §8,
and kernel schema +1. The two marches are compared on the same revision with
N1–N7 held fixed, under §7.6’s acceptance rule. Verified by §7.1, §7.3, §7.4, §7.5 and
§7.6.
Phase 3: the interaction formulation’s fused path (Q4), and replayed schedules
natively, with replay’s own cursor, slack, kink and exhaustion rules (§4.3, R6). Single
runs only; lockstep batches keep replay_step on the host (Q14). Verified by §7.2’s
matched marches, §7.4 and §7.5’s replay cases.
Phase 4: coupled flows and moving maps, natively, for every flow the resident engine
executes (Q2). The flow section and weights, the block’s end weights and the transform
weights per position. Verified by test_exponential_coupled.py under both marches.
Phase 5: device builds, inside this SEP and before Final (Q6), with a GPU budget
the maintainer approves when the phase starts. The march on CUDA and HIP builds, in one
GPU segment under the standing device policy: the parity tests with -m gpu, and
ten-step timings against the host march.
The native items of §6.9 proceed independently. Each phase’s benchmarks are measured on
main as it then stands, and the report says which of N1–N7 had landed.
12. Open questions#
The author decided every question below on 2026-10-28, before review. Each decision is recorded beside its question, and the proposal above follows it.
Q1. Amend
native/AGENTS.md’s rule with the wording of §6.3, or keep it as it is and record B as an exception in the new ADR? Decided 2026-10-28 (John Travers): amend.Q2. Configurations outside the native march’s admission: run the host march and record it, or refuse them by name on native? Decided 2026-10-28 (John Travers): run the host march with a warning, and aim to cover every configuration (§6.6).
Q3. The call bound. A scheduler allows seconds, but a keyboard interrupt wants tenths. Decided 2026-10-28 (John Travers): 256 attempts and 0.1 s of wall time, not a user setting (§6.5.7).
Q4. Does the interaction formulation’s fused path deserve the native march (phase 3)? It is not the default, but on SEP 37’s capillary rows it scored 1.45/1.47 times the fastest against the exponential formulation’s 2.15/2.26, and the controller is shared. Decided 2026-10-28 (John Travers): yes, in phase 3.
Q5. Coupled flows and moving maps (phase 4): native, or left on the host march? Answered by Q2: native, for every flow the resident engine executes. What has no resident engine is Q14.
Q6. Device builds: qualify in this SEP’s phase 5 or in a later device SEP? Decided 2026-10-28 (John Travers): in phase 5, before
Final, with a GPU budget approved when the phase starts.Q7. Whether phase 0’s refactor should also simplify
AdamsMarchitself. The general path,_attempt,_correct,_commitand_next_order, which serve onlyComparisonMultistep, could move into their own class. Decided 2026-10-28 (John Travers): yes, in phase 0, with no change in behaviour.Q8. A native Runge–Kutta march in a follow-up SEP, reusing the policy-record pattern, so that the default comparison is between equally optimised marches? Decided 2026-10-28 (John Travers): no. It can be taken up another time if it is needed.
Q9. A compiled march for
numba_cpu? Decided 2026-10-28 (John Travers): no. Backend parity of the march is not needed, andnumba_cpuis being demoted, so no roadmap item is added (§6.6).Q10. The capillary default. Decided 2026-10-28 (John Travers): outside this SEP’s scope; it changes no default.
Q11. Should phase 1, which needs no SEP on its own, land ahead of acceptance as an ordinary optimisation pull request? Decided 2026-10-28 (John Travers): no. It lands with the whole SEP.
Q12. Progress and logging granularity. Is one progress update and one debug line per event enough, or should the attempt rows be logged one by one at debug level? Decided 2026-10-28 (John Travers): progress once per call; one debug line per attempt, written from the rows when
DEBUGis enabled (§6.5.8).Q13. Should an accepted landing shorter than \(h_{\min}/g\) raise the floor (§4.4)? The Adams march forms its next proposal from the step taken, so it does; the Runge–Kutta march grows from its proposal and does not. Decided 2026-10-28 (John Travers): phase 0 fixes the commit order. The rule may be changed in this SEP if the implementation finds a reason, recorded in the phase report.
Q14. Which configurations may still run the warned host march at
Final(following Q2)? Proposed: only those with no resident engine or no data form for the native march:ComparisonMultistep’s general path;a coupled flow whose sections are off the eigenbasis route or wider than 16 channels (ADR-044);
a controller whose norm scale is not
PIController.scale;lockstep batch replay.
The untransported compensation is a private test witness and does not warn. Decided 2026-10-28 (John Travers): as proposed. The proposal also listed “device builds, until phase 5”. With phase 5 before
Final(Q6), that is a state of the branch, not ofFinal, and it moves to §6.6’s matrix (R11).
13. References#
E. Hairer, S. P. Nørsett and G. Wanner, Solving Ordinary Differential Equations I: Nonstiff Problems, 2nd ed., Springer (1993), sections II.4 and III.5. doi:10.1007/978-3-540-78862-1
P. N. Brown, G. D. Byrne and A. C. Hindmarsh, “VODE: a variable-coefficient ODE solver,” SIAM J. Sci. Stat. Comput. 10, 1038 (1989). doi:10.1137/0910062
M. Hochbruck and A. Ostermann, “Exponential integrators,” Acta Numerica 19, 209 (2010). doi:10.1017/S0962492910000048
S. M. Cox and P. C. Matthews, “Exponential time differencing for stiff systems,” J. Comput. Phys. 176, 430 (2002). doi:10.1006/jcph.2002.6995
G. Söderlind, “Automatic control and adaptive time-stepping,” Numerical Algorithms 31, 281 (2002). doi:10.1023/A:1021160023092
N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM (2002), chapter 22 (Vandermonde systems). doi:10.1137/1.9780898718027
D. Goldberg, “What every computer scientist should know about floating-point arithmetic,” ACM Comput. Surv. 23, 5 (1991). doi:10.1145/103162.103163
IEEE Standard for Floating-Point Arithmetic, IEEE Std 754-2019 (2019). doi:10.1109/IEEESTD.2019.8766229
J.-M. Muller et al., Handbook of Floating-Point Arithmetic, 2nd ed., Birkhäuser (2018), on the accuracy of elementary-function libraries. doi:10.1007/978-3-319-76526-6
C. R. Trott et al., “Kokkos 3: programming model extensions for the exascale era,” IEEE Trans. Parallel Distrib. Syst. 33, 805 (2022). doi:10.1109/TPDS.2021.3097283
Part II — Implementation record#
Phase 0 — report#
Scope.
Phase 0, the controller core in solprop, 2026-10-28/29.
Opus 5.5 (AI) for John Travers, on
feature/sep-0039-native-adams-march, based on LupoLab/solprop#56 at a500d2d.The decision-trace oracle was committed first, before any code changed. Then came the core, the general path and the fixes together, then the host timer, the baseline and the contract draft.
Delivered.
The oracle.
tests/backends/adams_traces.pydrives the march through a stub backend and engine on scripted norms, and records every stencil, decision, proposal, order, landing, restart and ledger counter.tests/data/generate_adams_traces.pywrotetests/data/adams_traces.json.gzfrom a500d2d: 35 scenarios and about 2 900 attempts, gzip-compressed to 313 kB, the floats exact. It refuses to overwrite the file without--force.The scenarios: fixed-order startup and post-restart growth at orders 2–6, variable order with the collar cap on and quiet, rejection cascades and failure restarts, the budget’s edge, the floor after rejections and after a short landing, equal approach on and off, the carrier cap, non-default factors, threshold norms (1 and its neighbours, 0, ∞, NaN), the interval cap, kinks on and off planes, dense observation, replay (the 160-step schedule 12 ulps short, kinks on edges, exhaustion, divergence) and eight random settings.
tests/backends/test_adams_traces.pyrequires the march to reproduce it.
The policy record.
MarchPolicyandmarch_policyare insrc/solprop/stepper/multistep.py.RESTART_AFTER,ORDER_HYSTERESISandCOLLAR_STABILITYmove there frombackends.multistep. The rejection’s growth cap becomes a named constant,REJECTION_GROWTH.LANDING_ULPSis one public constant instepper.schedule, whichbackends.driverimports; the duplicate is gone.The core.
src/solprop/backends/adams_core.py:targets,factor,stencil,stable_factor,order_weight,choose_order,rejection,carrier_capped,quiet_capped,collar_reentry,next_landing,approachandreplay_kink. Each is extracted operation for operation.AdamsMarchkeeps thin methods over them, those the tests and the general path call.The general path (Q7).
GeneralPathinsrc/solprop/backends/multistep_general.pyholds the comparison variants’ attempt, correction, commit and next order, with_Trial,_defect_atand_norm_function. The march creates it only for those variants.The fixes.
Commit order (§4.4). An acceptance moves the position, the kink restart, the stored order and
last_rejectedinside its commit before the next proposal is tested, in both paths (AdamsMarch._propose). A floor found then is raised once the attempt is recorded (_raise_floor): bymarch_to, by_replay_toand by the lockstep runner after their records, and by_affordbefore any later attempt, so that it cannot be passed over.The collar flag (R9).
MarchSnapshot.collar_was_quietis written to and read from the HDF5 snapshot, restored byloadand cleared bybegin_run, with any pending floor. Output schema 28; the tests that pin the schema follow.
Tests.
The corpus, with the short-landing floor scenario checked against the oracle as the fix changes it: the accepted attempt recorded, the position at its plane.
The flag across the file, its absence refused like the other attributes’, a new run clearing it, and a restored flag deciding the re-entry cap.
tests/backends/test_adams_table_oracle.py, the exact rational oracle of §7.2.
The baseline. The measurements of §3.3 are recorded in benchmarks.md, “Exponential Adams host cost: the SEP 39 baseline”. The lean host timer is
python -m benchmarks.adams_host.The contract. kernel-contract.md §11, “The Adams march”, drafted: the policy record’s fields, the state, an attempt, and what a native execution returns.
Deviations from the proposal.
The first proposal is not a policy field. The record holds no \(h_0\): the algorithm reads the proposal from the state, which the driver sets, through its start-up rule (
auto_init) when nodz_initis given.The norm’s scale is not in the record. It is the controller’s of kernel-contract §7, which the engine already applies to the norms it reduces. §4.2 lists it as something the algorithm reads; it reads it through the norms.
The quiet interval. The Python march still reads the monitor’s interval through the driver (
StepDriver._quiet_capped), so its own record leavesquiet_intervalempty. The native adapter fills it when it configures a run, from the attached monitor (phase 2).The oracle covers the exponential path. Its stub engine answers the exponential trial. The fused trial (phase 3) and the general path are covered by the existing suite, which passes unchanged. The general path’s half of the commit-order fix goes through the same
_proposeas the exponential path’s, and has no test of its own.The corpus is compressed, at 313 kB against 2.5 MB of JSON;
jsonkeeps its floats exact.
Decisions.
march_policytakes keywords, not the stepper settings.steppermay not importtranslate, which imports it.The general path is a held object, not a mixin.
backends.multistep_generalimports frombackends.multistep, and the march creates it lazily.The flag’s dataclass field defaults to false; the file reader requires it. A snapshot written before schema 28 is therefore ignored with a warning, and the resumed march restarts its history (ADR-051).
Verification.
The corpus: all 35 traces reproduced after the refactor, then 34 unchanged and the 35th changed exactly as the fix describes.
Targeted modules on the Apple M5 Max, under xdist:
the core alone: 693 passed;
the general path: 374 passed, 1 skipped;
the fixes and the schema: 1 121 passed. Fifteen setup errors (
fixture 'backend_name' not found) came from mixing test directories in one xdist call, and those modules passed alone.
The changed modules: basedpyright reports no errors, and ruff, numpydoc lint, codespell and their doctests pass.
The table oracle, over the 2 090 distinct stencils of the corpus and the review’s history of four unit steps and a trial of \(10^{-10}\):
every record (Numba and native) is within its bound of the exact tables;
the plain composition’s own error is at most \(4.5\times10^{-16}\) of a row’s weights, about two ulp, on every table. The reference does not inherit the monomial Vandermonde’s conditioning (§4.7); the test now holds it to 16 ulp.
Not run: the full gate and the docs build, which CI runs.
Sources checked. No constant or citation is new. The moved constants keep their values and docstrings.
Limitations. Every execution is still the Python march. The oracle’s scenarios use a stub engine, so the norms are scripted, not computed.
Findings for the roadmap, outside this SEP:
The Adams passes on x86-64. On the AMD EPYC 7763 the exponential trial’s predict and correct passes cost 2.9–3.8 times the M5’s per element, against 1.9 times for the source. They are 844 µs of a helium trial (§3.3), the largest single item left on Linux.
Threading on a shared node. On a shared 16-core domain of that machine,
he_rdw_averagedandh2_raman_averagedran slower at sixteen threads than at one, with both steppers. Worth re-measuring on an idle node before it is called a defect.
Handoff to phase 1.
The trial binding’s arguments are in
bindings.cpp(about lines 1839–1905), and its stage rows are read at rown_stages(general.cpp, about lines 6412 and 6447)._ResidentAdams.exponential_trialand_resident_arraysare the host side to replace.The scalar tables are the
PiecewiseChebyshevleaves thattranslate.stage.scalarsreads.The baseline to beat is §3.3’s.
Phase 1 — report#
Scope.
Phase 1, native stage scalars and the lean trial, 2026-10-29.
Opus 5.5 (AI) for John Travers, on the same branch (b97bdbd, c164e3b).
Delivered.
The tables, natively (
native/include/solprop_native/stage_tables.hpp). Where a resident exponential trial’s stage rows are one bundle repeated, the native backend uploads the run’s scalar tables once (NativeBackend._stage_tables, throughPiecewiseChebyshev.packed, the batch pass’s own view of the expansions): the weights of a constant or expanded \(\Phi\) and prefactor, the density, the taper, the Raman decay rates and densities, the Kerr density, the plasma channels’ densities and fractions, and an expansion’s rate table. The native state evaluates them by the reads oftranslate.stage.scalars, through the same strict Clenshaw helper in the same order.The lean trial.
adams_exponential_trial_attakes the anchor, the step, the nodes, the history’s rows and the scored orders, and forms in-process the coefficient tables (lagrange::trial_tables), the step’s stage row and the lab-frame sample’s, and the endpoint rate (stage::rate). Only the nodes cross.The host side.
_ResidentAdams.stagedselects the lean call; z tables, separable operators, SEP 6’s dispersive channels, moving maps and coupled flows keep the host’s packing until phase 4. The carrier frame’s shift is still formed on the host.Bindings for the tests:
adams_stage_scalarsandadams_stage_rate.benchmarks.adams_host --host-stagesrestores the host packing, for this phase’s A/B.
Deviations from the proposal.
No kernel-schema bump, no
SCALAR_TABLES_FIELDS. The tables are the problem’s ownPiecewiseChebyshevleaves, which the native backend already receives; they are packed and uploaded by the native backend’s adapter, not carried in a newKernelProblemleaf. No contract field moved, so phase 2’s bump (schema 39) is the SEP’s only one.The carrier chord stays on the host. It runs only with the carrier frame, which the native march refuses until it forms the chord natively (phase 4’s list).
Verification.
tests/backends/test_native_stage_tables.py: the native bundles equaltranslate.stage.scalarsfield for field, exactly (constant, gradient, profile, multi-gas, Raman, plasma), and the endpoint rate equalsphi_rate.On one build with
SOLPROP_FFTW_PLAN=estimate, the staged march’s steps and fields equal the host-staged march’s bit for bit on four cases and seven routes (a recorded expectation, §4.9; the norms’ spread is zero).The backend modules on the Apple M5 Max: 313 passed and one failure, the helper ownership test, which counted the host’s table calls and now counts the native ones.
Measured (interleaved fresh processes, medians of three, the same steps in both arms):
machine |
case |
host µs/trial, host packing → staged |
wall |
|---|---|---|---|
AMD EPYC 7763, 1 thread |
|
211 → 66 |
−9.8 % |
|
150 → 59 |
−13.9 % |
|
|
169 → 58 |
−7.7 % |
|
|
358 → 178 |
−2.7 % |
|
Apple M5 Max, 1 thread |
|
43 → 12 |
−5.0 % |
|
30 → 11 |
−8.5 % |
|
|
36 → 11 |
−4.4 % |
|
|
88 → 46 |
−0.8 % |
The EPYC node was shared (load 81–93), so its walls carry that node’s noise; the host times are the timer’s own. The gain is about what §6.1 estimated for phase 1 (−110 and −60 µs a trial on helium and argon).
Handoff to phase 2. The march loop’s engine calls are adams_start, the staged
trial and adams_exponential_accept; the state the march must hand back is
AdamsMarch’s (§4.2).
Phase 2 — report#
Scope.
Phase 2, the native march for the exponential formulation, 2026-10-29.
Opus 5.5 (AI) for John Travers (b1290af, 124c3d7). LupoLab/solprop#56 merged to
mainduring the phase; the branch tookmain(d3af6df) and #58 now targets it.
Delivered.
The march (
native/include/solprop_native/adams_march.hpp,native/src/adams_march.cpp):Policy,State, the decisions transcribed fromadams_coreoperation for operation (Python’smin/maxNaN semantics included), the engine interface, andrun, which performs §4’s attempt loop to an event: reached, host step, monitor, bound (256 attempts, 0.1 s on a steady clock), budget, floor or a native error recorded with its stage and category. The unit is compiled with-ffp-contract=offon every compiler that contracts, and calls onlypow.The engines (
bindings.cpp):ResidentEngineover the general state and the phase-1 tables (startup, staged trial, accept withpromote_fsal), with a test hook that fails a chosen stage (adams_march_fault);ScriptedEngineandScriptedMarch, which replay the decision corpus’s norms.The adapter (
_NativeAdamsMarch, a subclass ofAdamsMarch): the admission of §6.6 withHostMarchWarning; onecommitting()per call around the native run, the state’s adoption and the rows’ booking; progress, debug lines and the event step’s statistics and natural stop after it; the errors raised last, with the reference’s messages. A snapshot, a load and a new run are the Python march’s own.The driver gains
_record_attemptand_skip_step;BoundaryMonitor.steps_to_checkandchecked_atgive a call its monitor bounds.The seam (kernel schema 39): the two hooks join
DRIVER_SURFACE, with_take_step_stats,_natural_stopand_natural_stop_duefrozen beside them;MARCH_POLICY_FIELDSis checked exactly (a field either way is refused by name) andADAMS_MARCH_SURFACEnames the march’s methods the adapter relies on.Docs: ADR-060 (amending ADR-043), the
native/AGENTS.mdrule in §6.3’s words, kernel-contract §11 normative, stepper.md, backends.md §4, the Adams guide (coverage, cost, the resume envelope), the changelog.
Deviations from the proposal.
Bulk hooks are per attempt. The driver books each row through
_record_attempt, not one bulk call: a call’s rows are few (an event comes within 256 attempts), and the per-row hook keeps the reference’s bookkeeping in one place.The monitor’s first check is the monitor’s own (
steps_to_checkreturns one while it has not checked); the bounds’first_checkstays false.No
adams_march_state/adams_march_load. The state crosses with every call, so a snapshot is the Python march’s and nothing native needs saving.benchmarks/ab.pyhas no per-arm environment. The A/B usedbenchmarks.adams_host --host-marchin interleaved fresh processes instead.
Verification.
The decision corpus through the native march on its own norms (
test_native_march_traces.py): all 35 scenarios, each call bound to one attempt so that every stencil, decision, proposal, landing, restart and ledger counter is compared with the reference’s exactly, and again with the production bound, comparing the attempts and the end state.Lifecycle (
test_native_march_lifecycle.py, §7.5): failures injected at the startup, a trial and an accept raise their class at the last accepted step, with the checkpoint and the march’s state at one position and a resume that meets the accuracy envelope; a progress reporter’s exception and SIGTERM during a call likewise; the floor and the budget stop both marches at the same step with the same message.Admission (
test_native_march_admission.py): the native routes run without a warning, the uncovered ones warn naming the reason, the private switch is quiet, and the metadata records the march.The unit (
test_adams_march.cpp): it rounds a product and a sum separately (checked at -O3 on the Apple M5 Max: without the per-file flag it fuses), refuses a policy by the field at fault, and keeps its ledger on a steady engine.Same build, estimate plans (§4.9): the native march’s steps, ledger and fields equal the Python march’s bit for bit on the four tracking cases (recorded, not asserted).
The backend and driver modules touching Adams runs on the M5 Max; the full gate on CI.
Measured (interleaved fresh processes, medians of three; every arm took the same steps and calls; “host” is solprop’s march on the phase-1 staged trial, “before” the march with host packing, as before phase 1):
machine, threads |
case |
host µs/trial: before → host → native |
wall vs host |
wall vs before |
|---|---|---|---|---|
Apple M5 Max, 1 |
|
43 → 13 → 0.6 |
−3.3 % |
−7.6 % |
|
30 → 11 → 0.4 |
−6.0 % |
−13.6 % |
|
|
37 → 11 → 0.5 |
−3.9 % |
−11.6 % |
|
|
91 → 47 → 2.7 |
−0.6 % |
+0.4 % |
|
Apple M5 Max, 16 |
|
47 → 13 → 0.6 |
−4.2 % |
−13.2 % |
|
32 → 12 → 0.4 |
−9.2 % |
−17.2 % |
|
|
39 → 12 → 0.6 |
−6.2 % |
−16.2 % |
|
|
171 → 122 → 1.9 |
−2.2 % |
−4.5 % |
|
AMD EPYC 7763, 1 |
|
208 → 65 → 1.7 |
−6.1 % |
−14.0 % |
|
148 → 58 → 1.1 |
−11.5 % |
−24.2 % |
|
|
168 → 58 → 1.2 |
−9.5 % |
−22.7 % |
The M5 was otherwise idle but for one indexing process (load 2.3–4.6). The EPYC rows
are from a shared node (load 80–97, the task’s cores spread over its NUMA domains):
its one-thread walls are consistent, its sixteen-thread ones bimodal and not reported
here. rdw on that node read 6–9 % slower natively at both widths, against host times
of 185 → 17 µs a trial. Re-timed on a quiet node (load 3–5, sixteen cores of one NUMA
domain), nine rounds: −1.7 % at one thread (medians); at sixteen, +2.7 % by medians
and −3.3 % by minima, inside that node’s run-to-run spread of ±6 %. The host time
saved there, 280 µs on each of 394 trials, is 3 % of the run: below what the node can
resolve, and no regression is measurable.
The gain is §6.1’s estimate for phase 2 on helium and argon (60–85 and 50–75 µs a trial on the EPYC), and the native march’s own host time, 0.4–2.7 µs a trial, is the adapter’s per-call work and the rows’ booking.
Mac performance leads. The residual host time on the M5 is 0.4–0.6 µs a trial on
the mode-averaged cases: the per-call Python (bounds, state dictionaries, the rows’
booking), amortised over the attempts between events. On rdw a statistics row falls
due every step, so every attempt is its own call (2–3 µs). A cheaper booking path (one
_record_attempts call per call’s rows) would trim the latter; nothing else on the
host is above a microsecond a trial.
What a native run spends now, sampled with macOS sample over twelve whole
he_rdw_averaged runs at one thread (each run’s translation included): the native
kernels 58 %, the system libm’s exp and sincos 16 %, FFTW 11 %, Python and NumPy
13 % (mostly the per-run translation). The libm share is the Adams predict pass’s and
the stage entry’s complex exponentials, element by element: on Apple silicon the vector
maths layer keeps the libm (ADR-059: SLEEF’s AdvSIMD functions gained nothing there,
and Accelerate’s vForce a fifth at 1.7 ulp). It is the largest single item left on the
M5, and the lead for Mac tuning; a symbolised profile of the native share is the next
step (the shipped _core is stripped).
Phase 3 — report#
Scope. Phase 3, the interaction formulation, the one-order-higher PEC comparisons and replayed schedules natively, 2026-10-29 (115c12d). Opus 5.5 (AI) for John Travers.
Delivered.
The fused trial staged.
stage::fused_weightsformsfused_tables’ predictor, corrector and defect rows over the strictlagrange::basis_rows, operation for operation;adams_fused_trial_atreads the stage row from the uploaded tables, which interaction-formulation runs now upload too. The Python march takes it as well (_ResidentAdams.fused_trialwhen staged).The fused path in the native march.
ResidentEngineruns the fused trial and accept forMultistep(formulation="interaction")and forComparisonMultistepwithcorrector="pec",corrector_order = order + 1(Adams or Hermite interpolant);Engine::acceptreceives the kept history, which the fused accept transports.Replay (§4.3): the schedule’s steps and edges at configuration, the cursor (the driver’s schedule index) in the state, the slack (
replay_tolerance) in the bounds; kinks within the slack restart, a non-finite norm returnsdiverged(raised with the reference’s message, unrecorded) and exhaustion is the host’sBackendError, as before. Lockstep batches keepreplay_stepon the host (Q14).
Deviations. The fused trial’s tables are formed natively rather than shipped (a small extension of phase 1’s scope to the interaction formulation).
Verification.
The corpus’s four replay scenarios (the 160-step schedule 12 ulps short, kinks on edges, exhaustion, divergence) run through the scripted native march and match the reference exactly, messages included; every corpus scenario is asserted native.
Same build, estimate plans: the native march, the staged Python march and the host-packed Python march agree bit for bit in the interaction formulation on the four tracking cases, and with PEC4/5 (both interpolants) on two (recorded). The staged-march test holds both formulations to a rounding bound.
The resident, helper-ownership, z-table and driver modules on the M5 Max.
Measured (Apple M5 Max, interaction formulation; medians of three, the same steps in every arm):
threads |
case |
host µs/trial: before → host → native |
wall vs host |
wall vs before |
|---|---|---|---|---|
1 |
|
41 → 12 → 0.5 |
−4.0 % |
−11.8 % |
|
33 → 11 → 0.5 |
−5.4 % |
−16.7 % |
|
|
40 → 11 → 0.5 |
−5.6 % |
−18.3 % |
|
|
85 → 43 → 1.5 |
−1.3 % |
−2.9 % |
|
16 |
|
41 → 12 → 0.5 |
−3.9 % |
−12.9 % |
|
33 → 11 → 0.4 |
−8.6 % |
−18.2 % |
|
|
42 → 11 → 0.5 |
−34.1 % |
−15.0 % |
|
|
165 → 119 → 1.5 |
−2.1 % |
−4.7 % |
Mac lead. h2_raman_averaged at sixteen threads took 2.24 s on the Python march
with the staged trial in all three rounds, against 1.74 s with the host packing and
1.48 s natively: the staged host march is slower there than the unstaged one, which
nothing in its arithmetic explains. Suspected: the OpenMP workers’ idle policy
(ADR-056) meeting the shorter host gaps between native calls. Not investigated; the
native march, the default, does not show it.
Phase 4 — report#
Scope. Phase 4, every route staged and native, 2026-10-29 (2ddea85, f8ccd06, 86e6c55). Opus 5.5 (AI) for John Travers.
Delivered.
Separable operators (the Cartesian, line and planar routes) stage as a constant: the distance and the position, a unit prefactor, no span (
_trial_arrays’ packing).SEP 6’s dispersive channels: their densities are stage tables of their own.
z tables: the slot cache moves into the native state (
stage::ZSlots), one cache for both marches and every consumer, with the rate slot’s memo;_ZTableskeeps its interface over it. The stage rows reade_1 - e_0ande_1over the slots, loaded as_weight_rowsloads them.Coupled flows (the eigenbasis route, at most 16 channels) and moving maps: the flow’s section (its anchor’s) and a frozen residual’s weights, and a moving guide’s map weights, are sectioned Chebyshev rows formed natively (
stage::Sections: NumPy’schebvanderrecursion, strict, in the sectionflow_sectionandtransform_sectionfind, with the step’s own section kept asflow_weightskeeps it). Beside a flow the lab-frame sample is a stage anchored at the step’s end, as_resident_sampleforms it.The carrier frame (phase 2’s row): the chord’s imaginary part at the reference element (a constant’s rate, or an expansion’s terms) uploads with the tables; the engine forms the shift, the rows’ rotations and the cap’s phase, and returns the accepted step’s rotations for dense observation. A z table’s chord stays solprop’s.
What remains on solprop’s march, with the warning: the comparison methods’ general
path; a coupled flow on the host engine (off the eigenbasis route or wider than 16
channels, ADR-044); a norm scale other than PIController’s; a z table’s carrier
frame; lockstep batch replay.
Deviations from the matrix (§6.6).
A z table’s carrier frame is on solprop’s march (warned), where the matrix puts it on the native one: its chord is the table’s value at the reference element, which the native state would have to evaluate per position from the table’s element column. Not built; a small follow-up if a z-table carrier run is ever wanted.
Lockstep batch replay was at first not warned; it is now: the native batch marks its Adams members’ marches as host-stepped (
refusal, so the metadata sayshost) and warns once, as the matrix asks.
Verification.
Every capability route is staged, and its native scalars equal solprop’s exactly (
test_native_stage_tables.py, the flow’s and the maps’ rows included); a staged march takes the host-packed march’s steps, fields within a rounding bound, in both formulations where the formulation is admitted.Same build, estimate plans: the coherent wall, the taper coupling (92 attempts), the moving guide (211) and the separable routes take the Python march’s steps with fields bit for bit; the carrier frame’s hydrogen capillary takes its steps, fields bit for bit on a short window and 5.7e-16 apart on a longer one (the chord’s rounding: the reference’s
tensordotsums in its BLAS’s order).The z-table, coupled-flow, moving-guide, carrier-frame, dispersive and driver modules on the M5 Max; the full gate on CI.
Phase 5 — report (CUDA on NVIDIA A40s; HIP to come)#
Scope. Device builds, 2026-10-29: CUDA on NVIDIA A40s only (John Travers’s budget for the night: no rented devices). Opus 5.5 (AI) for John Travers.
Delivered.
CUDA builds take the native march. The march is host code; its engine calls the device kernels the Python march calls, one norm read-back a trial as before. z tables and the carrier frame stay refused on devices by name, as before.
HIP builds keep the warned host march until their own qualification (§6.7).
Two compile fixes found building for the A40:
general.cpp(from LupoLab/solprop#56, already onmain): nvcc refuses a device lambda’s first capture of a view insideif constexpr; the host-only branch is now guarded for host compilers alone. Device builds ofmaindo not compile without it.nvcc 12.8’s front end re-emitted the fused tables’ constant
std::arraybounds asunsigned long(...), which GCC refuses, where the exponential tables’ twin is emitted plainly (not reproduced in isolation); their host-side scratch is sized at run time instead. It was first misread as GCC refusing the strict-function attribute there: that attribute went (harmlessly, since the fused tables only subtract), and the sections’ recursion became a free strict function.
Verification (NVIDIA A40, CUDA 12.8.1, GCC 14.4 host, AMPERE86; one job):
The native package’s device tests: 31 passed. solprop’s device tests: 63 passed.
The native march’s modules on the device build (the decision traces, the lifecycle and admission tests, the staged-march comparisons, the resident-Adams and resume modules,
test_native_gpu.py): 203 passed. Four failed, each a configuration device builds refuse by name: the carrier frame’s admission test (now skipped on devices) and three z-table resume cases that were not device-aware before this SEP (now skipped there too).
Measured (A40, interleaved fresh processes, medians of three; the same steps in both arms):
case |
host µs/trial: Python march → native |
wall |
|---|---|---|
|
64 → 2.2 |
−6.7 % |
|
61 → 1.6 |
−10.6 % |
|
83 → 5.0 |
−1.8 % |
A trial costs about a millisecond on this device, whose FP64 rate is a sixtieth of its FP32’s, so the host’s share is small there; on the FP64 devices SEP 16 measured (kernels of 3–9 µs), the same saving is most of the trial.
Not done. The pinned single upload a trial (§6.7, item 2) was not built: the trial still uploads its scalars as before. HIP (AMD MI325X) and H100-class timings wait for a GPU budget.
Phase 5 — report, part 2 (HIP on an AMD Instinct MI325X; an NVIDIA H200)#
Scope. HIP builds, 2026-10-29: one AMD Instinct MI325X (gfx942, 304 compute units)
with a ROCm 7 prerelease stack (HIP 7.14, AMD Clang 23), within the maintainer’s GPU
budget for the night. Opus 5.5 (AI) for John Travers.
Delivered.
HIP builds take the native march, as CUDA builds do: the refusal that kept them on solprop’s Python march with a
HostMarchWarningis gone. z tables and the carrier frame stay refused on devices by name.A HIP compile fix. Since the x86-64 Adams lanes, the device predict pass declared a flag only the host lanes read; Clang (
-Werror,-Wunused-variable) refused the HIP build. GCC’s host pass of nvcc does not warn on the constant, so CUDA builds were not affected. The flag is marked[[maybe_unused]].The pinned single upload a trial (§6.7, item 2), which part 1 left undone, has since been built: a trial’s scalars cross in one copy from a ring of pinned buffers.
Verification (AMD Instinct MI325X, HIP, AMD_GFX942, band staging on; one session):
The native package’s device tests: 31 passed. solprop’s device tests: 63 passed (one skipped:
mlx). The native march’s modules on the device build (as in part 1): 203 passed, 7 skipped (the z-table and coupled-interaction refusals). The native radial and backend modules: 103 passed.On the same device the previous HIP default fails those lanes: 71 of the targeted modules, 6 native device tests and 27 of solprop’s, every one the
HostMarchWarningthat the suites raise as an error.The native and the Python march on the same build took the same steps and calls on every case below, and their fields were bitwise equal.
Measured (MI325X, python -m benchmarks ab --device and benchmarks.adams_host,
interleaved fresh processes, medians of three; the same steps in both arms):
case |
host µs/trial: Python march → native |
wall, native vs Python march |
|---|---|---|
|
22 → 1.3 |
−2.9 % |
|
17 → 0.8 |
−3.4 % |
|
40 → 3.0 |
−2.0 % |
|
15 → 0.5 |
−3.0 % |
|
29 → 2.8 |
−0.8 % |
A trial there is 160 to 550 µs of device time in 25 to 70 kernels of a few microseconds, which the host launches one by one, so the Python march’s 15 to 40 µs a trial was a smaller share than SEP 16’s measurements suggested. The rest of a trial was launch and synchronisation overhead in the native backend’s own device code, which the performance work that followed reduced (the benchmark ledger, “Device launches and the Adams passes on an AMD Instinct MI325X”).
On an NVIDIA H200 (CUDA 13.1, GCC 14.3 host, HOPPER90; the same session’s tree):
the native package’s device tests 31 passed, solprop’s 63 (one skipped, mlx), the native
march’s modules 203 passed and 7 skipped, the native radial and backend modules 103. The
native march against the Python march on the same build (three rounds, the same steps):
case |
host µs/trial: Python march → native |
wall |
|---|---|---|
|
49 → 2.2 |
−15 % |
|
44 → 1.2 |
−21 % |
|
70 → 5.2 |
−4 % |
|
41 → 0.8 |
−23 % |
|
65 → 4.7 |
−3 % |
The H200’s host ran the Python march at about twice the MI325X host’s cost a trial, which is most of the difference between the two devices’ walls here.
Not done. HIP’s device sanitizer does not exist (ROCm ships none), so the CUDA sanitizers’ role is not filled on HIP.
Handoff. Phase 5 is complete on both vendors’ devices (NVIDIA A40 and H200, AMD Instinct
MI325X); the SEP is ready for Final.
Closing record. Every phase is merged and reported, and the native march runs on
the native CPU build, CUDA and HIP. Q9 records that numba_cpu has no march, so solprop’s
Python march is the reference there. The manual’s Adams methods
page documents the native march and links this SEP. Every open question was decided
before acceptance (§12). The one item left is a device sanitizer for HIP, which ROCm
does not ship; until one exists, the CUDA sanitizers cover the shared device code.
The roadmap keeps a device-resident controller, which the SEP left open, and the
findings outside its scope that the phase reports named: the fixed host cost of a run
and -fno-math-errno.
Part III — History#
2026-10-28: Drafted by Opus 5.5 for John Travers, from a profiling study of the exponential
Multistep()on the native CPU backend (Apple M5 Max and AMD EPYC 7763). Recommends option B (policy as data, mechanism native), built on option C’s native tables and stage scalars.2026-10-28: The author’s decisions recorded:
Q1: amend
native/AGENTS.md’s policy rule;Q2: uncovered configurations run the host march with a warning, and the SEP aims to cover every configuration (§6.6; phase 4 is no longer optional; Q5 answered; Q14 added);
Q11: phase 1 stays in the SEP.
2026-10-28: The author’s decisions on the remaining questions recorded:
Q3: the call bound is 256 attempts and 0.1 s;
Q4: the interaction formulation is covered in phase 3;
Q6: device builds are phase 5, before
Final;Q7: phase 0 moves the general path into its own class;
Q8: no native Runge–Kutta march;
Q9: no
numba_cpumarch and no roadmap item, sincenumba_cpuis being demoted;Q10: no default changes, outside scope;
Q12: one debug line per attempt at
DEBUG;Q13: phase 0 fixes the commit order, and the rule may change if the implementation finds a reason;
Q14: the warned host-march list as proposed.
2026-10-28: AI review by GPT-6 (R1–R12) recorded by the author. The drafting agent (Opus 5.5) answered every point under Response to review and revised Part I.
2026-10-28: The author accepted the two proposals left open by the response: the collar flag \(w_Q\) is kept in
MarchSnapshotand reset bybegin_run(R9), and the native march may not be slower than the host march beyond the A/B spread of about 2 % (R12).2026-10-28: Refreshed by Opus 5.5 against the head of LupoLab/solprop#56 (a500d2d), after LupoLab/solprop#51 and #55 had merged, so that Part I describes the code it will be implemented on. No decision of the author’s is changed. The changes:
Measurements. §3.3 keeps the drafting tables and adds counted re-measurements on both machines. These cover walls, steps and calls, the lean host time per trial, and where a trial’s native time goes. The
rdwRunge–Kutta call count had been inferred about 2.5 times too high.Derived figures. §1, §2, §4.1, §6.1, §6.2, §6.9, §10 and §11 restate their figures from those measurements. The host cost left per trial is about half the drafting’s, so B’s gain over phase 1 is 2–11 % of a single-mode CPU run.
§6.9. It records which items landed, and how.
§3.1. The per-trial host work is rewritten to the current path.
§4.7–§4.9 and §7.2. On native both marches now read the same strict tables and Clenshaw values. Only the carrier chord differs, so the same steps are expected bit for bit on one build under estimate plans: recorded, never asserted.
§4.2. It gains the norm’s
regionand where each constant lives.§4.4. The floor fix also covers the stored order and the general path’s commit.
§4.6. It corrects the landing search to “at or beyond \(z + \tau\)”, and the equal approach’s to “strictly beyond”.
§5 and §6.4. The march’s name goes into the backend record’s settings rather than a new
RunMetadatafield: the record that holds how a run executed, outside the resume key, with no output-schema bump. §6.4 also records the current schema numbers, ADR-058’s restart of the output schema, and how this SEP relates to ADR-058’s reserved step-loop protocol.§6.5.5–§6.5.7 and §6.7. They gain the uncached rate weights, the one-row stage staging, the monitor reading that has landed, and the fused trial on devices.
§7.6. The no-regression rule is judged on an idle machine or a domain held alone.
2026-10-28: Reviewed by John Travers (human), who accepted the proposal and asked for it to be implemented on a branch based on LupoLab/solprop#56. Before setting the status, the drafting agent (Opus 5.5) made a final read of the refreshed Part I against SEP 0’s acceptance criteria and the code at a500d2d. That read is the source of §4.6’s landing-search correction; it found nothing else to change.
2026-10-28: Accepted by John Travers. Part I is frozen from here; deviations go into the phase reports of Part II.
2026-10-28: In progress. Implementation started by Opus 5.5 for John Travers on
feature/sep-0039-native-adams-march, based on LupoLab/solprop#56, with one draft pull request opened early so that CI tests each push.2026-10-30: Final, set by John Travers in LupoLab/solprop#59, after phase 5’s report, part 2 (the HIP qualification), and the closing record. Archived.
AI review — 2026-10-28#
Reviewer: GPT-6 (AI), acting through Codex. Reviewed all of Part I and the
associated index and roadmap changes on PR #52 at 40c34af. This review changes
only the reviewer metadata and appends findings here; it does not revise Part I
or record a human review. The author’s decisions in §12 remain the author’s.
Assessment: option B is a reasonable architecture, and keeping the host march available on the same engine gives it a useful verification path. The proposal needs the following corrections and contract decisions before acceptance. P1 marks a correctness or lifecycle issue to resolve before implementing the affected phase; P2 marks a specification or qualification issue to resolve before accepting the corresponding promise. All findings are open for the author’s response.
Evidence and limits. The review compared the specification with the current
march, controller, landing rule, stage tables, native adapter, interrupt guard,
monitor and snapshot code, and with SEPs 0, 16, 25 and 29. Small executable probes
confirmed R1 and R6 and the conditioning example in R7; no propagation benchmark
or native CPU/GPU qualification was run for this review. The existing virtual
environment did not contain solprop-native. The moment identity in §4.7 agrees
with equation (2.10) of
Hochbruck and Ostermann’s author manuscript:
putting k = p + 1 gives the stated factorial. The Lagrange/Vandermonde identity
was also checked against
Higham’s explanation.
The other bibliography entries and the profiling measurements were not independently
verified. The issues below concern execution and qualification, not a proposed
change to the propagation equations.
R1 — P1: distinguish the stored order from the order controlling startup growth (§§4.4–4.5, 7.1, phase 0). With fixed order, §4.5 sets
q_n = q_max, then §4.4 uses that order inF. The currentAdamsMarch._choose_orderinstead storesmethod.orderfor the next stencil but returnscurrentas the factor’s exponent order. At startup and after a restart,currentcan be smaller thanq_max. A probe withMultistep(order=5, variable_order=False), one available history sample and norm0.25gives a factor of1.6in the reference, versus1.0079368399158986under the draft’s formula. This changes steps away from any rounding threshold. Please specify these two orders separately and add fixed-order startup and post-restart traces for orders 2–6. Preserve the pre-refactor reference as an oracle: generating the whole corpus only from the newly extracted core could bless this same error in both halves.R2 — P1: define how phase 1 commits an acceptance before the next trial exists (§6.1 option C, §6.5.7, phase 1). Fusing a trial’s accept into the next trial call leaves the accepted state pending. Today
_decidecalls the engine’s accept before moving the position, recording statistics, checking the monitor or returning a plane. There may be no next trial: a save plane, natural stop, exhausted call budget, final endpoint or interruption can intervene. A monitor verdict can also change the next trial’s collar setting after the current state must already have been adopted. Please specify a flush/commit operation and its ownership at every such boundary, or retain a separate accept until phase 2 can decide and commit inside the native call. Include a one-step run ending at a plane, an immediately exhausted budget and a monitor change in phase 1’s tests; measure the crossings actually left by that design.R3 — P1: enter the signal guard before native mutation and make host publication complete before callbacks (§§4.4, 4.8, 6.5.7, 7.5). The text puts the host’s row bookkeeping inside
committing()after the native call.solprop._interrupts._handleraises immediately outside that guard, including when Python resumes from a native call that has already committed several steps. The low-storage precedent guards the native call itself as well as the position update. Please require one guard spanning native execution, synchronization of the host position/proposal/history/ledger and ingestion of all returned rows. Also separate ingestion from user progress callbacks:_recordinvokes_progress.step, whose exception is not deferred bycommitting(). A callback failing on the first replayed row must not leave the native state several commits ahead of the host records. The accepted-step floor fix needs the same complete publication rule, including the accepted attempt row and kink restart, before raising. Inject signals at the return boundary and exceptions from callbacks and from the floor path; verify the terminal checkpoint’s state, position, rows and counters together.R4 — P1: preserve call charges when source evaluation fails (§§4.3, 6.5.7–6.5.8, 7.1). The algorithm adds
startupafter the startup evaluation andpredictorafter the trial and its reductions. The reference increments them beforeengine.startandengine.exponential_trial, respectively. A source call that raises, or succeeds before a later reduction fails, has still spent work. Rolling back to the last committed ledger or incrementing only after success undercounts it, which matters when that ledger is saved and a budgeted run resumes. Please distinguish accepted-state commits from attempted-call charges, specify the failure stage and exception category in the returned error record, and preserve the reference’s charging boundary. A message alone cannot reliably select the original exception type. Add independent call-counter tests for startup failure, predictor failure and failure after the source returns, including export/resume of their ledgers.R5 — P1: make event draining total for empty, rejected and coincident returns (§§4.8, 6.4–6.5, 7.1). The event table is headed “after an accepted step”, but a call bound can end a rejected attempt, and a budget/error event can return without an attempt or without any acceptance. The prescribed unconditional final
_take_step_stats/_natural_stopthen counts a step that did not happen. Conversely, a call can finish after accepted steps followed by rejections, so its last attempt is not its last accepted step. Please specify entry checks, per-attempt checks, zero-row and zero-acceptance returns, and exactly-once accounting of each accepted step. Define how simultaneous plane, monitor, statistics, natural-stop and call-bound events are all serviced, including a dense crossing whose natural stop remains due. State how_record’s backend hook is handled: native_on_accepted_stepcurrently callspromote_fsal, so replaying that hook in bulk is not automatically an inert record operation. Finally reconcile §6.5.8’s per-attempt progress callbacks with Q12’s “progress once per call”. Testmax_calls=1, rejection-only returns, accepted-then-rejected returns, and several coincident events.R6 — P1: specify replay’s separate stopping and kink tolerances (§§4.3, 4.6, 4.8, phase 3). Replacing only the proposal and landing steps does not describe the existing replay.
_replay_tousesreplay_tolerance(schedule, target), withmax(LANDING_ULPS, schedule.n_steps)ulps, and uses the same slack to identify replayed kinks. It also advances_schedule_index. The draft otherwise leaves the adaptivereachedtest in force and omits the replay cursor from its state description. The existing documented witnessFixedSchedule(np.full(160, 1e-3 / 160))ends 12 ulps below1e-3in the review probe: adaptivereachedis false, while the replay test succeeds. Following the draft literally can report exhaustion for this valid schedule. Please specify the cursor, edge arithmetic, replay tolerance, exhaustion and restoration rules, with tests for accumulated-edge drift, kinks and a resume part-way through the schedule.R7 — P2: qualify coefficient tables over the nodes the march actually permits (§§4.7, 7.2). The claimed modest conditioning does not follow from the default growth and rejection factors. They are configurable, multiple rejections compound a shrink, and a forced landing can shorten a step by an arbitrary ratio without discarding history at an ordinary save plane.
equal_approach=Falseis admitted. For example, four preceding unit steps followed by a trial shortened to1e-10give corrector nodes[1, 0, -1e10, -2e10, -3e10, -4e10]; their monomial Vandermonde has an infinity-norm condition estimate of about2.05e53in the probe, despite having only six nodes. Multiplying that estimate by operation count and unit roundoff gives no useful accuracy test. This is not itself evidence that the existing root-product algorithm fails: its error needs analysis appropriate to that algorithm and to the weights ultimately applied. Please remove the unsupported conditioning claim, include actual landing/rejection histories and non-default factors in the corpus, and require finite, informative bounds supported by an independent polynomial or higher-precision oracle. Any new admission limit or restart rule would be a numerical-policy decision for the author, not an implementation convenience.R8 — P2: make the exact-arithmetic promises consistent with the build and threshold contracts (§§4.8–4.9, 6.5.1, 6.7, 7.1).
SOLPROP_NATIVE_STRICT_FUNCTIONalone is empty under Clang; the function body also needsSOLPROP_NATIVE_STRICT_BLOCK. Both macros are empty under CUDA/HIP, including the CUDA host pass (native/include/solprop_native/fp.hpp). Thus that annotation alone does not establish the promised strict controller on every phase-5 build. Also, §7.1 permits a one-ulppowdifference while still demanding identical order and landing decisions at exact thresholds. A small weight or proposal difference can change precisely those branches. Please specify the host compilation and elementary-function requirements, and separate exact decision tests on identical supplied scalar scores from numerical tests that independently compute those scores. Either ensure common arithmetic for threshold witnesses or define margin-aware expectations. Verify strictness on each supported compiler. The nominal1e-14norm difference in §4.9 also needs a case-specific basis, especially after R7.R9 — P2: bound the snapshot portability promise and account for quiet collar state (§§4.2, 6.5.9, 7.1, phase 4). Changing the march on the same resident engine is different from changing engines. A frozen-residual native history exports
appliedrows;HostHistory.load_rowsrefuses those, while_ResidentAdams.load_rowsrefuses the host engine’sweights_<row>format. Therefore retaining the existing format cannot by itself make every phase-4 snapshot continue onnumpy_refornumba_cpuwithout restart. Please give a support matrix and retain the existing named refusal, or explicitly design and qualify a conversion with its memory cost. Separately, §4.2 listsw_Qas march state, but neither the listed snapshot fields nor the currentMarchSnapshotstores_collar_was_quiet. Its value controls the re-entry cap. State whether load restores it, reconstructs it from monitor state or deliberately resets it, and test a stop/resume across a quiet-to-active transition. Verify that a reused backend starts with the specified flags too.R10 — P2: use accuracy envelopes for ordinary adaptive resume (§§4.9, 6.5.9, 7, 9). The unconditional promise that a resume agrees with an uninterrupted run “to rounding” contradicts SEP 25 §4.4, which explicitly classifies ordinary resumed-versus-uninterrupted comparisons as accuracy comparisons. The native and host marches can compute different tables and proposed steps, and can then choose different orders or acceptances, even if the serialized state was copied losslessly. Please keep exact checks for the snapshot transfer, reserve rounding budgets for matched fixed-order schedules with matched history and plane handling, and add adaptive resume accuracy cases against a converged reference. Throughout the interface and compatibility wording, replace agreement “to the run’s tolerance” with the qualified per-case accuracy envelope: local
rtol/atoldo not bound the global error. This follows the policy already cited by the proposal.R11 — P2: make admission a single consistent matrix (§§2, 6.6, Q14). Q14 permits the warned host march for
ComparisonMultistep’s general path, but §6.6 lists that same path as “Refused by name, unchanged”. The current_refuse_adamsdoes not impose that blanket refusal;AdamsMarchusesHostHistorywhen the fused/exponential engine is unavailable. Moreover,ComparisonMultistepis a subclass ofMultistep, and some comparison configurations use the fused resident path, including the Hermite dense interpolant covered bytest_resident_adams.py. A type-membership check alone does not enforce the stated scope. Please enumerate admission by formulation, corrector/interpolant, resident-engine capability, scale and build, distinguishing native march, warned host march, private unwarned witnesses and pre-existing backend refusals. Record device rows atFinalafter phase 5, rather than leaving “until phase 5” as a final exception.R12 — P2: make the performance decision reproducible and independent of the other optimisations (§§3.3, 6.1, 6.9, 7.6, 11). The measurements are useful motivation, but the draft links no committed timing ledger or profiling artifact for the baseline, and infers some trial counts from timings. The estimated gain of B over completed phase 1 is the relevant evidence for accepting a second controller implementation. Please name the reproducible benchmark invocation/configuration, exact measured window, measured attempt and source counts, and public ledger entries. Compare the two marches on the same implementation revision with N1–N7 held fixed, and report end-to-end time and event frequency as well as residual host cost; busy monitors and
step_planes=1return every step. State the acceptance rule for regressions on expensive-source and frequent-event cases. The claim that call bounds cost “at most 0.04 %” assumes the time bound is limiting; the 256-attempt bound can instead dominate cheap trials. Measure it. Finally, §7.6’s “then complete runs” must name short-guide lengths that finish in seconds, consistently with §7 and the repository’s qualification rule, rather than authorize full-length tracking problems by implication.
The author should answer R1–R12 in a separate response subsection and revise Part I thereafter, as SEP 0 requires. This review does not accept the SEP or change its status from Draft.
Response to review — 2026-10-28#
The drafting agent, Opus 5.5, checked each point against the code before answering. All twelve are taken. Part I is revised as follows; section numbers are those of the revised text.
R1: taken. Confirmed in
AdamsMarch._choose_order: under a fixed order it storesmethod.orderbut returns the step’s own order for the factor.§4.4 (step 6–7) and §4.5 now distinguish the stored order \(q_s\) from the control order \(q_c\) and its norm \(\varepsilon_c\). The review’s probe is quoted in §4.5.
§7.1’s corpus is generated from the reference before the refactor, through a stub engine, and both the refactored core and the native march must reproduce it. Its traces cover fixed-order startup and post-restart growth at orders 2–6.
Phase 0 is updated to match.
R2: taken, by dropping the fusion. Option C and phase 1 keep the trial and the accept as separate calls (§6.1, §11). The flush that fusion would need belongs at exactly the boundaries the review lists, and phase 2 removes those crossings anyway, by deciding and committing inside the native call.
Option C’s figures are recomputed without the fused accept: −265 to −335 µs per helium trial on the EPYC, two crossings per accepted trial.
R3: taken. Confirmed:
committing()only defers inside its block, and_recordcalls the progress reporter.§6.5.7 now requires one guard spanning the native call, the synchronisation of the host’s position, proposal, order, history and ledger, and the ingestion of every row.
User code runs only after the guard: progress callbacks, debug lines and the event step’s work. The floor publishes the accepted attempt, its kink restart and the ledger before it raises (§4.4).
§7.5 injects signals during a call and at the return boundary, and exceptions from a progress reporter and from the floor. It checks the terminal checkpoint’s state, position, history and ledger together.
R4: taken. Confirmed: the reference charges
startupandpredictorbeforeengine.startandengine.exponential_trial.§4.3 now charges before evaluating.
§6.5.7’s error record carries the stage, a category code for the exception class (never parsed from the message), and the message, with the ledger as charged.
§7.1 counts calls independently through startup, trial and post-source failures, including a snapshot and resume of their ledgers.
R5: taken. §4.8 now specifies:
the entry checks (as
march_to’s, so no call is made when no attempt is due);the per-attempt, per-accepted-attempt and pre-attempt tests;
returns with no rows, rows without acceptance, and acceptances followed by rejections;
exactly-once accounting of each accepted row;
the single event step, for which the host runs the reference’s own
_take_step_statsand_natural_stop, so coincident events (including a natural stop due on a dense crossing) are serviced in the reference’s order.
The event table is split into accepted-step events and the other returns, which have no event step.
_on_accepted_stepis not replayed: on native it callspromote_fsal, whose quadrature scoring the native march performs itself after each acceptance. Progress is reconciled with Q12: one callback per attempt, delivered in bulk, so a display updates once per call (§4.8, §6.5.8). The tests the review asks for are in §7.1.R6: taken. Confirmed:
_replay_tousesreplay_tolerancefor both stopping and kinks, and advances_schedule_index; a load rebuilds the cursor with_schedule_index_at. §4.3 now specifies:the cursor;
the precomputed edges;
the slack \(\max(u, n_{\mathrm{steps}})\) ulps;
the stopping, kink and exhausted rules;
the restoration on load.
reachedis explicitly not used on a replay. §7.5 adds the 160-step witness, kinks on edges, exhaustion and a resume part-way. Phase 3 refers to them.R7: taken. The conditioning claim is removed. §4.7 gives the review’s example and says why a condition-number budget is uninformative here.
§7.2 replaces the budget with an exact rational oracle (
fractions.Fraction, no new dependency).The node sets come from real landing and rejection histories, with non-default factors and
equal_approach=False.The error is measured in the applied weights. The bound is finite and relative to the reference’s own error (at most four times it, or 16 ulp).
No restart or admission rule is proposed. If the reference proves inaccurate on admissible histories, that is reported to the author as a numerical-policy question (§4.7).
R8: taken. Confirmed in
fp.hpp:SOLPROP_NATIVE_STRICT_FUNCTIONis empty under Clang, and both macros are empty when__CUDACC__or__HIPCC__is defined, the host pass included.§6.5.1 puts the controller in a host-only translation unit compiled with contraction off by a per-source option on every build, checked on each supported compiler. It names
powfrom the platform’s C library as its only elementary function.§7.1 separates exact decision tests on identical supplied scores (where the threshold witnesses live) from numerical tests of \(F\) and \(W_k\). Those are exact with a common C library and at one ulp otherwise, with margin-aware whole traces.
§4.9’s nominal \(10^{-14}\) is removed. Phase 1 measures the norms’ spread per case (§7.2).
R9: taken, with one proposal for the author.
§6.5.9 now gives a support matrix. It keeps today’s named refusals between the native
appliedrows and the host engines’weights_<row>for frozen-residual flows, and proposes no conversion.On \(w_Q\), confirmed: neither
MarchSnapshotnorloadkeeps_collar_was_quiet, andbegin_rundoes not reset it. The SEP now proposes restoring it from the snapshot (one new field, output schema +1 in phase 0) and resetting it inbegin_run, fixed in the Python march first.Reconstructing it from the monitor’s state at the plane was considered and not proposed. The plane’s own check may change the flag after the last attempt, so it would not reproduce the uninterrupted run’s cap.
§7.1 and §7.3 test the quiet-to-active resume and a reused backend.
Whether to snapshot \(w_Q\) (proposed) or deliberately reset it is the author’s call. Decided 2026-10-28 (John Travers): snapshot it and reset it in
begin_run, as proposed.R10: taken. §6.5.9 states the three levels:
the transfer is exact;
an adaptive continuation meets the case’s accuracy envelope against a converged reference;
a rounding budget applies only to matched fixed-order schedules.
“To the run’s tolerance” is replaced by the per-case accuracy envelope throughout (§4.9, §6.6, §9). §7.3 adds adaptive resume cases. §8 restates the Adams manual page’s resume paragraph, which today says “to rounding”, in those terms.
R11: taken. Confirmed:
_refuse_adamsdoes not refuse the general path, and the one-order-higher PEC comparison variants take the resident fused path, the Hermite interpolant included. §6.6 replaces the list and the wrong “refused by name” line with one matrix. The matrix:is keyed on formulation, corrector and interpolant, resident engine, norm scale and build;
distinguishes the native march, the warned host march, the unwarned private paths and the existing refusals;
records device builds at
Final.
Q14’s “device builds, until phase 5” is moved there as a state of the branch. This is a consistency correction under Q6, not a change to the author’s Q14 decision.
R12: taken.
§6.1 adds the estimate the review asks for: B’s gain over a completed phase 1. That is 125–220 µs per helium trial and 85–150 µs per argon trial on the EPYC, or 7–14 % and 9–23 % of the runs once §6.9’s items have landed. It also notes that frequent events return every step.
§7.6 now compares the two marches on the same build and revision, with N1–N7 held fixed (a per-arm environment for
ab). It reports end-to-end time, counted attempts, calls and events, and residual host time.The cases add an expensive source (a short
ctcwindow) and frequent events (a busy monitor,step_planes = 1,row_period = 1).The acceptance rule is no regression beyond the A/B’s measured spread, about 2 %, on any case.
Runs use
--lengthfractions that finish in seconds; complete tracking runs stay with SEP 38’s release procedure.The “0.04 %” figure is replaced by an estimate that names which bound binds, to be measured in phase 2 (§6.5.7).
Phase 0 records the baseline publicly (§3.3, §7.6). The profiling study’s numbers remain the motivation; the counted entry is the evidence.
The 2 % rule is a proposal; the author may set another. Decided 2026-10-28 (John Travers): the 2 % rule, as proposed.