Benchmarks#
How solprop’s performance is measured and judged, and what the measurements so far
indicate. A performance claim names what it measured, the build, the machine and the
result; the rules below say how to take it. The numbers in the indicative sections are
indicative: each was measured once, on one machine, for one change. Re-measure before
relying on any of them. Rows naming numba_cpu record a backend that
SEP 43 retired; they can no longer be re-measured, and
stay as what was measured.
Where things are#
The benchmarks are
benchmarks/(SEP 38), run withuv run poe bench(python -m benchmarks).tracktimes complete runs of the tracking set (about a dozen representative workloads, with each interface’s default integrator and tolerances) at one thread and the production width, in about an hour per machine;abcompares two builds while a change is made. Running the benchmarks is the manual.The history is
benchmarks/history/: one CSV per hardware set, named by its CPU model and core count, with a few rows per release.python -m benchmarks historysummarises it. Full run bundles stay in the ignored.benchmarks/directory.CPU performance: what was tried records the compiled CPU backend’s performance experiments: what was adopted, measured and rejected, and what remains open. Read it before repeating an experiment.
backends.md describes each backend’s execution design and the performance policies it adopts.
Rules#
State thread counts and plan flags; never compare across different ones.
A hot-path change needs a benchmark entry in its pull request or commit message.
Estimates are labelled as estimates in the docs until a measurement exists.
Do not tune for the benchmark: the cases are the target simulations at their real sizes.
A performance change is judged on the aggregate, not case by case: the equal-weight geometric mean of its per-case timing ratios over the cases it can affect, declared before timing, taken separately for the production thread policy and for one thread, with the complete per-case table beside it and a whole-suite aggregate when the work closes. A change is adopted when the production saving is significant: more than 5 %, and the geometric mean of the conservative per-case ratios
(candidate median + 2 MAD) / (before median − 2 MAD)below one, a non-positive denominator counting as unresolved. Small regressions on individual cases, or at one thread, are accepted when the aggregate improves; they stay visible in the table and do not automatically veto the change. Numerical bounds are never relaxed to make a change pass. The A/A spread of the same comparison says how large a difference has to be to count (comparing two builds).
What is timed#
Release tracking times complete runs. The tracking set’s cases are complete guides at production sizes, with the library’s defaults, so the history says how long the usual simulations take now. Construction, translation and backend construction are timed separately; an untimed warm-up builds plans first (what is timed).
A change in progress may time less.
ab --lengthruns part of each guide from the entrance, which is fine for comparing two builds on the same work. Report the fraction run, and do not extrapolate an entrance window to a complete-run cost: the expensive part of a propagation (compression, ionisation, the Raman shift) often comes late.A later state of a long propagation is timed by replaying a saved witness into a short run, not by regenerating the propagation.
Compare two source trees with alternating pairs (A/B/A/B…), and report the A/A spread beside the ratio; a difference inside the spread is not a result.
Reporting a performance claim#
The machine record. Every measurement carries the CPU (or device) model, core count, memory, operating system, Python and dependency versions (including the Kokkos build and its profile), thread counts, FFTW plan flags and wisdom state, the source revision and whether the tree was dirty, and the load before and after. A tracking run’s plan and results record these; a hand-run measurement states them. Results are compared only within one machine.
A quiet machine. Absolute numbers are taken on a dedicated workstation or an exclusively allocated node. A busy laptop is fit only for same-process A/B comparisons, labelled as such: read their ratios, not their times. Report the A/A spread of repeated baseline runs beside every ratio; a difference inside it is not a result.
What goes in a pull request. The measured window and what it includes, the build and machine record, the per-case table with medians and spreads, the declared aggregate and its verdict, and any case that regressed. Concise evidence belongs in the change description; raw runs stay local.
Indicative results#
Indicative ratios, not absolute times. Each is a short window (see above) on one machine; the rows’ cases, grids and step lengths differ, so rows share no scale. They say which backend or method to expect to win, and by roughly how much.
Backends and builds#
Speed-up over numpy_ref, one thread, ten-step windows on an 18-core AVX-512 server
(one socket, one NUMA node) with a local Kokkos build (vendor BLAS, native architecture).
The capillary cases have 3 or 4 channels on a 513-bin grid; the general-route cases are a
two-channel step-index pair and a graded-index guide at 2304 quadrature nodes.
case |
|
Kokkos CPU |
|---|---|---|
M1 soliton (GNLSE), reduced |
2.6× |
2.1× |
GNLSE soliton with Raman |
2.6× |
2.6× |
M2 (RDW capillary), reduced |
4.9× |
5.9× |
M2 (RDW capillary), full |
7.1× |
8.6× |
capillary, no wall |
2.5× |
3.9× |
capillary, scalar coherent wall |
2.1× |
3.5× |
capillary, TE/TM coherent wall |
4.2× |
8.8× |
capillary taper with wall (coupled flow) |
2.4× |
2.8× |
general route, step-index pair |
2.7× |
5.5× |
general route, graded index |
2.9× |
4.7× |
Threads. numpy_ref does not thread. At four threads Kokkos CPU ran the general route
2 to 6 times faster than numba_cpu and the capillary cases 1.5 to 3 times faster. On
larger general-route and coherent-wall workloads (2 to 32 channels, up to 16 384 fine
samples) at 18 threads it was 1.2 to 4 times faster than numba_cpu at 18 threads and 6 to
40 times faster than single-threaded numpy_ref. Small capillary grids stop scaling beyond
a few threads on every backend.
Kokkos build profiles, same server, seven ten-step capillary and general-route cases.
The vendor BLAS and the instruction set are separate gains. Against the reference build
(no vendor BLAS, generic x86-64), linking OpenBLAS alone made the straight capillaries 2.6
to 3.8 times faster at one thread (the tapers 1.7) and left the general route unchanged;
compiling for x86-64-v3 alone made the general route 1.4 to 1.5 times faster and left
the capillaries unchanged. A distribution-style build (x86-64-v3 and the
scipy-openblas32 OpenBLAS, ADR-034) ran within about 5 % of the local build
(-march=native, the system OpenBLAS) on every case at one and four threads and beat
numba_cpu on all seven; x86-64-v4 added 7 to 14 % on the general route only. The
distribution wheels now target a lower baseline (CPU performance).
At one thread, in milliseconds per ten steps:
case |
reference |
distribution-style |
local |
|
|---|---|---|---|---|
scalar wall |
61.4 |
21.8 |
22.8 |
35.0 |
TE/TM wall |
1547 |
390 |
402 |
480 |
taper wall |
90.3 |
54.6 |
56.6 |
64.7 |
general pair |
188 |
118 |
120 |
241 |
general GRIN |
358 |
245 |
244 |
454 |
On the same server MKL was 1.4 to 1.8 times faster than OpenBLAS on the TE/TM wall, where the MKL build keeps the custom complex kernel, and level elsewhere.
GPU against a whole CPU socket. On the same seven cases, the Kokkos CUDA build on a data-centre NVIDIA GPU and the HIP build on an AMD Instinct GPU ran 2.7 to 18 times faster than the Kokkos OpenMP build on all 18 cores of the server, the graded-index general route most. These grids are small for a device; the ratios say nothing about large grids.
The tracking set on the release wheel, without Numba#
SEP 43’s final tree against the history’s 0.1.0 rows:
python -m benchmarks track with CI’s cp314-abi3 wheel at its x86-64-v3 build, three repeats,
passive threading, an exclusively allocated AMD EPYC 7763 node at 1 and 16 threads. The rows
aren’t appended, because the history holds releases.
case |
threads |
now, s |
0.1.0 native, s |
ratio |
0.1.0 |
|---|---|---|---|---|---|
|
1 |
43.57 |
44.16 |
0.987 |
1.24 |
|
1 |
0.583 |
0.578 |
1.009 |
1.60 |
|
1 |
11.71 |
11.63 |
1.007 |
1.80 |
|
1 |
1.075 |
1.058 |
1.016 |
2.63 |
|
1 |
26.50 |
26.44 |
1.002 |
1.86 |
|
1 |
3.941 |
3.920 |
1.005 |
2.79 |
|
1 |
1.250 |
1.240 |
1.008 |
1.98 |
|
1 |
6.101 |
6.119 |
0.997 |
2.40 |
|
1 |
13.34 |
12.07 |
1.105 |
1.45 |
|
16 |
39.05 |
37.84 |
1.032 |
1.37 |
|
16 |
0.587 |
0.582 |
1.007 |
1.62 |
|
16 |
5.112 |
5.148 |
0.993 |
3.18 |
|
16 |
0.770 |
0.785 |
0.980 |
3.70 |
|
16 |
9.852 |
9.861 |
0.999 |
2.30 |
|
16 |
3.271 |
3.261 |
1.003 |
3.37 |
|
16 |
1.123 |
1.322 |
0.850 |
2.22 |
|
16 |
5.163 |
5.250 |
0.983 |
2.82 |
|
16 |
108.4 |
111.5 |
0.972 |
2.44 |
|
16 |
20.50 |
21.17 |
0.969 |
1.79 |
|
16 |
29.10 |
25.26 |
1.152 |
1.24 |
The geometric mean is 1.002, with the same steps on all twenty, and Numba’s rows are 2.08 times
slower. These are runs on different days, so a few per cent is not a result. radial_filament_256
is left out, since its guide changed at revision 2.
kerr_beam: the rise isn’t the removal. The branch against themainit came from gave 0.9956 and 0.9994 on these rows, with identical fields. Its size matches the transverse edge’s read cost, +10.5 % on the Cartesian case there, though this comparison didn’t bisect it.The same work on the Xeon Gold 6240: at one thread, the same steps and ratios of 0.99 to 1.04 in two runs, apart from
kerr_beam_192at 1.18 to 1.20 andrdwat 0.99 to 1.09. At 36 threads the two runs of one wheel differ from each other by 0.84 to 1.57 per configuration, unpinned across two sockets, so the 36-thread column gives no verdict.
Kokkos CPU operation-size threading#
The Kokkos CPU backend chooses each operation’s thread width from its dimensions at construction (tuning; the thresholds are under reference tables). It was calibrated on an 18-core arm64 laptop and a two-socket, 36-core AVX-512 server, against one thread on the small cases and against the previous default build on the large windows (best of complete paired rounds):
window |
arm64 laptop |
two-socket server, all 72 logical CPUs |
|---|---|---|
small cases (eight case/track configurations), geometric mean |
2.00× faster |
2.06× faster |
averaged helium RDW |
same within noise |
same within noise |
resolved blueshift |
2.0–2.6× faster |
1.8–2.4× faster |
reduced comb-to-continuum (revision 1, 1 ps) |
2.8–3.1× faster |
2.4–3.6× faster |
RDW ten-step window |
1.81× faster |
2.33× faster |
reduced comb-to-continuum ten-step window (revision 1, 1 ps) |
3.18× faster |
3.85× faster |
full comb-to-continuum ten-step window |
7.9 % faster |
2.7 % faster, noisy |
general modal |
4–11 % faster |
same, noisy |
Cartesian and radial filaments |
same within noise |
within 5 %, noisy |
scalar / TE-TM walls |
44 % / 17 % faster |
TE-TM same |
Separate thresholds for copies and arithmetic matter: an 8,192-element temporal copy is slower threaded, while arithmetic stages of the same size gain from the full pool. A wide response chunk followed by a full-width FFT needs both at full width, and a serialised coupled-flow matrix function on the scalar wall took about 20 times its threaded kernel’s time; both were corrected before the comparison above. “Noisy” rows did not meet the predeclared stability check; taper, short-line and planar controls were not requalified.
The exponential Adams trial without a coupled flow is two passes around its source evaluation, a prediction and a correction that also reduces the defects’ norms. An element of them costs about ten stage entries, so they take a class of their own, its work the field’s elements times the history’s rows (seven at the default fifth order). Forced to 1-16 threads of a 16-thread process whose idle threads sleep, over single-column capillaries of 401 to 10,241 elements, on 16 cores of a dual AMD EPYC 7643: four threads lost at 4,487 (0.8×) and gained 1.1-2.1× (median of two rounds) from 5,607 to 71,687, while sixteen beat four on argon from 11,207 but lost to them on helium up to 17,927 and lost to one thread at 8,967 (0.7×), a fork and join of sixteen sleeping threads costing 100-200 µs. The first round had the node to itself; in the second, with other jobs on it, the serial times held within 5 % while the threaded ones slowed by up to 2.3×, the sixteen-thread ones most, yet four threads stayed at least as fast as one from 5,607 up. On a loaded Apple M5 Max (indicative) four gained 1.0-1.6× at 4,487-8,967 and sixteen beat four only at 71,687 (4.7× against 2.8×). The class therefore threads at four from 8,192 and takes the pool from 65,536 on both platforms.
A stable kernel team#
GNU OpenMP keeps one pool of threads for the main thread’s parallel regions. When a region
asks for fewer threads than the last, the surplus threads end; the next wider region
starts them again. A four-thread kernel between full-width ones therefore costs a thread
creation per surplus thread each time: alternating 16- and 4-thread regions took about
490 us a pair, against 40 us for two 16-thread regions and 23 us for 16 and 1 (one socket
of a Xeon Gold 6240, idle threads sleeping; twelve clone3 calls a pair). FFTW’s threaded
loops run on the runtime’s whole team and only hand out fewer iterations, so a
four-thread FFT plan costs no resize. At 16 threads, h2_raman_averaged (an exponential
Adams trial pass and a response at four threads beside a full-width stage) re-created
twelve threads every source evaluation, and was slower than at one thread.
A kernel pool that already holds a full-width instance now hands it to medium-width
kernels too; a pool without one keeps four threads (averaged helium, whose other kernels
are serial, took 14 % longer on a Xeon Gold 6240 with its Adams passes at full width). Workspaces ask for their stage
instance first. Interleaved benchmarks ab at 16 threads against the parent, 3 rounds,
--length 0.1, passive-bound placement, the same steps and fields to rounding (at most
1e-13 relative):
case |
Xeon Gold 6240 (socket 1) |
AMD EPYC 7763 (16 cores) |
|---|---|---|
|
0.526 [0.521, 0.531] |
0.483 [0.483, 0.510] |
|
0.717 [0.700, 1.070] |
0.806 [0.800, 0.850] |
|
1.001 |
0.979 |
|
0.984-1.036 |
0.983-1.106 (node noise) |
geometric mean (12 cases) |
0.924 |
0.938 |
The unchanged cases have no medium-width kernel or none beside a full-width one; their
spread is the A/A spread (about 2 % on a Xeon Gold 6240, up to 10 % on the shared EPYC nodes). On
a Xeon Gold 6240 h2_raman_averaged now takes 0.79 s at 16 threads against 1.06-1.12 s at one.
Every medium width serial instead lost 2.8 times on averaged helium (EPYC geometric mean
1.08); every medium width full, the FFTs included, gained on resolved cases but lost on the
averaged ones (EPYC 0.94, Xeon 0.90 with averaged helium 1.22).
The promotion applies everywhere but macOS (stable_kernel_team in core.hpp). On an
18-core Apple M5 Max, whose cores are of two kinds, the 11207-element Adams passes of
argon_blueshift at 18 threads instead of four took the window from 3.0 to 3.2 s, and
h2_raman_averaged at 18 threads ran 0.86 of main’s time without the promotion
against 0.94 with it. The Windows wheel also runs on LLVM’s libomp, which keeps a
narrower region’s threads, but there the promotion still gained: on eight cores of a
Google Cloud C4D (AMD EPYC 9B45) h2_raman_averaged took 0.906 [0.899, 0.914] of its time
with it at eight threads, every other modal case within 1 % (5 rounds, --length 0.2).
Compact CPU teams#
A problem of one column on one node (the GNLSE, the mode-averaged capillary) has few
operations large enough to thread, and between them serial transforms and products. At
16 threads gnlse_raman_shock (16384 bins, 6289 source evaluations a --length 0.1
window) ran its stage entry on all sixteen threads and its responses on all of them too,
its FFTs on four threads within the runtime’s whole team, and gained nothing over one
thread (5.86 against 6.02 s on one socket of a Xeon Gold 6240). Its serial kernels each
ran 1.2-2.5 times slower than in a one-thread process; with every pool forced serial in
the same 16-thread process they ran at their one-thread times, so the idle pool costs
nothing and the wide regions do. A per-thread perf stat of the timed window shows both
parts: the main thread ran at about 2.7 GHz instead of 3.5 (waking fifteen cores every
0.9 ms evaluation holds the socket in its many-core turbo bin), and its loads found
16 million lines modified in another core’s cache, against 60 thousand.
Such a problem (compact_problem: one column, one node, one transform polarisation, no
general modal route, coupled flow or separable geometry, fewer than 65536 fine samples)
now runs on a compact team: its kernels at most eight threads wide, medium widths
promoted to that team as in a stable kernel team, and its FFT and
Raman plans at most eight (see the end of this section for their sizing). FFTW’s threaded loop normally opens the runtime’s whole team
(sixteen threads for a four-thread plan); beside an eight-thread kernel team that made
GNU OpenMP end and restart threads at every pair, and a whole run took 2.6 times as long.
The native backend therefore installs FFTW’s loop callback (FFTW 3.3.9 and later, the
OpenMP FFTW only): outside a scope it runs the jobs on the runtime’s team, as FFTW’s own
loop does, and inside the scope that every native call of a compact problem opens it runs
them on the problem’s team. A build without the callback keeps a compact problem’s
transforms serial. An observation now copies the field on the stage’s instance rather
than the default one, which would also widen the team.
Sweeps of the whole native pool on the same 16-thread process (SOLPROP_NATIVE_THREADS,
2 rounds, wall s) chose the team: gnlse_raman_shock 5.84 at 16, 4.72 at 4, 4.64 at 6,
4.76 at 8, 5.42 at 12; h2_raman_averaged 0.71 at 16, 0.61 at 4 and at 8; the 33-node
cases lose at 4 (argon_blueshift 1.77 against 1.48 s, rdw 0.90 against 0.75 s) and
stay with their own sizing. Interleaved benchmarks ab against the parent on a Xeon Gold 6240,
3 rounds, --length 0.1, passive-bound placement, the same steps and fields to the
measured-plan floor (at most 6e-14 relative):
case |
1 thread |
16 threads |
|---|---|---|
|
0.995 |
0.801 [0.794, 0.831] |
|
0.992 |
0.888 [0.883, 0.891] |
|
1.027 [0.971, 1.055] |
0.979 |
|
0.983-0.985 |
0.995-1.017 |
|
0.988-1.009 |
0.999-1.009 |
gnlse_raman_shock now runs 1.25 times faster at 16 threads than at one (4.76 against
5.94 s), where it ran at the same speed.
On the compact team the transforms kept a 16-thread sizing: the four-thread plans of the
16384-sample transform left half the team idle. A compact problem’s threaded transform now
takes its whole team. Three refinements were measured on both machines, interleaved
benchmarks ab at 16 threads, 3 rounds, --length 0.1, the same steps (AMD EPYC 7763: 16
cores of a quiet shared node):
change |
Xeon Gold 6240 |
AMD EPYC 7763 |
|---|---|---|
threaded transforms at the team width: |
0.943 |
0.981 |
the same: |
1.003 |
0.995 |
Raman transforms from the modal FFT’s start (not kept): |
0.946 |
1.056 |
the same: |
0.978 |
1.17 [1.08, 1.21] |
one-node products as the one-mode kernel on the team (not kept): |
0.973 |
1.045 |
The Raman transforms therefore keep their own start (65536 samples), and the GNLSE’s
1 × 1 synthesis and projection stay with the vendor GEMM. Threading every compact kernel
of at least 4096 elements at the team width gained nothing either (h2_raman_averaged 3 %
slower on a Xeon Gold 6240), nor did threading compact transforms from 4096 or 8192 samples
(averaged argon 1.6 times slower at 4096).
The final rule against the parent of the compact-team commit, 3 rounds, --length 0.1,
the same steps (fields within 9e-14), 16 threads; one thread moved by 0.98-1.02:
case |
Xeon Gold 6240 |
AMD EPYC 7763 (16 cores of a quiet shared node) |
|---|---|---|
|
0.774 [0.763, 0.800] |
0.965 [0.960, 0.970] |
|
0.895 [0.872, 0.905] |
0.965 [0.957, 0.980] |
|
0.98-1.05 |
0.97-1.04 |
With the package’s own plan mode (threaded rows planned at FFTW_ESTIMATE, where the
suite measures every plan) the Intel Xeon Gold 6240 rows were 0.780 and 0.905. On the EPYC the 16
allocated cores lay in three CCXs; its 16-thread gnlse_raman_shock window ran 1.04
times faster than one thread before and 1.08 times after (Xeon: 1.02 and 1.29).
The products of a compact problem run on one OpenBLAS thread. On 16 cores of a Google
Cloud C4A (Neoverse V2) the compact team made gnlse_raman_shock slower at 16 threads
than main (1.13) and than itself at eight (29.6 against 19.5 s in a window, with the same
sizing). Its 1 × 1 synthesis and projection reach an OpenMP OpenBLAS, which on aarch64
sends them to its threaded GEMV at the process width; between the team’s eight-thread
regions, GNU OpenMP ended and re-created eight threads at every call: a short window made
100,836 clone3 calls, against 72 for main and 28 at eight threads. Every native call of
a compact problem already opens a team scope (CpuTeamScope); for an OpenMP OpenBLAS the
scope now also sets the calling thread’s OpenMP default width to one, which OpenBLAS reads
before it threads a call. openblas_set_num_threads is not used for this: it frees the
per-thread buffers above the new count and allocates them again when the count returns,
which cost h2_raman_averaged 3 % at 16 threads on an Intel Xeon Gold 6240. The library is recognised at
import whatever width the environment gave it (the suite’s workers set
OMP_NUM_THREADS, which skips fit_blas_threads). On the C4A gnlse_raman_shock now
takes 17.6 s at 16 threads, against 25.8 s for main; on an Intel Xeon Gold 6240 the change is within
1 % of the A/A spread on every case.
One-mode products on the response team#
A one-mode capillary with a field plasma column (argon_blueshift: 33 radial nodes,
6400 fine samples) synthesises its node fields as an outer product (33 × 1 by 1 × 6400)
and projects three node drives a source evaluation as a stacked 2 × 33 by 33 × 6400
product. The product sizing keeps them serial on the engine’s side (211200 work, below
the dense threshold), so the vendor GEMM threaded each call over its own pool: at 16
threads on a Xeon Gold 6240 the 4212 calls of a --length 0.1 window took 0.46 s, 108 us
each, against 0.29 s at one thread; the band column, which writes the node rows, runs on
the full-width response team. Plain loops tried earlier ran on the product’s
serial instance and lost to the threaded library. On a host build with a vendor BLAS,
one real mode at several nodes in one chunk now synthesises and projects through two
small kernels on the response team: the projection one contiguous share of the samples
per thread, both stacked rows from one read of the node rows, written straight into the
fine and coarse rows (no stacked copy); the synthesis one node row per item. Each element
sums the nodes in order from zero (within the GEMM rounding bound; a native test holds
both kernels to KokkosBlas::gemm). Label timer at 16 threads: projection 40 us a call
instead of 112, synthesis 26 instead of about 100; the band column and the held products
that read the synthesised rows also ran faster (0.35 to 0.29 s, 0.10 to 0.06 s).
Interleaved benchmarks ab against the compact-team commit, Xeon, 3 rounds, --length 0.1, the same steps, fields within 4.4e-15:
case |
1 thread |
16 threads |
|---|---|---|
|
0.999 [0.993, 1.000] |
0.679 [0.672, 0.688] |
|
1.000 |
1.000 |
argon_blueshift now runs 4.1 times faster at 16 threads than at one (1.01 against
4.13 s), where it was 2.8 times. On one thread the kernels are left to the vendor GEMM’s
serial ones: in two tracking-set runs the one-thread window was 1.8 % slower with them,
and without them it took 0.963 [0.948, 0.969] of the time with them (the four-mode rdw,
whose code is unchanged, also moved by 0.964 in that A/B: code-layout noise of this size
is present at one thread).
The kernels apply with OpenBLAS only. Apple’s Accelerate runs these small calls well, and
on an 18-core Apple M5 Max the kernels on the 18-thread response team took the
argon_blueshift window from 3.0 to 3.8 s (1.31 of main at 18 threads in the
tracking-set A/B; a bisection of the push found this commit).
Stacked projection splits#
A real projection that also forms the embedded rule’s coarse rows projects both in one
stacked product and then copies the halves into the fine and coarse rows. The copy ran
on the temporal copy’s instance, which the copy sizing keeps serial below 65536 samples:
on the four-mode capillary (rdw, 4 × 10240 samples) each took about 90 us at 16 threads
on a Xeon Gold 6240, 43 ms of a --length 0.1 window, beside full-width kernels; on ctc
159 us. It now runs on the stage’s instance (full width there, serial where the stage
is). Interleaved benchmarks ab against the one-mode commit, 3 rounds, --length 0.05,
the same steps, fields within 6.1e-15:
case |
1 thread |
16 threads |
|---|---|---|
|
0.990 |
0.977 [0.976, 0.980] |
|
0.987 |
0.992 [0.962, 0.994] |
|
1.004 |
1.012 (no stacked real split) |
NumPy’s BLAS beside a native run#
NumPy’s OpenBLAS is a pthreads library with a pool of its own, sized from
OPENBLAS_NUM_THREADS or every CPU (the benchmark suite sets the variable to the run’s
width). During a native run the host calls it only for small products: the power
weights’ expansion at each saved plane is a 3 × 2561 dgemv on averaged helium, 24 of
them in a --length 0.5 window. After each call OpenBLAS’s workers spin before they
sleep, and the planes come often enough that they never do: at 16 threads on a Xeon Gold 6240
Gold 6240, its 15 workers took 1.09 s of CPU each in a 1.24 s window, and the
four-thread Adams passes ran three times as slowly (0.74 s against 0.22 s, label timer).
The native backend now holds every BLAS but its own to one thread while it lives, as
numba_cpu holds its BLAS pool. Interleaved benchmarks ab against the parent, 3 rounds,
--length 0.1, passive-bound placement, the same steps and fields to rounding:
case |
Xeon Gold 6240, 1 thread |
16 threads |
AMD EPYC 7763, 1 thread |
16 threads |
|---|---|---|---|---|
|
0.996 |
0.462 [0.456, 0.465] |
1.012 |
0.762 [0.749, 0.780] |
|
1.011 |
0.693 [0.670, 0.773] |
0.997 |
0.343 [0.326, 0.816] |
|
0.996 |
0.725 [0.718, 0.726] |
0.992 |
0.610 [0.603, 0.791] |
the other nine tracking cases |
0.984-1.019 |
0.979-1.010 |
0.988-1.020 |
0.972-1.017 |
geometric mean (12 cases) |
1.000 |
0.886 |
1.001 |
0.855 |
The EPYC rows are 16 cores of a shared node (its averaged-helium spread is the node’s).
The four-mode capillary now runs 3.1 times faster at 16 threads than at one (0.77 s against 2.40 s), where it was 1.4 times.
Smooth grid sizes#
native/benchmarks/size_cliffs.py times the four-mode RDW capillary per source
evaluation (short and long capped Runge-Kutta windows, their difference) on 2-3-5-smooth
and power-of-two grids, and flags a size whose cost per sample exceeds its nearest power
of two’s by more than 25 %. Two size boundaries made cliffs: the stage threshold (8192
elements, just above n_t 4000’s 8004-element stage, whose entry kernel then took 708 us
serially against 59 us at full width) with the FFT threshold (32,768 samples, above its
32,000-sample batch), and a node budget that left 33 nodes as a 32-node pass and a serial
one-node tail at n_t 8640. With the thresholds at 4096 and 16,384 and the nodes spread
evenly over the passes, on one 18-core socket of a Xeon Gold 6240 (GCC 16, FFTW 3.3.11,
ms per evaluation, two rounds each):
n_t |
4000 |
4096 |
4500 |
5000 |
6000 |
7200 |
8000 |
8192 |
8640 |
|---|---|---|---|---|---|---|---|---|---|
before |
2.45–2.47 |
1.24–1.36 |
1.49–1.56 |
1.54–1.57 |
1.86–1.87 |
2.21–2.26 |
2.38–2.39 |
2.29–2.30 |
3.52–3.55 |
after |
1.32 |
1.27–1.36 |
1.53–1.54 |
1.57–1.61 |
1.83–1.87 |
2.17–2.26 |
2.38–2.40 |
2.28–2.31 |
2.54–2.58 |
At a stage threshold of 1024 a 1351-element SSFS stage regressed (1.2 to 1.7 ms); the reduced comb-to-continuum and SSFS cases did not move at 4096. On a dual EPYC 7643 (Zen 3, idle node, pinned) the same change made n_t 4000 24-27 % faster at 12 and 24 threads (2.36-2.42 to 1.74-1.82 ms), and the even chunks made n_t 8640 4-13 % faster; other sizes moved within 5 %. On an 18-core Apple arm64 laptop, in contrast, n_t 4000 went from 0.88 to 1.04 ms (level with 4096), and even 4096’s threaded stage is dearer than 4000’s serial one: the crossover there lies above 8192. macOS therefore keeps the stage and FFT starts at 8192 and 32,768; its own crossover still needs a sweep on a quiet machine. Its node-chunk gain stands (n_t 8640: 2.35 to 1.73 ms).
Radial analytic chunk dispatch#
Admitting partial analytic radial node chunks to the interleaved real GEMM, instead of a
scalar contraction that took about 94 % of the source time, made the radial filament’s
adaptive Adams window (256 and 512 nodes, 12 accepted steps, construction excluded) 2.8
to 8.8 times faster on Kokkos CPU, on an exclusively allocated two-socket AVX-512 server.
At one thread Kokkos then took 0.91 (256 nodes) and 0.95 (512) times numba_cpu’s time,
at 36 threads 0.10 and 0.12 times.
Multimode CPU step costs#
Sharing real cubic monomials across output components, planning general-modal tail
transforms over active rows only and seeding serial transforms with measured FFTW wisdom
brought Kokkos CPU to 1.00–1.07 times numba_cpu’s best time at one thread on an arm64
laptop, over 1–2 mm entrance windows of the general modal and reduced
comb-to-continuum cases with both adaptive methods. In ten-step profiles the general
route’s tensor work fell from 0.44 to 0.15 s and its ordinary FFTs from 1.00 to 0.84 s.
Measured FFTW planning adds about 18 ms (general modal) and 120 ms (reduced
comb-to-continuum) of construction. A direct FFTW execution adapter, a pooled allocator
and vector transcendental functions were measured and not needed
(CPU performance).
Threaded FFT plan selection#
FFTW’s measured planning sometimes chose a slower threaded plan: on a 32-core AVX-512 server, for 204 rows of 2048 complex samples at 32 threads, a 64 × 32 decomposition instead of 32 × 64 made the warm general-modal RK window about 12 % slower, almost all of it in complex transforms. Measured wisdom is now seeded only for serial transforms; threaded ones default to estimate planning. Against the previous build, the general modal case’s window then took about 0.80 times as long at one thread and the same at 32 threads; reduced comb-to-continuum stayed within its repeatability.
Low-storage interaction-picture RK#
The low-storage march (four evaluations a step) against the IP-DOPRI5 replay (six, with first-same-as-last) on a Cartesian envelope Kerr beam with no collar, fixed 0.5 µm steps, ten timed steps after one untimed one, same-process comparisons on an 18-core arm64 workstation. Shape A is 192 × 192 × 300 (one state 177 MB), shape B 384 × 384 × 600 (1.42 GB). Ratios are how many times less time the march takes than the replay on the same backend:
backend |
shape, threads |
per step |
per evaluation |
field volumes held, march / replay |
|---|---|---|---|---|
|
A, 1 |
1.83× |
1.22× |
4 / 16 |
|
A, all |
1.77× |
1.18× |
4 / 16 |
|
B, all |
1.73× |
1.15× |
4 / 16 |
Kokkos CPU |
A, 1 |
2.02× |
1.35× |
4 / 15 |
Kokkos CPU |
A, all |
1.81× |
1.21× |
4 / 15 |
Kokkos CPU |
B, all |
1.85× |
1.24× |
4 / 15 |
Recomputing the flow factors instead of tabulating them holds three volumes and costs
3–13 % more. Most of a march step is now the source evaluation’s core (transforms, the
Kerr column, packing): 69 % on numba_cpu at one thread, the fused finish 27 %. At all
threads the Kokkos march was 1.6 (shape A) to 2.1 (shape B) times faster than
numba_cpu’s.
Band-rule plasma integration#
The band-exact plasma integrals (SEP 18) against the trapezoid, on the four-mode RDW window (33 nodes, 8192 fine samples, ten fixed steps from the launch), for the band column as first built:
backend |
threads |
band / trapezoid window |
|---|---|---|
|
default |
1.51–1.58 |
|
1 |
2.4–2.5 |
Kokkos CPU (arm64 laptop) |
18 / 4 / 1 |
1.3 / 1.9 / 2.7 |
Kokkos CUDA (data-centre NVIDIA GPU), direct halo reads |
— |
1.10 |
Kokkos HIP (AMD Instinct GPU), halo staged in team scratch |
— |
1.25 |
On a CPU each integral is a nine-tap filter inside a serial traversal; on a device the
filters are parallel maps over the row, so the band rule costs about a tenth more than
the trapezoid there. Staging the halo in team scratch loses on the NVIDIA device (1.21)
and wins on the AMD one (1.36 with direct reads), so each build defaults to its winner.
numba_cpu’s column has since been rewritten as whole-row passes, 1.7 times faster than
the traversal measured here (algorithms.md). A finer grid, the
alternative SEP 18 rejects, costs about twice the whole nonlinear term for a quarter of
the error.
Dispersive electronic response#
The dispersive Kerr response (SEP 6, backends.md §3.11) is judged by its nonlinear stage time against the constant cubic’s on the same route: the symmetric evaluator’s target is 1.3, the polynomial’s 4. Same-process medians of ten-step windows from the launch, on the RDW grid and on Cartesian, line, planar and general-route problems:
evaluator |
|
Kokkos CPU |
GPUs (NVIDIA and AMD) |
|---|---|---|---|
symmetric, one species |
0.92–1.23 (1.31–1.35 on one averaged node, analytic sector) |
0.81–1.21 at one thread |
0.86–1.30 |
symmetric, mixture (one filter from the mixture’s Kerr diagonal) |
0.90–1.11 |
0.97–1.21 at one thread |
0.86–1.30 |
polynomial, one species or equal leg bounds |
1.16–3.72 |
1.52–3.65 at one thread; 4.2–5.0 on the general route |
1.01–3.53 |
polynomial, mixture of distinct leg bounds |
4.1–7.6 (3.3–3.7 for helium and argon in the default pools) |
3.2–8.3 at one thread, at most 2.2 in the default pools |
within 4 (air 1.5–2.1) |
Ten-step windows, which carry the integrator’s own work, move less than the stage. A rebuild of the mixture filter when the densities change costs about 2 % of a symmetric stage. A polynomial mixture is one group per distinct leg bound, which is why mixtures such as air miss its target on the CPUs.
Far-field statistics pass#
The banded far-field pass (six aperture statistics and the on-axis spectrum, 30 slots)
on the last state of a Cartesian envelope Kerr run (192 × 192 × 256 and 384 × 384 × 512),
same-process comparisons: about 2.3–2.6 % of one Runge–Kutta step on both numba_cpu
and Kokkos CPU, paid at save planes and periodic rows only. On numba_cpu it costs about
as much as the energy statistic and is 30 to 37 times faster than the host pass it
replaced (which cost 77–84 % of a step). On Kokkos CPU it is compute-bound (square roots
and saturated tanh weights), 15–16 times energy.
The per-row profile and shape of kernel schema 45 (#446) left a set of circles’ path alone.
Measured on the pass alone, NativeBackend.stats of aperture_spectrum and beam_spectrum
called seven times per process on a held 192 × 192 × 192-bin state of a Cartesian grid,
interleaved old and new builds in four rounds, native CPU at 8 threads on an Intel Xeon Gold
6240, GCC 16.2. The machine was shared and each round’s numbers moved by up to 60 %
between runs, so the fastest round of each arm is the figure (ms per call, before → after):
three hard holes 31.75 → 32.16 (+1.3 %), three hard holes on one centre, with the radius
shared, 32.26 → 32.69 (+1.3 %), three tanh holes 79.42 → 76.74 (−3.4 %), sixteen rcos
holes 103.4 → 89.7 (−13 %). The results of both backends on these sets were identical to the
bit. A shape other than a circle forms its offsets and takes one more branch per row and
element. That comparison is indicative; the far-field pass runs at save planes and periodic
rows only. The whole-run check is python -m benchmarks ab --case rdw --case far_field_stats --threads 1,16 --length 0.1 --rounds 5 --repeats 1, an exclusive AMD EPYC 7763 node, GCC 16.2,
base f004a662: candidate/baseline far_field_stats 0.994 at one thread (21.48 → 21.38 s) and
0.994 at 16 (4.699 → 4.696 s), rdw 0.997 at both, geometric mean 0.995, the fields agreeing
to 6e-15 and the same work on every row. Those holes are circles; the new shapes were not timed
there.
On a device (#364): one stats call of aperture_spectrum on the marched field, rcos edges
and quadrature weighting, the median of six in one process, against one low-storage RK4IP step
of the same process. NVIDIA H200 with CUDA 13.1 and AMD Instinct MI325X with ROCm 10, 2026-11-04.
Grid |
Holes |
H200 call / ms |
of a step |
MI325X call / ms |
of a step |
|---|---|---|---|---|---|
384² × 600 |
4 circles |
6.2 |
11.6 % |
6.1 |
9.9 % |
384² × 600 |
16 circles |
13.6 |
25.3 % |
14.6 |
23.9 % |
768² × 600 |
4 circles |
15.4 |
6.5 % |
14.4 |
6.5 % |
768² × 600 |
16 circles |
33.3 |
14.1 % |
33.8 |
15.2 % |
768² × 600 |
4 mixed shapes |
16.0 |
6.7 % |
16.7 |
7.5 % |
768² × 600 |
16 mixed shapes |
36.9 |
15.6 % |
44.7 |
20.0 % |
A step takes 53.9 and 236 ms on the H200, 61.0 and 223 ms on the MI325X. So at a few steps per
saved plane the pass is under 5 % of the plane, and SEP 23 §6’s registers and groups have
little left to win: the pass costs about 1.5 ms per hole on top of the 9 ms beam_spectrum
takes, and even sixteen holes cost less than the 54 ms (H200) and 80 ms (MI325X) of energy on
the same field.
Raman kernel rebuild#
On a density gradient the Raman kernel is rebuilt at every stage, and its cost scales with
the response’s rows. numpy_ref’s rebuild at \(2^{14}\) fine samples (median of 35,
same-process, a loaded laptop) took 0.31 ms for a two-term N₂ model (rotation and
vibration), 0.47 ms for optmat’s H₂ (4 rows), 6.0 ms for optmat’s N₂ (72 rows) and
10.2 ms for dry air (126 rows), against 0.25 ms for one convolution. With tens of rows
the rebuild dominates the step; Kokkos’s native rebuild is slower than the host’s at one
thread and overtakes it at four. SEP 10’s low-rank
representation is what removes it.
General-route symmetric contraction#
Summing the general route’s real cubic over its ten symmetric monomials instead of the
dense 81 + 3 products (algorithms.md), on a two-channel step-index pair
(288 nodes, 256 samples) at one thread: the respond kernel alone 2.5 times faster on
numba_cpu; a ten-step window 1.45 times faster on numba_cpu and 1.35 times on Kokkos
CPU. The gain shrinks as channels are added.
Adams carrier frame per step#
The scalar carrier frame (SEP 35) adds, per trial, a shifted argument and one complex product per element in the moments pass and one complex product per history row read. Ten replayed steps of a 2 mm, 40 bar hydrogen capillary (16 µm, 515 nm, 2 ps window, 4097 bins, one mode, a weak collar), the stepping alone timed, one thread, median ratio of nine same-process interleaved pairs on an Apple M5 Max, a machine in ordinary use (labelled A/B, not an absolute timing):
source |
|
ratio |
native CPU off → scalar |
ratio |
|---|---|---|---|---|
Kerr only |
11.9 → 12.4 ms |
1.042 [1.019, 1.096] |
9.2 → 9.7 ms |
1.057 [1.036, 1.066] |
Kerr and Raman |
121.5 → 121.9 ms |
1.007 [0.995, 1.017] |
95.4 → 95.8 ms |
1.006 [0.997, 1.009] |
Forming exp(x - sigma) as exp(x) exp(-sigma) instead of a second exponential took
native’s Kerr-only ratio from 1.076 to 1.057. The per-step cost is a few per cent where
the source is cheap and negligible where it is not; the policy’s case rests on the calls
it saves, which SEP 35’s phase 4 measures at matched error.
Resident Adams storage#
At maximum order five the Kokkos resident Adams engine held 29 complex field volumes,
eleven of them dense-output slopes and saved fields allocated even when planes are landed
on. Allocating those only for interpolate_planes=True reduces it to 18
(backends.md §8). Peak RSS of a reduced-resolution
kerr_beam_384 (same box, 600 temporal bins, ten accepted steps, four threads, median of
three fresh processes on an 18-core arm64 laptop), in GiB; the 384² row is a projection
from the other two (constant plus transverse area), not a measurement:
grid |
|
Kokkos, 29 volumes |
Kokkos, 18 volumes |
18 volumes / |
|---|---|---|---|---|
128² |
4.31 |
7.53 |
5.92 |
1.37 |
256² |
15.7 |
28.2 |
21.8 |
1.38 |
384², projected |
34.8 |
62.7 |
48.2 |
1.39 |
Field export adds a further host copy (up to 1.43 times numba_cpu’s peak). No
field-sized workspace scales with the thread count. The allocations are itemised under
reference tables.
Free-space cases at the package defaults#
Per-step time of the free-space cases at the package’s defaults, production thread policy, on a 64-core AVX-512 cloud server (seconds per step, medians of two repeats):
case |
stepper |
Kokkos CPU |
|
|---|---|---|---|
Kerr beam 384² |
march |
0.348 |
0.796 |
Kerr beam 384² |
IP-DOPRI5 |
0.998 |
2.49 |
Kerr beam 768² |
march |
1.51 |
3.34 |
Kerr beam 768² |
IP-DOPRI5 |
4.09 |
10.1 |
Cartesian filament 128² |
default |
0.076 |
0.300 |
Cartesian filament 256² |
default |
0.296 |
1.19 |
The march scales from 384² to 768² as 4.33 (Kokkos) and 4.20 (numba_cpu) against
\(N \log N\)’s 4.30. Against a per-evaluation reference built from the cost of its parts
(transforms and response), the march’s evaluations take 0.94–1.37 times the reference and
IP-DOPRI5’s 1.21 (Kokkos) and 1.80 (numba_cpu)
(SEP 28).
The boundary monitor’s reading#
Each backend reduces the boundary monitor’s five numbers where its state lives, instead
of copying the field to the host to be transformed there (ADR-057). The benchmark
harness runs without a monitor, so the monitor’s cost is measured by its own same-process
comparison: each case built by the harness’s builder and run through Simulation.run
with the default check and with boundary_check=None, alternated, five times each
after a warm-up of each, the shortest of each arm compared.
Monitored over unmonitored run time, native CPU, one thread, on a two-socket AMD EPYC 7763 node (GCC 16.2, FFTW 3.3.11, Kokkos 5.2.1 OpenMP); before is the parent revision, after is this change:
case, window |
accepted steps |
before |
after |
|---|---|---|---|
Kerr beam 192², the whole 2 µm guide |
4 |
+7.8 % |
+4.0 % |
radial filament 256, 3 mm |
68 |
+0.9 % |
+0.6 % |
GNLSE soliton, a fifth of the guide |
81 |
+5.5 % |
+1.5 % |
The Kerr beam’s guide is shorter than its steps times the monitor’s 256 checks, so every step is checked. A check now costs one inverse transform of the state where it lives and a pass that sums it; it was three host copies and a single-threaded host transform. One check of the launched state, the backend’s reading against that host path, same process, shortest of seven:
case |
EPYC 7763, one thread |
NVIDIA A40 |
NVIDIA L4 |
|---|---|---|---|
Kerr beam 192² (36,864 columns × 384) |
100 ms / 218 ms |
3.4 ms / 214 ms |
5.0 ms / 511 ms |
radial filament 256 (256 × 864, field) |
1.08 ms / 1.22 ms |
0.13 ms / 1.03 ms |
0.14 ms / 4.3 ms |
GNLSE soliton (one column × 3072) |
0.027 ms / 0.071 ms |
0.082 ms / 0.085 ms |
0.072 ms / 0.095 ms |
The GPU columns are CUDA 12.8 builds (an A40 on its node’s host, an L4 on an 8-vCPU
cloud host, whose host path is the slower). On a field grid under the split scheme the
native reading uses the collar’s inverse real transform, half the analytic signal’s
work; through the analytic signal the radial filament’s check took 1.83 ms against the
host path’s 1.13 ms at one thread. The L4’s column was measured before that change. On mlx
(Apple M5 Max, a busy laptop: ratios only, same process), monitored over unmonitored on
the spatial cases of 60 fixed steps: radial 256 1.36 → 1.01, Cartesian 64² 1.21 → 1.05,
Cartesian 128² 1.28 → 1.06. The unmonitored path is unchanged:
python -m benchmarks ab at one thread on the same node gives 1.000 (GNLSE soliton),
1.005 (radial filament) and 1.005 (Kerr beam) for the three cases above.
What the split finds (the guards judged on the power each step’s split finds,
halfway through its factor, and the split collar’s crossing limit, #127 and #149). The
finish’s removal pass sums four more numbers of the samples it already holds, on every
accepted step under the split scheme while a monitor is attached. Only a check reads
them, but a saved plane is checked whatever the cadence said of its step, and the
native marches decide their own steps; forming them only on the steps that end at a
check would save a few multiply-adds per sample already in hand, at the cost of telling
every march which steps those are. They are formed on every step. Measured on an Apple M5 Max (Homebrew Clang 23, NEON FFTW
3.3.11, one thread, a shared machine: ratios only), a tenth of each guide, before the
change (its parent on main) and after:
case, a tenth of the guide |
backend |
monitored / unmonitored, before |
after |
|
|---|---|---|---|---|
radial filament 256 |
native |
1.028 |
1.030 |
1.018 |
line |
native |
1.666 |
1.660 |
1.001 |
planar |
native |
1.469 |
1.470 |
1.001 |
The first two columns are the same-process comparison above (shortest of five after a
warm-up, alternated); the last is python -m benchmarks ab --length 0.1 --rounds 3,
the median ratio of candidate to baseline over paired rounds. Within the machine’s
spread both ways; the line and planar cases’ monitored cost is the transverse edge’s
reads (#396). The steps are the same: on these cases the crossing limit does not bind.
The crossing cap read at every check (#440). Under the split scheme each check also
sums the state’s energy per walk-off bucket, one more pass over the state where it lives
(a histogram of 128 buckets: NumPy’s bincount on the host, a Kokkos ScatterView in
native, a scatter-add on the mlx device). Measured as above, the same machine and
window, before (the previous head of this branch) and after:
case, a tenth of the guide |
backend |
monitored / unmonitored, before |
after |
|---|---|---|---|
radial filament 256 |
native |
1.031 |
1.039 |
line |
native |
1.658 |
1.698 |
planar |
native |
1.470 |
1.484 |
One to two and a half per cent of the run on the shared machine, about a third of a check’s own cost. The Cartesian filament 128² takes 1.158 monitored over unmonitored before; its after was not timed, at fifteen minutes a pair at one thread. The steps are unchanged on every case: the cap never fell below a step these runs took.
The transverse edge and a requested collar#
SEP 40 puts a k⊥ safety ramp and reads of the transverse edge on every free-space run by default, and builds a real-space collar on request. The default’s cost was measured as the monitor’s was above, with each case built by the harness’s builder and run four ways in the same process:
with neither the ramp nor reads;
with the ramp alone;
with reads alone;
with both.
The four were interleaved, six rounds each. The setup: a Xeon Gold 6240, one socket, 18 threads, GCC 16.
case |
steps (no reads → reads) |
both, native |
both, |
|---|---|---|---|
line |
27 → 28 |
+14.0 % |
+6.4 % |
planar |
40 → 40 |
0.0 % |
−1.1 % |
radial filament 256 |
127 → 127 |
+2.2 % |
−0.1 % |
Cartesian filament 128, half length (its light reaches the edge) |
40 → 41 |
+10.5 % |
+2.6 % |
The ramp alone costs nothing measurable. Reading costs in two ways. The read ceiling,
min(L/16, 4g/v_q), adds a step on the line and Cartesian cases. Each read also has a
host cost: about 1 ms on the line case’s 0.2 s run, and about 180 ms on the native
Cartesian filament. Both are open in #396;
the edge against 0.1.0 has the later measurements.
A requested collar was measured on the suite’s spatial_absorber case, a 0.9 P_cr argon
beam over 8 cm with 64 samples across its 3e-4 m aperture. The table gives the field error
on the aperture against a bare wall at 8 R:
transverse room |
wall |
error |
translate |
run, native |
run, |
|---|---|---|---|---|---|
3 R |
bare |
1.2e-7 |
— |
0.22 s |
1.24 s |
3.6 R |
collar, f = 2.6 |
2.3e-6 |
6.4 s |
0.74 s |
2.27 s |
4.5 R |
collar, f = 3.5 |
5.0e-7 |
8.2 s |
1.08 s |
2.24 s |
Most of a collar’s cost is its design check at translation. That check also raised the process’s peak memory from 0.45 to 0.68 GB. The SEP’s phase 3 report has the full tables.
The transverse edge against 0.1.0#
#453 found the tracking set’s kerr_beam
rows slower than at 0.1.0, and ctc’s construction three times longer. These runs compare
0.1.0 with main at 00d83454 and with the changes this section describes.
The setup: python -m benchmarks ab on 16 cores of one NUMA domain of an exclusively
allocated two-socket AMD EPYC 7763, SOLPROP_FFTW_PLAN=measure, with main’s harness on
both arms. A small local change to benchmarks/builders.py, not merged, gave 0.1.0 the same
grids, because it calls collar_fraction window_factor. The builds are GCC 16.2 from
source for every 0.1.0 and fix comparison; the first sweep used the published 0.1.0 wheel
and CI’s wheel, and agrees with them on the Kerr rows. Every row took the same steps in
both arms and no sample was taken on a busy machine.
ctc’s construction is not a regression. Both wheels take about 5.5 s without the
planning flag and about 15.5 s with SOLPROP_FFTW_PLAN=measure, construction_s including
the backend’s construction, where FFTW plans. The 15.01 s #453 compared had the flag, and the
5.21 s row in the history is close to a run without it, so the two differ in planning, not
in code. In the first sweep, with the flag on both arms, the
ctc window’s ratio is 1.010. Limiting host BLAS during construction
(#382) is not the cause:
SOLPROP_BLAS_THREADS=16 made no improvement. --append-history now refuses
SOLPROP_FFTW_PLAN, so the history’s rows stay comparable.
The Kerr rows’ cost is the transverse edge, with a smaller part from the split collar’s
occupancy. With the monitor off, a Kerr run takes the same time in both arms, so the
kernels are unchanged. Two first-parent boundaries account for the rise, with threads
bound (--threading passive-bound, below): SEP 40 (05ee1f32 to 39ee3335) is ×1.110
on kerr_beam_192 at one thread and ×1.406 on kerr_beam_384 at 16;
#439 (6083d63a to 3e13360e) is ×1.060
and ×1.015.
Two changes reduce it, neither moving a field by more than rounding:
A read leaves out what cannot reach its resolution (
READ_RESOLUTION,transverse_monitor.py). Each read synthesised every frequency plane, and a run’s light occupies from a fifth to a third of them at this level on the filaments and Kerr beams. A read now leaves out the planes whose energy together is at most 1e-3 of the warning. By Parseval a plane cannot put more on a face than it holds, so a face’s share reads low by at most 1e-9 of the launch energy at the default budget, against a warning at 1e-6.The occupancy takes its host scatter per row.
scatter.access()finds its thread’s copy, and it dominated the loop when taken per element.
Complete runs, passive unbound threads as the history uses, three rounds, medians of the
per-round pairs with the fix’s range. Each ratio is from its own comparison; the 0.1.0 times
shown are from the comparison with main, and the fix’s comparison’s were within 1 % of them.
row |
threads |
0.1.0 (s) |
|
fix (s) |
|
fix / 0.1.0 |
range |
|---|---|---|---|---|---|---|---|
|
1 |
12.05 |
14.24 |
13.02 |
1.182 |
1.080 |
1.078–1.090 |
|
16 |
3.17 |
3.75 |
3.75 |
1.185 |
1.184 |
1.179–1.184 |
|
1 |
101.13 |
117.06 |
106.48 |
1.157 |
1.067 |
1.060–1.067 |
|
16 |
25.33 |
29.91 |
28.83 |
1.180 |
1.136 |
1.129–1.140 |
Against main, the free-space set with threads bound, three rounds, same steps, final fields
equal to 2e-15 or better except the Cartesian filament’s 8e-13 at 16 threads. Complete runs
unless stated:
case |
threads |
|
fix (s) |
fix / |
range |
|---|---|---|---|---|---|
|
1 |
14.13 |
12.99 |
0.918 |
0.917–0.919 |
|
16 |
4.56 |
3.95 |
0.871 |
0.868–0.980 |
|
1 |
117.18 |
106.81 |
0.911 |
0.905–0.915 |
|
16 |
35.77 |
29.73 |
0.831 |
0.830–0.832 |
|
1 |
6.85 |
6.78 |
0.990 |
0.979–0.992 |
|
16 |
1.88 |
1.89 |
1.003 |
0.992–1.007 |
|
1 |
0.905 |
0.889 |
0.982 |
0.978–1.000 |
|
16 |
0.275 |
0.267 |
0.980 |
0.962–0.987 |
|
1 |
0.450 |
0.436 |
0.969 |
0.960–0.983 |
|
16 |
0.148 |
0.149 |
1.012 |
1.006–1.047 |
|
1 |
346.2 |
342.4 |
0.990 |
0.976–0.992 |
|
16 |
97.3 |
96.3 |
0.990 |
0.986–0.995 |
Bound threads overstate the transverse edge. SciPy’s transforms take a bound main thread’s
single core (0.48 s with 16 workers against 0.11 s unbound, on a 384² × 256 array), so
main’s kerr_beam_384 at 16 threads is ×1.425 against 0.1.0 when bound and ×1.180 unbound.
#453 measured ×1.152 unbound, from runs on different days.
What remains, proposed as an accepted cost. Each of these is a known part of the default’s work, and reducing it further means moving work rather than removing it:
kerr_beam_192at 16 threads does not improve. The monitor takes 0.77 s of 3.70 s against 0.20 s of 3.13 s at 0.1.0. Of the 0.57 s more, five reads are 0.31 s, of which the plane scan is 0.21 s, about what the left-out planes save at this width; six more host copies of the state are 0.12 s; the other 0.14 s, which includes the launch’s transform, was not broken down. Reducing the faces and the plane energies in native would remove most of this, which is the seam change in #396.These rows read at every step. A four-step run reads five times, its launch and each step, because
L/16is shorter than a step.radial_filament_256took 19 reads over 127 steps in #396. How far the share falls on a longer Kerr run has not been measured.Construction.
kerr_beam_192goes from 1.63 s to 1.95 s andkerr_beam_384from 11.25 s to 13.52 s, one-off work per translation. At 384² the launch’s occupancy is about 1.0 s, the walk-off buckets 0.6 s and the edge’s design 0.7 s.Memory. Peak resident memory rises by about 1 %: 2.51 to 2.54 GiB and 15.60 to 15.81 GiB.
radial_filament_256, 3 to 5 % against 0.1.0. Five rounds, one and 16 threads:
boundary |
t1 |
t16 |
|---|---|---|
0.1.0 to SEP 40’s parent |
0.992 |
0.997 |
SEP 40 |
1.016 |
1.035 |
SEP 40 to #439’s parent |
1.002 |
1.002 |
#439 |
1.028 |
1.016 |
#439 to |
1.003 |
1.006 |
|
0.990 |
1.003 |
0.1.0 to the fix |
1.030 (1.018–1.035) |
1.048 (1.019–1.070) |
SEP 40 moves the final field by 1.2e-7 through its ramp. #439 takes no step more and leaves the field exact; its accepted steps now form five sums instead of one, so that the guards are judged on the symmetric splitting’s state. That the sums are its whole cost is read from the code, not profiled.
The rest of the tracking set. The 22 configurations, a quarter of each guide from the
entrance, three rounds, 0.1.0 against the fix, threads bound: geometric mean 1.021. Apart from
the Kerr and radial rows, the largest are argon_blueshift_envelope at 16 threads at 1.025
(0.12 s; 1.006 and 1.010 for a complete run on main) and grin_nonlinear at 1.017, whose
range (0.999–1.020) overlaps its A/A control’s (0.979–1.015). The windows say nothing about
what a run does past them. rdw at 16 threads, 1.048 in the first such window, was 1.001 at
full length.
Host vector maths#
The native CPU kernels’ transcendentals in vector passes (ADR-059) against the parent
revision, complete runs of the tracking cases from the entrance (the fraction of the
guide in brackets where it is not the whole), the default stepper and RungeKutta(),
three rounds of interleaved fresh processes, medians with their median absolute
deviations. Every arm took the same steps; final fields differ from the parent’s by
4e-15 to 6e-11 relative, the run-to-run spread of FFTW’s measured plans (a candidate
with this change’s arithmetic on the libm route differs as much).
Two-socket AMD EPYC 7763 (Zen 3, AVX2; 16 cores of a shared node, glibc 2.34, GCC 16.2, FFTW 3.3.11, OpenBLAS 0.3.34, Kokkos 5.2.1 OpenMP, a local build, so SLEEF’s AVX2 functions), one thread, this change’s final revision, two rounds:
case |
stepper |
before (s) |
vector maths |
|---|---|---|---|
|
RK |
18.84 ± 0.02 |
−19.4 % |
|
RK |
2.947 ± 0.015 |
−17.2 % |
|
default |
8.235 ± 0.003 |
−6.2 % |
|
RK |
1.365 ± 0.003 |
−12.4 % |
|
default |
2.338 ± 0.009 |
−10.5 % |
|
RK |
2.866 ± 0.002 |
−10.1 % |
|
RK |
7.679 ± 0.012 |
−14.4 % |
|
default |
5.313 ± 0.012 |
−11.4 % |
|
RK |
8.915 ± 0.010 |
−7.8 % |
|
default |
6.261 ± 0.025 |
−5.9 % |
|
RK |
1.665 ± 0.006 |
−5.7 % |
geometric mean |
−11.1 % (conservative 0.896) |
An earlier revision, three rounds, with a third arm built with
SOLPROP_NATIVE_VECTOR_MATH=SCALAR (this change’s arithmetic on the libm route, an
A/A-like control): that arm’s geometric mean was +0.7 % and its cases −1.2 to +2.2 %.
The free-space routes, the same machine and protocol, one thread, with the low-storage
flows and the split factor: spatial_absorber (0.5) −12.2 %, radial_filament_256
(0.3) RK −5.3 % and default −1.8 %, planar (0.5) −1.5 %, line (0.5) −1.1 %,
kerr_beam_192 (0.5) −0.9 % (inside its spread); geometric mean −3.9 %. Sixteen-thread
runs on that node, which other jobs shared, scattered by more than the effect (one
case’s arms spread by 30 %), and are not reported.
AMD EPYC 9B45 (Zen 5, AVX-512; a cloud instance of 16 cores without SMT, glibc
2.39, GCC 14.2, Ubuntu’s FFTW 3.3.10 and OpenMP OpenBLAS 0.3.26), local builds at
-march=native; the AVX2 rows with -mno-avx512f too, so the AVX2 base is the
reference. One thread, three rounds:
case |
stepper |
before, AVX2 (s) |
vector maths, AVX2 |
AVX-512F (opt-in) |
|---|---|---|---|---|
|
RK |
10.42 ± 0.03 |
−21.3 % |
−29.2 % |
|
RK |
1.581 ± 0.003 |
−10.9 % |
−15.8 % |
|
default |
4.424 ± 0.004 |
−6.2 % |
−13.1 % |
|
RK |
0.765 ± 0.011 |
−6.2 % |
−8.7 % |
|
default |
1.295 ± 0.014 |
−10.8 % |
−18.1 % |
|
RK |
1.546 ± 0.003 |
−10.3 % |
−14.7 % |
|
RK |
3.873 ± 0.012 |
+4.9 % |
+2.2 % |
|
default |
3.123 ± 0.001 |
−2.5 % |
−6.6 % |
|
RK |
5.026 ± 0.012 |
−4.3 % |
−6.1 % |
|
default |
5.071 ± 0.036 |
+2.0 % |
+0.5 % |
geometric mean |
−6.8 % (conservative 0.945) |
−11.4 % (0.900) |
The AVX-512 base was itself 2.0 % faster than the AVX2 base, so AVX-512F’s functions
save 9.8 % against it. At sixteen threads, the AVX2 builds, two rounds, six cases:
geometric mean −5.3 % (conservative 0.983), argon_blueshift_averaged −10.9 % (RK) and
−11.3 % (default), he_rdw_averaged −6.9 % (RK) and −1.5 % (default), rdw −0.9 %
(RK) and +0.2 % (default). An Intel Xeon of the Ice Lake generation (a cloud instance of
16 cores without SMT, the same software), one to two rounds and noisier: the AVX2
functions −3.7 %, the AVX-512F functions +2.9 % (he_rdw_averaged with RK +21 %,
rdw with RK +12 %). The AVX2 functions are therefore the default, AVX-512F an opt-in.
rdw with RK lost 3 to 5 % on both cloud machines, against a 7 % gain on the EPYC
7763, whose glibc (2.34) is older and slower. The label timer put it in the band plasma
column (1.92 s → 2.21 s), whose block passes staged every sample’s magnitude, rate and
fraction: most of the pulse’s samples lie outside the rate table and cost about a
nanosecond each, as much as the staging. Writing the rows as the classifying loop goes
and gathering only the live samples for the vector calls took the Zen 5 machine’s
rdw with RK to −4.7 % (from +4.4 %), argon_blueshift_averaged to −14.1 % (RK) and
−6.8 % (default) and he_rdw_averaged with RK to −11.4 %, three rounds; the tables
above predate that change.
Apple M5 Max, a machine in ordinary use (labelled A/B), one thread: this change’s
default (the system libm, each element’s own calls) against the parent, geometric mean
+0.4 % over eight of the cases above, every case within its spread; SLEEF’s AdvSIMD
functions forced on (SOLPROP_NATIVE_VECTOR_MATH=SLEEF) +11.6 %, gnlse_raman_shock
+56 %.
Exponential Adams host cost: the SEP 39 baseline#
The host side of an exponential Multistep() trial on the native CPU backend, before
SEP 39’s native march, at the head of the vector
maths change (a500d2d). The SEP’s §3.3 reads these figures.
Method.
Complete runs of the tracking cases, with the fraction of the guide in brackets where it is not the whole, timed by the suite’s worker after its warm-up.
The default stepper and
RungeKutta()interleaved over three rounds of fresh processes, the order reversed every round; medians reported.Host time per trial:
python -m benchmarks.adams_host CASE [--length F](SEP 39), the march’s wall time less its nativeadams_*calls, with no other wrappers.The per-piece split comes from one run with
perf_counterwrappers on about twenty-five host functions, which inflate the total by about half, so it gives the shares, not the sum.Steps and calls are counted.
AMD EPYC 7763 (Zen 3, 16 cores of one NUMA domain of a shared node, GCC 16.2, SLEEF’s AVX2 functions, FFTW 3.3.11, OpenBLAS 0.3.34), one thread:
case |
|
|
host / native per trial (µs) |
steps (acc/rej), calls: MS · RK |
|---|---|---|---|---|
|
2.126 |
1.528 |
212 / 1 182 |
1 434/3, 1 438 · 480/1, 2 887 |
|
9.17 (7.83–9.47) |
2.460 |
177 / 632 |
11 166/2, 11 169 · 1 684/0, 10 105 |
|
4.820 |
2.146 |
173 / 695 |
5 417/2, 5 420 · 1 308/0, 7 849 |
|
4.157 |
5.440 |
372 / 9 245 |
393/1, 395 · 175/0, 1 051 |
The node’s sixteen-thread runs, which other jobs shared, were slower than one thread on several cases and are not reported.
Apple M5 Max (Homebrew Clang 23.1.3, FFTW 3.3.11 with NEON codelets, the system libm, an otherwise idle machine):
case |
threads |
|
|
host / native per trial (µs) |
|---|---|---|---|---|
|
1 |
0.815 |
0.650 |
51 / 448 |
|
1 |
2.920 |
1.419 |
38 / 213 |
|
1 |
1.755 |
1.311 |
40 / 263 |
|
1 |
1.711 |
2.736 |
129 / 3 730 |
|
16 |
0.636 |
0.650 |
53 / 282 |
|
16 |
2.474 |
1.381 |
36 / 175 |
|
16 |
1.697 |
1.652 |
44 / 254 |
|
16 |
0.862 |
1.256 |
205 / 1 555 |
The step and call counts are the EPYC’s on both machines.
Host pieces, helium, one thread (µs per trial, instrumented):
piece |
EPYC 7763 |
M5 Max |
|---|---|---|
stage arrays (bundle, packing, sample) |
113 |
21 |
… of which the bundle’s Clenshaw reads |
73 |
14 |
the decision, less the native accept |
48 |
7 |
rate weights (uncached Clenshaw) |
26 |
4 |
landing and approach |
30 |
5 |
the tables call (one native helper) |
18 |
4 |
order choice and stencil |
17 |
3 |
sample, hoist, record and the rest |
22 |
4 |
On the four-mode rdw the step history’s record costs 67 µs a trial on the EPYC: its
backend hook scores the quadrature alarm natively.
Native time per trial (µs, host-only Kokkos label timer, one thread):
case |
machine |
predict pass |
correct pass |
the source and the rest |
|---|---|---|---|---|
|
EPYC 7763 |
557 |
287 |
≈290 |
|
EPYC 7763 |
192 |
112 |
≈200 |
|
M5 Max |
169 |
100 |
≈155 |
|
M5 Max |
51 |
34 |
≈110 |
On the EPYC the two passes cost 1.5–2.9 source evaluations a trial, against 0.6–0.9 for the host. Their per-element cost is 2.9–3.8 times the M5’s, against about 1.9 times for the source.
The native Adams march#
SEP 39’s native march against solprop’s Python
march on the same build (benchmarks.adams_host --host-march), and against the march
with the host packing the stage rows, as before the SEP’s phase 1 (--host-stages).
Interleaved fresh processes, the arm order reversed every round, medians; every arm
took the same steps and calls (the counts above). Host time per trial as in the
baseline above.
Exponential formulation (the default Multistep()):
machine, threads |
case |
host µs/trial: packed → staged → native |
wall, native vs staged |
wall, native vs packed |
|---|---|---|---|---|
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 % |
|
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 % |
|
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 % |
|
|
168 → 8 (staged → native) |
−1.7 % |
— |
The EPYC’s first three rows are from a shared node (load 80–97), whose one-thread
walls were consistent; its rdw row is from a quiet node, nine rounds, where the
sixteen-thread median read +2.7 % and the minimum −3.3 % inside a ±6 % spread.
Interaction formulation (Multistep(formulation="interaction"), M5 Max):
threads |
case |
host µs/trial: packed → staged → native |
wall, native vs staged |
wall, native vs packed |
|---|---|---|---|---|
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 % |
The staged Python march’s sixteen-thread h2_raman_averaged (2.24 s, slower than the
packed march’s 1.74 s, in every round) is unexplained; see SEP 39’s phase 3 report.
On an NVIDIA A40 (CUDA 12.8.1; exponential formulation, short windows; the same
steps in both arms): host time per trial 64 → 2.2 µs (he_rdw_averaged, 0.3), 61 → 1.6
(argon_blueshift_averaged, 0.1) and 83 → 5.0 (rdw, 0.1), walls −6.7 %, −10.6 % and
−1.8 %. A trial costs about a millisecond on this FP64-weak device.
On an AMD Instinct MI325X (HIP 7.14 prerelease, AMD_GFX942; the same build,
--host-march against the native march, five rounds, the same steps): host time per trial
21.7 → 1.5 µs (he_rdw_averaged, 0.3), 17.3 → 0.8 (argon_blueshift_averaged, 0.1), 40.5 →
3.0 (rdw, 0.1), 14.9 → 0.5 (h2_raman_averaged, 0.4) and 30.2 → 2.9
(radial_filament_256, 0.1); walls (ab --device, three rounds) −2.9 %, −3.4 %, −2.0 %,
−3.0 % and −0.8 %, fields bitwise equal. A trial there is 160 to 550 µs of device time in 25
to 70 kernels, most of it launch-bound
(device launches on an MI325X).
On an NVIDIA H200 (CUDA 13.1, HOPPER90, three rounds, the same steps): 49 → 2.2 µs
(he_rdw_averaged), 44 → 1.2 (argon_blueshift_averaged), 70 → 5.2 (rdw), 41 → 0.8
(h2_raman_averaged) and 65 → 4.7 (radial_filament_256, 0.1); walls −15 %, −21 %, −4 %,
−23 % and −3 %.
The Adams passes on x86-64 lanes#
The exponential Adams trial’s predict and correct passes on a host block of 32 elements
held as lanes (include/solprop_native/adams_phi.hpp, adams_lanes_* in
src/general.cpp) against the element-by-element form, at 740b24d. Before the change
the passes ran each element through latency-bound scalar strict code (the
phi-functions’ series, Horner pass and squarings, the compensation and the defects),
GCC calling every strict helper out of line from the contracting kernels; per element
they cost 2.9–3.8 times the Apple M5 Max’s on the AMD EPYC 7763 (§ “Exponential Adams
host cost” above). Now every lane loop vectorises on x86-64-v3, Zen 3 and Cascade Lake
(checked with GCC 16.2’s vectoriser report): the selections are bitwise, the
comparisons quiet, and the corrector’s endpoint rate is formed beside the step’s
phase, in one pass over the operator’s terms. The strict parts keep each element’s
operations and their rounding (a native test holds the lanes’ phi-functions to
adams_phi’s bits); the predictor’s and corrector’s sums may contract differently, so
the first step whose inputs differ differs by about 2e-16 in its norm. Every case
below took the same steps and trials in both arms; final fields differ by 2e-15 to
5e-12 relative (he_rdw_averaged at 0.3 of its length: 4.6e-12, the step sizes
apart by up to 5e-5 relative after the pulse’s compression amplifies the rounding).
AMD EPYC 7763 (16 cores of a shared node, GCC 16.2, SLEEF’s AVX2 functions, OpenMP OpenBLAS), one thread, three interleaved rounds, medians; the passes in µs per call (host-only Kokkos label timer), the wall of the timed window:
case (fraction) |
predict |
correct |
wall |
|---|---|---|---|
|
562 → 273 |
293 → 109 |
−32 % |
|
190 → 82 |
112 → 45 |
−30 % |
|
269 → 115 |
164 → 65 |
−37 % |
|
2430 → 1373 |
1222 → 530 |
−16 % |
|
13 470 → 7 050 |
9 890 → 4 430 |
−16 % |
|
189 → 83 |
111 → 48 |
−7 % |
Per element the predict pass now costs about 1.6 times the M5 Max’s figure of § 3.3
of SEP 39, and the correct pass about 1.1 times. A stage clock (rdtsc around each lane
step, a profiling build) puts the rest of he_rdw_averaged’s predict pass at about a
third in SLEEF’s six complex exponentials an element (the flow, the series argument and
four spans), a sixth in the 18-term phase and rate sum, which is memory-bound, an eighth
in the phi-functions and the rest in the history sums and stores. That node’s
sixteen-thread runs scattered by more than the effect and are not reported.
Intel Xeon Gold 6240 (18 physical cores of one socket, the other socket in use, GCC
16.2 -march=cascadelake, SLEEF’s AVX2 functions, OpenMP OpenBLAS), complete windows at
0.1 of each case’s length, python -m benchmarks ab with passive bound threads, three
rounds, medians of the paired ratios (range over rounds); every pair did the same work,
final fields within 6.2e-15:
case |
one thread |
sixteen threads |
|---|---|---|
|
0.786 (0.779–0.809) |
0.879 (0.775–0.885) |
|
0.752 (0.747–0.759) |
0.946 (0.920–1.057) |
|
0.697 (0.696–0.710) |
0.936 (0.921–0.952) |
|
0.827 (0.816–0.829) |
0.974 (0.961–0.974) |
|
0.839 (0.836–0.849) |
0.937 (0.932–0.945) |
|
0.936 (0.934–0.963) |
0.953 (0.946–0.975) |
The geometric mean of the twelve ratios is 0.867. At sixteen threads the single-column passes run on four threads (the size class above), so less of the gain shows.
SLEEF’s AVX-512F functions (SOLPROP_NATIVE_VECTOR_MATH_AVX512, opt-in) took a further
5–8 % off these windows at one thread on a Xeon Gold 6240, and were 6 % slower on
he_rdw_averaged at sixteen threads (one round); they stay opt-in.
Tried and not adopted: blocks of 16 elements (5–15 % slower on the EPYC) and of 64
(within 2 % of 32 on single columns, 5 % faster on rdw).
Device transfers and the free-space Kerr stage#
On an NVIDIA A40 (CUDA 12.8.1, AMPERE86, binary64 at about a sixty-fourth of the
single-precision rate), python -m benchmarks ab --device, three rounds of interleaved
fresh processes, half of kerr_beam_384 (two fixed RK4IP steps, 384² × 750):
change |
wall, s |
ratio (range) |
field difference |
|---|---|---|---|
one copy at a load and an observation, the free-space split on the stored columns |
4.775 → 3.878 |
0.812 (0.812–0.815) |
1.0e-15 |
the split factor per sample, the pinned single upload |
4.686 → 3.892 (with the above) |
0.830 (0.829–0.832) |
1.0e-15 |
the rank-3 Kerr stage |
3.913 → 3.764 |
0.963 (0.957–0.964) |
1.3e-16 |
all, against the baseline |
4.732 → 3.747 |
0.792 (0.789–0.798) |
1.0e-15 |
Kernel time fell from 827 to 651 ms a step: the split’s transverse pair and transposes
(about 100 ms) and the chunks’ pack, crop and pointwise passes (about 75 ms). An
observation of the 1.77 GB state took 0.85 s of host time through three fresh buffers.
On an Intel Xeon Gold 6240 at one thread kerr_beam_192 (half) took 0.920 of the time
(0.917–0.922, fields to 6e-16). radial_filament_256 is unchanged on the A40 (0.3 and 0.5
of its length, within ±2 %): its trial, 3.3 ms of kernels, is bound by binary64
arithmetic (the synthesis and projection GEMMs near the device’s binary64 peak, the
φ-functions), so neither the single upload, the GEMM for its node chunks nor one chunk
in place of two (48 → 31 kernels a trial, not adopted) moved it. The retained-spectrum
synthesis does, by halving the synthesis’s product: on the A40, half the run, three
rounds of three repeats, 0.569 → 0.512 s, ratio 0.902 (0.898–0.903), fields to 3.2e-15,
kernel time 238 → 200 ms over a 71-trial window.
The device prefactor with one reciprocal in place of two divisions took the separable
fold from 29.5 to 21.6 ms (kerr_beam_384, kernels 651 → 620 ms a step, wall 0.981,
0.936–0.984, fields to 2.4e-17).
Device launches and the Adams passes on an AMD Instinct MI325X#
On an AMD Instinct MI325X (HIP 7.14 prerelease, AMD Clang 23, AMD_GFX942, band staging
on), rocprofv3 --kernel-trace over a timed window showed the device busy for a third of
an Adams run. A single-mode trial was 30 to 70 kernels of 2 to 9 µs, launched one by one,
and between them Kokkos’s constant-memory launches: a functor of 512 B to 32 kB (most of
a trial’s) is copied by a blit kernel behind an event wait on the previous such launch,
4 to 10 a trial, 13 to 18 % of the kernel time. Each change below, ab --device,
interleaved fresh processes, three rounds, the same steps:
change |
case |
ratio (range) |
field difference |
|---|---|---|---|
functors under 3840 B passed as the launch argument (a light-weight hint) |
|
0.900 (0.885–0.935) |
1.4e-11 |
|
0.829 (0.823–0.849) |
3.7e-15 |
|
|
0.938 (0.935–0.947) |
3.4e-15 |
|
HIP range reductions back to 256-thread blocks with the hint |
|
0.944 (0.931–0.953) |
0 (against no hint) |
predict pass’s phi-functions in registers (order dispatched, one to eight) |
|
0.971 (0.949–0.984) |
1.0e-12 |
correct pass’s history read once into registers |
|
0.979 (0.973–0.995) |
1.3e-11 |
|
1.000 (0.983–1.002) |
3.8e-15 |
|
no fence after an accepted step on a device |
|
0.959 (0.955–1.005) |
0 |
|
0.966 (0.900–0.971) |
0 |
|
the held currents’ transforms in one plan |
|
0.964 (0.957–0.967) |
0 |
|
0.903 (0.890–0.938) |
0 |
The light-weight hint first took the range reductions to Kokkos’s largest HIP block (1024
threads), which put a single-mode trial’s predict and correct passes on three compute
units, 40 → 56 µs; their reduction order moved the norms, hence the 1.4e-11 over 411 steps.
The predict pass held its phi-functions in scratch memory (560 B a thread) because their
loops ran to a run-time order: 38.9 → 28.8 µs a trial on he_rdw_averaged and 64.6 → 32.7 µs
on radial_filament_256. The correct pass’s register copy is 31.9 → 27.5 µs on the former
and 54.2 → 57.7 µs on the latter (its registers doubled); it is kept for the small runs.
The unrolled device arithmetic contracts differently, so fields move at rounding level,
which the adaptive steps carry to the 1e-12 and 1e-11 above with the same steps.
All of them, against the HIP build with the native march alone (five rounds; radial three repeats):
case |
wall, s |
ratio (range) |
field difference |
|---|---|---|---|
|
0.196 → 0.145 |
0.744 (0.697–0.814) |
1.3e-11 |
|
0.346 → 0.237 |
0.686 (0.683–0.688) |
2.9e-15 |
|
1.344 → 0.994 |
0.740 (0.715–0.791) |
1.3e-13 |
|
0.196 → 0.171 |
0.875 (0.861–0.901) |
8.1e-15 |
|
0.182 → 0.165 |
0.901 (0.889–0.907) |
3.7e-15 |
A trial’s native time (benchmarks.adams_host, medians of five) went 323 → 206 µs, 279 →
178, 224 → 160, 446 → 291 and 547 → 399 µs. Against the previous HIP build, which stepped
the runs with the Python march: he_rdw_averaged 0.463 (0.405–0.666; that build’s walls
are bimodal on this device, 0.20 or 0.37 s), argon_blueshift_averaged 0.629, h2_raman_averaged
0.624, rdw 0.605, radial_filament_256 0.843, kerr_beam_384 (half) 0.650 and
kerr_beam_192 (half) 0.702 (both from the free-space work above), and at a tenth of their
length (two rounds) gnlse_raman_shock 0.843, argon_blueshift 0.657, ctc 0.780.
What remains is mostly the host’s launch rate: a trial’s kernels take 2 to 4 µs each, and
rocFFT runs each of these transforms as four kernels.
On an NVIDIA H200 (CUDA 13.1, HOPPER90) the same changes, of which the light-weight
hint is a no-op there (grid-constant arguments), against the build without them (three
rounds, the same steps): he_rdw_averaged 0.920 (0.904–0.936, fields to 1.2e-11),
argon_blueshift_averaged 0.902 (0.880–0.912), h2_raman_averaged 0.918 (0.916–0.920),
rdw 0.969 (0.962–1.035), radial_filament_256 0.989 (0.962–1.134). Over a 411-trial window
of the first the correct pass went 21.4 → 14.1 µs and the predict pass 24.7 → 22.2 µs, the
transforms 4 → 3 a trial (cuFFT’s one kernel each). Against the previous main build:
he_rdw_averaged 0.790, argon_blueshift_averaged 0.882, h2_raman_averaged 0.788, rdw
0.841, radial_filament_256 0.868, kerr_beam_384 (half) 0.722, kerr_beam_192 (half) 0.776.
Pinned trial norms and batched forward rows on a device#
On a device, an Adams trial’s defect norms are reduced into pinned host memory. The reduction’s last block writes them there directly, so the device-to-host copy is gone (on HIP it was a blit kernel: 2.16 → 1.16 copies a trial), and the trial keeps its one fence. A modal stage’s in-stage absorber, projection and held-current rows are carved from one buffer and transformed with one batched plan after the absorber is formed. This does not apply to dispersive responses, the general route or coarse rows. The radial route’s three held streams also take one plan.
All A/Bs use ab --device, three rounds, against the tree before the change. Every case
took the same steps, and the fields were bitwise equal.
device |
case |
ratio (range) |
|---|---|---|
AMD Instinct MI325X |
|
0.903 (0.856–0.906) |
|
0.858 (0.854–0.929) |
|
|
0.888 (0.836–0.893) |
|
|
0.994 (0.959–1.001) |
|
|
0.969 (0.963–0.977) |
|
|
1.004 (0.984–1.024) |
|
NVIDIA A40 (batched rows and pinned norms) |
|
0.784 |
|
0.755 |
|
|
0.878 |
The pinned norms alone were within noise on the MI325X. When the pack is forced on a CPU build, the fields are bitwise equal.
Device graph replay of an Adams trial’s source#
On a CUDA or HIP build, an elementwise Adams trial’s source evaluation runs in a graph
region (graph::Region). The first trial captures its kernels, device copies and
transforms. Each later trial whose operations repeat, in order and with equal keys,
launches the instantiated graph instead. In a microbenchmark, thirty dependent tiny
kernels cost 3.36 µs each back to back and 2.18 µs as a replayed graph on an AMD Instinct
MI325X, and 2.41 and 1.67 µs on an NVIDIA A40. Capturing every trial and updating the
instantiated graph cost more than plain launches (4.04 µs on the MI325X).
All A/Bs below use ab --device, three rounds, against the build without the region.
Every case took the same steps, and the fields were bitwise equal.
case |
MI325X |
NVIDIA H200 |
NVIDIA A40 |
|---|---|---|---|
|
0.915 (0.902–0.995) |
0.991 (0.937–1.003) |
0.972 (0.944–0.976) |
|
0.893 (0.880–0.897) |
0.918 (0.917–0.962) |
0.962 (0.959–0.965) |
|
0.885 (0.864–0.889) |
0.964 (0.948–0.976) |
0.978 (0.976–0.981) |
|
1.029 (0.978–1.031) |
0.987 (0.968–1.039) |
0.999 (0.997–1.000) |
|
0.990 (0.986–0.994) |
1.022 (0.992–1.114) |
0.999 (0.997–1.004) |
|
1.001 (0.981–1.003) |
1.001 (0.993–1.023) |
0.987 (0.986–1.005) |
The three mode-averaged runs replay all but their first trial: 410, 1048 and 5418 replays
to one capture. The sources of rdw and radial_filament_256 hold a vendor GEMM or a
reduction. Their region stops at its second operation and the run continues with ordinary
launches; its one capture is the 3 % on the MI325X’s short rdw window. The Kerr cases do
not use the region. A first version on the H200, whose trial is bound by the host, was
slower (he_rdw_averaged 1.10, argon_blueshift_averaged 1.25). A verifying trial walks
the whole source before its one launch, and that walk copied every operation it skipped.
Comparing keys in place and copying an operation only when it is recorded removed that
cost.
Device transforms of the four-mode capillary and the radial filament#
rdw and radial_filament_256 gained least on devices from the batched rows and the graph
replay. An NVIDIA A40 trace of one rdw trial (34 kernels, 1.30 ms of kernels, gaps of
about 0.9 µs) showed why: the trial is bound by its transform shapes, not by its launches.
Coarse rows in the forward pack. Each of the stage’s four real transforms (fine and coarse projection, absorber, held currents) took about 96 µs on 16 of the A40’s 84 multiprocessors, whatever its rows: the held pack’s 16 rows cost the same as the projection’s 4. The coarse projection’s rows now join the device forward pack, so the stage’s 28 rows are one transform (
packs_forward). A device never defers the coarse quadrature, so those rows are projected at every stage.Few-mode transforms through the custom kernels. cuBLAS took 71 µs for the stacked projection (8 rows, 36 nodes, 10240 samples; about 84 GFLOP/s) and 27 µs for the synthesis. Below eight modes the device’s hybrid guard now keeps the custom kernels, which form the fine and coarse rows in one pass: 19 µs and 8 µs. With no vendor GEMM left, the whole source replays as a graph (158 replays to one capture).
CUDA trial reductions in blocks of 128. Kokkos’s CUDA range reduction gave the prediction and correction 256-thread blocks of one element each:
rdw’s 10496 elements were 41 blocks. Blocks of 128 occupy every multiprocessor. On the A40 the prediction went from 92.5 to 55.4 µs and the correction from 49.3 to 41.0 µs. The reductions’ order changes, so the trial norms move by rounding.An AMPERE86 build’s real GEMMs. cuBLAS runs a compute capability 8.6 device’s DGEMM with the FP64 tensor-op kernels of 8.0. A probe on the A40 measured 316 GFLOP/s for the radial filament’s 128 × 256 × 866 synthesis, about 54 % of the FP64 peak, at any row pitch.
device_dense_gemmgives a thread four rows of one column and takes 132 µs where cuBLAS took 180. Eight rows a thread were 5 % slower. The radial source then replays as a graph too (70 replays to one capture).
All A/Bs use ab --device, three rounds, against the build before these changes.
case |
NVIDIA A40 |
AMD Instinct MI325X |
|---|---|---|
|
0.799 (0.795–0.810) |
0.962 (0.912–0.974) |
|
0.930 (0.928–0.934) |
1.000 (0.995–1.005) |
|
0.934 (0.927–0.937) |
0.996 (0.990–1.004) |
|
0.940 (0.938–0.956) |
0.994 (0.990–1.007) |
|
0.952 (0.947–0.954) |
0.999 (0.989–1.025) |
Every case took the same steps. Measured one change at a time on the A40, the coarse rows
gave rdw 0.892 and the custom transforms 0.923, both bitwise. The CUDA reductions gave
rdw 0.981 (0.968–1.008), and they are the whole of the single-mode runs’ gain. The
AMPERE86 GEMM gave radial_filament_256 0.933, bitwise. The reductions’ order moves the
norms by rounding, and through the step sizes it moves the final fields: by 2e-15 to
6e-14, and by 1.0e-11 on he_rdw_averaged (410 steps). The HIP reduction’s block did the
same before (1.4e-11). On the MI325X only the first two changes apply. There, rdw’s
field moves by 2.5e-15 (the custom transforms against rocBLAS) and the others are bitwise.
The MI325X’s rdw source replays as a graph after four captures, while its radial source
still holds rocBLAS.
Three leads were dropped. Eight rows a thread in the AMPERE86 GEMM ran at 1.054 against four. Padding the radial GEMM’s row pitch changed nothing: cuBLAS ran at 316 GFLOP/s with 866, 872, 896 and 1024 doubles. Recording vendor GEMMs in the graph was not tried. The trace puts its saving at about 1 % of a radial trial, and KokkosBlas sets cuBLAS’s stream at every call, which resets cuBLAS’s workspace to its default pool.
What is left on the A40’s rdw trial (0.77 ms of kernels): the inverse transform (96 µs
on 4 multiprocessors, one block a row), the packed forward transform (96 µs), the band
column’s four kernels (235 µs), the three projections (57 µs), the prediction (55 µs),
the correction (41 µs) and the quadrature partials (39 µs on 4 teams). A timed run is
also one long host gap at the first saved plane, in solprop’s output layer.
Split real-row transforms on compute capability 8.6#
After the device transforms of the four-mode capillary, an NVIDIA A40 trace of one rdw
trial still spent a quarter of its 0.77 ms of kernels in two cuFFT kernels: the inverse
transform of the fine spectrum (4 rows of 10240 samples, 96 µs) and the packed forward
transform (28 rows, 96 µs). cuFFT gives a real row of a few thousand samples one thread
block, and a GA10x device (compute capability 8.6) runs FP64 at a sixty-fourth of its
FP32 rate, so each block is FP64-bound on its multiprocessor while the rest of the
device waits. A single-mode run’s one-row transforms used one multiprocessor of 84.
An AMPERE86 build now transforms up to 64 contiguous real rows of n = 2m samples as
m-point complex rows (sample pairs as one point, the standard half-length real
transform) by the four-step method (RealRowPlan, fft_plan.hpp): m = n1 n2, with n2
the largest power of two up to 512 that divides m. Our kernels pack the rows into n2 × n1
columns, cuFFT transforms n1 points per column, a kernel applies the twiddle
exp(∓2πi j2 k1 / m) and transposes, cuFFT transforms n2 points per row, and a last kernel
forms the real spectrum (or unpacks the samples). The roots are rounded once from long
double. The inverse drops the imaginary parts of the DC and Nyquist bins, as cuFFT’s and
FFTW’s do. A probe on the A40 timed 4 × 10240 at 20 µs against cuFFT’s 102, and 28 rows
at 60 against 114, both agreeing with cuFFT to 2e-15 relative. Of the splits of 5120
points, 10 × 512 and 40 × 128 were fastest; those with an 80-point pass took half as
long again. In the engine the two transforms of an rdw trial take about 78 µs where
they took 193.
An NVIDIA H200 runs the same cuFFT kernels in 12 to 13 µs, faster than the split (12.5 µs for 4 rows and 17.8 for 28), and on it the single-mode mode-averaged runs lost 4 to 5 % with the split, so only AMPERE86 builds split their rows, as they form their own real GEMMs.
case |
NVIDIA A40 |
|---|---|
|
0.932 (0.924–0.939) |
|
0.751 (0.750–0.752) |
|
0.757 (0.756–0.758) |
|
0.770 (0.768–0.771) |
|
0.996 (0.985–0.997) |
ab --device, three rounds, against the build before the change. Every case took the
same steps; the radial filament’s rows are too many to split and its field is bitwise.
The fields agree to 1.2e-13, except he_rdw_averaged (1.1e-11): the split moves each
transform by rounding, the trial norms follow, and through the step sizes the final
field, as the CUDA reductions’ order did (1.0e-11). On the H200 the build with the split
off agrees with the build before bit for bit and takes 0.99 to 1.01 of its time.
Two other levers were measured and dropped. A team per 128-bin chunk of a column’s
quadrature partials took them from 39 to 7.8 µs, but the score’s sum over the chunks took
most of that back, by one thread (2.5 to 7.9 µs) or by a team: 0.989 on rdw, 0.994 and
1.005 on the single-mode runs. The band column’s four tiled kernels (235 µs of the rdw
trial) moved neither with teams of half a tile (0.985 on rdw, 1.002 on
he_rdw_averaged) nor with 512-sample tiles (1.010, 1.019). The A40 we measured on
gave no access to its performance counters, so what bounds them is not known. A
single-mode trial’s prediction and correction (84 of its 205 µs of kernels) ran on 21
blocks of 128 threads; blocks of 64 gave 0.985, 0.980 and 0.987 on the mode-averaged
runs and 1.000 on rdw, and blocks of 32 regressed the radial filament (1.029).
Retained radial synthesis and Raman modal products#
Two work reductions from the CPU experiment’s handoff, each measured with
python -m benchmarks ab (fresh interleaved processes, passive-bound threads,
SOLPROP_FFTW_PLAN=measure, three rounds, one repeat; medians of the per-round pairs,
range over rounds). Each case exercises one of them: the radial filament has no Raman
term, and the capillary is not a square radial map. Work counts are equal in every pair
and the final fields agree to 4–10 × 10⁻¹⁵ relative.
Retained-spectrum radial synthesis (square radial analytic maps, backends.md): the synthesis product covers
2 n_omegareal columns instead of2 n_to.radial_filament_256, complete guide: on an Intel Xeon Gold 6240 (one socket, 18 cores) 0.928 [0.925, 0.945] at one thread and 0.954 [0.936, 0.968] at 16; on 16 cores of an AMD EPYC 7763, 0.869 [0.868, 0.891] at one thread, and at--length 0.050.866 [0.850, 0.868] at one thread and 0.825 [0.653, 0.894] at 16. The EPYC’s 16-thread ratios moved by about ±15 % between allocations on a shared node (a complete-guide repeat gave 0.999 [0.735, 1.020]); the Intel Xeon Gold 6240’s are the resolved ones. A 16-thread label profile on the EPYC put the synthesis GEMMs at 0.20 s against 0.38 s, of which the gather and pad, as first written per chunk and over every fine bin, took back 0.14 s; they now gather once per evaluation and write only the retained bins.Raman modal products (three-mode
ctc, 6 product rows against 33 node drives):--length 0.05at 16 threads 0.884 [0.881, 0.899] on a Xeon Gold 6240; on the EPYC 0.833 [0.823, 0.837] at one thread and 0.921 [0.861, 0.928] at 16 (--length 0.02: 0.850 and 0.884). The first reconstruction looped over the products per sample and cost ten times the node path’s finish (0.46 s against 0.046 s in a one-thread window); it now accumulates each product over contiguous blocks of 512 samples.
Both add FFT plans (the node transforms; the product rows), measured at construction: on a Xeon Gold 6240 a CTC worker’s wall time, construction included, rose from 16.3 s to 17.7 s.
Not adopted, on the same EPYC allocation:
Routing every real product with a left matrix of at most 256 entries through
native_order_gemminstead of the vendor BLAS: RDW 1.012 / 0.881, argon 1.057 / 1.099, CTC 1.070 / 1.377 at one / 16 threads (--length 0.05). The vendor library wins on the serial transform spaces the operation-size policy gives these shapes.Plain row-block loops for one-mode synthesis and projection: argon 0.996 at one thread; at 16 the loops took 0.65–0.69 s of a short window where the vendor GEMMs took 0.55 s. The vendor pool still costs 0.55 s against 0.23 s at one thread there.
Planning the product rows at the full Raman chunk’s width rather than their own: CTC 1.001 / 0.980 [0.952, 1.012].
Host stage entry, free-space crossings and statistics#
Host-only changes found by profiling the tracking set at one and sixteen threads (Kokkos
label timer and perf, Intel Xeon Gold 6240, GCC 16.2 -march=cascadelake, SLEEF’s AVX2
functions, OpenMP OpenBLAS). Each was measured with python -m benchmarks ab (fresh
interleaved processes on 18 cores of one socket, passive bound threads,
SOLPROP_FFTW_PLAN=measure, three rounds of one repeat at 0.1 of each case’s length;
medians of the per-round pairs, range over rounds). Every pair did the same work.
The stage entry’s finish and combination as lanes (the modal routes’ host entry kernel). The deferred finish and the combination ran each bin through scalar
Kokkos::complexcode: ongnlse_raman_shock(16384 bins, DOPRI5, noise, Raman, a temporal collar) the entry took 2.8 s of a 7.1 s one-thread window, 21 % of it in the lambda’s own scalar code. A 64-bin block now runs as real and imaginary lanes, each bin the same operations in the same order (only the crop’s gathers stay per bin, and an identity crop reads and writes contiguously), and a lossy problem’s block whose phases all have zero real parts takes both magnitudes as exactly one without the twoexppasses (only 42 of the 256 blocks ofgnlse_raman_shockcarry loss). One trial step from identical input differs by at most 3.4e-18 of the field’s peak.case
one thread
sixteen threads
gnlse_raman_shock0.836 (0.826–0.840)
0.962 (0.960–0.987)
gnlse_soliton0.955 (0.937–1.042)
0.939 (0.934–0.959)
argon_blueshift_envelope1.011 (0.968–1.019)
0.984 (0.981–1.013)
ctc1.012 (0.982–1.012)
1.022 (1.001–1.027)
Both arms of these sixteen-thread runs predate the hold of NumPy’s BLAS.
The separable low-storage fold in the scratch’s layout. The fold read the column-major state, accumulator, stage register and flow table 32 columns apart at each bin beside the transverse scratch, divided each column by the polarisation count and called the element’s full spatial value out of line: 63 ns an element at one thread, 27.7 s of
kerr_beam_384’s 69.8 s window. The host’s registers now sit at the scratch cell’s own position; a work item takes 32 adjacent columns over every bin, loads each 16-bin tile of their state a contiguous run at a time and folds it in the scratch’s order; a tabulated fold reads only the prefactor. The flow table is formed in the same order. Fields are bitwise equal.case
one thread
sixteen threads
kerr_beam_3840.789 (0.788–0.790): 68.6 → 54.2 s
0.899 (0.895–0.903)
kerr_beam_1920.742 (0.732–0.746)
0.902 (0.900–0.918)
Crossings without divisions, the monitor’s runs, the radial statistics’ GEMMs. Every host crossing between state columns and the transverse scratch located its cell with a run-time
% n_poland/ n_pol; in a microbenchmark ofkerr_beam_384’s chunk (4468 rows of 750 bins) at one thread they doubled the crop’s time (17 to 35 ms), and visiting the pack’s tile rows outermost took it from 36 to 22 ms. The boundary monitor’s reading divided each sample’s flat index by the row length (1.76 s ofkerr_beam_384’s one-thread window); a host work item now sums a run of 512 samples of one row. A radial run’s local flux statistics synthesised each node’s field and closure with a scalar 256-mode sum inside the pointwise kernel (0.57 s ofradial_filament_256’s 3.65 s window, five planes); they are now two GEMMs over the time fields’ interleaved real columns into buffers the statistics already hold, bitwise equal on the host. Against the fold above:case
one thread
sixteen threads
radial_filament_2560.834 (0.827–0.840)
0.945 (0.933–0.954)
kerr_beam_3840.830 (0.826–0.856): 53.7 → 44.9 s
0.943 (0.943–1.190)
kerr_beam_1920.855 (0.849–0.861)
0.951 (0.946–0.973)
Together with the fold,
kerr_beam_192takes 0.635 of its time at one thread (8.04 → 5.11 s) andkerr_beam_3840.847 at sixteen (9.36 → 7.93 s); at sixteen threads the 384² fold is now bound by memory bandwidth (about 52 GB/s), and about 5 s of that window is solprop’s own host work at the saved plane.
On 16 cores of an AMD EPYC 7763 (GCC 16.2 -march=znver3, the three changes together,
same protocol), one / sixteen threads: gnlse_raman_shock 0.841 (0.836–0.842) / 0.972,
gnlse_soliton 0.941 / 0.942, radial_filament_256 0.838 (0.837–0.850) / 0.938,
kerr_beam_384 0.953 (0.947–0.958) / 1.003, kerr_beam_192 0.956 / 0.995, ctc
0.994 / 0.977, argon_blueshift_envelope 0.981 / 1.001. The free-space gains are small
there: Zen 3 ran the 384² window in 30 s at one thread where a Xeon Gold 6240 took 69 s, its
64-bit divisions and strided streams far cheaper.
Not adopted:
The Kerr-only free-space stage through one rank-3 FFTW transform, as a device takes it: for 384 × 192² at one thread an estimated plan took 1.03 s a transform and a measured one 0.31 s, the same as the batched planes and a strided temporal transform, after 17 s of planning; native’s transform pair with the chunks’ pack and crop is no slower.
The pack and crop through a 32 × 32 tile buffer (as the fold’s state): 0.55 → 0.68 s and 0.52 → 0.56 s on
kerr_beam_192at one thread.A one-element GEMM (one mode, one node) as an elementwise product: no faster than OpenBLAS’s small kernel; both are memory-bound.
Transparent huge pages through glibc’s
glibc.malloc.hugetlb=1(the workstation’s setting ismadvise): within ±3 % onkerr_beam_192at one thread.
Persistent FFTW wisdom#
At one thread, the default plans every row transform with FFTW’s measured search, which
is most of a short run’s backend construction: about 1.3 s for averaged helium’s
6400-sample real pair alone. SOLPROP_FFTW_WISDOM now keeps those plans for the next
process (tuning). Fresh processes at one thread,
SOLPROP_FFTW_PLAN=measure, passive-bound, through the benchmark worker’s own
timed_run (--length 0.1; ctc, kerr_beam_192 and grin_nonlinear on the EPYC
and ctc on a Xeon Gold 6240 --length 0.05). For each case one process stores the wisdom,
then three rounds alternate a process without the variable (cold) and one with it
(warm): medians of the backend’s construction, cold → warm, and the warm/cold window
ratio’s median and range over the rounds. Step counts are equal in every pair. One core
of an Intel Xeon Gold 6240 (an otherwise idle socket) and of a 16-core allocation of an
AMD EPYC 7763:
case |
Xeon construction |
Xeon window |
EPYC construction |
EPYC window |
|---|---|---|---|---|
|
1.398 → 0.033 s |
0.985 [0.945, 0.988] |
1.777 → 0.049 s |
1.002 [0.973, 1.004] |
|
1.330 → 0.032 s |
1.006 [0.987, 1.009] |
1.708 → 0.040 s |
0.987 [0.961, 1.001] |
|
4.003 → 0.029 s |
1.003 [0.996, 1.025] |
5.224 → 0.040 s |
0.999 [0.987, 1.009] |
|
2.097 → 0.033 s |
1.002 [0.926, 1.010] |
2.801 → 0.048 s |
1.004 [0.987, 1.005] |
|
0.453 → 0.022 s |
0.999 [0.995, 1.005] |
0.636 → 0.035 s |
0.995 [0.990, 1.019] |
|
1.405 → 0.051 s |
0.992 [0.985, 1.003] |
1.827 → 0.068 s |
0.993 [0.990, 1.005] |
|
1.321 → 0.035 s |
0.995 [0.985, 0.999] |
1.735 → 0.048 s |
0.998 [0.996, 1.002] |
|
0.473 → 0.024 s |
0.989 [0.976, 1.021] |
0.678 → 0.038 s |
1.007 [0.995, 1.016] |
|
0.613 → 0.093 s |
0.994 [0.986, 1.022] |
0.820 → 0.107 s |
1.008 [1.004, 1.011] |
|
5.033 → 0.056 s |
0.985 [0.983, 1.016] |
6.565 → 0.068 s |
1.002 [1.001, 1.004] |
|
1.248 → 0.657 s |
0.993 [0.972, 1.006] |
1.079 → 0.255 s |
0.995 [0.973, 1.126] |
|
1.984 → 0.309 s |
1.015 [1.000, 1.040] |
Warm windows run as fast as cold ones: the plans are those a measurement chose, in this process or an earlier one. A warm process given the seeding process’s wisdom reproduced its final field bit for bit (averaged helium), where two cold processes differ by measured plans (5.8 × 10⁻¹⁵ relative). What remains of a warm construction is allocation and setup: tens of milliseconds, a few tenths of a second for the free-space Kerr beam’s 1.4 GB of state. A job of a one-thread scan still pays solprop’s own import, case construction and translation (0.7 s and 3.0 s for averaged helium on a Xeon Gold 6240), which the native backend does not own.
Band column passes and the general route’s real signal#
Host-only changes found by profiling ctc and grin_nonlinear (Kokkos label timer and
perf, Intel Xeon Gold 6240, GCC 16.2 -march=cascadelake, SLEEF’s AVX2 functions).
In a one-thread ctc window (0.01 of the guide) the band plasma column took 1.00 of
1.99 s inside Kokkos, 25 ns a sample, and the band statistics another 0.29 s; in a
grin_nonlinear window (0.05) the complex inverse transform took 2.53 of 8.02 s, the
projection 1.81 s and the synthesis 1.57 s.
The band column’s passes as block loops. In the column’s own code a third of the samples went to the integrand pass (an out-of-line strict call per sample for the ionised fraction, a scalar division in the loss guard, a live-window note per sample), two fifths to the scans (Kokkos’ host team scan runs each closure twice, the second time from the same zero), and a fifth to the rates’ classification and scatter. A rate block of 256 samples that is mostly live now takes every sample through the vector-maths passes, a quiet one’s magnitude replaced by the table’s lower limit, and selects the limits after; a mostly quiet block gathers as before. The fractions come from one strict block loop, the guard’s branch is a selection that divides by one where it is not taken, the live window is searched from a block’s ends, and a one-thread team scans in one pass. Every value is unchanged to the bit. Label timer, column: 1.00 → 0.69 s at one thread, 0.147 → 0.100 s at sixteen.
The filter one tap pair at a time, on x86-64. GCC had vectorised each sample’s nine-tap sum across the taps, a serial chain of additions per sample. A thread now takes blocks of 512 samples and runs the interior as one loop per tap pair; each sample keeps its order of terms, and only the contraction of its multiply-adds can move it (one
ctcstep: 2.2 × 10⁻¹⁶ of the field’s peak). Column: 0.69 → 0.54 s at one thread, 0.100 → 0.082 s at sixteen. Other hosts keep the per-sample traversal: the earlier tap-first filter slowed an Apple M-series argon window (see the rejected leads below).The rate table’s cubic vectorised out of line. Called from the blocks, the table’s cubic ran scalar, a division per sample: the compiled-out-of-line loop stored through the pointer it read while it gathered from the table, which GCC could not prove apart. It now writes a local block. Bitwise; column 0.54 → 0.51 s.
The quiet-block skip. A rate block with no live sample takes only the table’s limits, without the gather, and the single gas’s density is read once per node, not after each store of the current (which the compiler had to assume might alias it). Bitwise;
argon_blueshiftcolumn 0.72 → 0.64 s of a 0.05 window at one thread,ctc0.51 → 0.49 s.The band statistics in the same blocks. The statistics scan took each sample’s rate and diagnostic fraction through the system libm and its whole-row prefix through Kokkos’ two-pass scan; it now uses the column’s rate blocks, block fractions and the one-pass scan.
ctc: 22.8 → 14.0 ms a call at one thread. The statistics’ rates now carry the vector maths’ rounding: the peak ionisation rate moves by 3.6 × 10⁻¹⁴ relative (a one-ulp change oflog |E|through the cubic’s slope), every other statistic not at all.The real form’s field through a complex-to-real transform. A real-form cubic reads only the real part of the analytic signal, which is the c2r transform of the one-sided spectrum with its doubled bins halved (exactly) and the imaginary parts of the zero and Nyquist bins dropped. The host now synthesises that half spectrum and transforms it c2r; the buffers are the complex transform’s, aliased, so the ledger and solprop’s memory estimate are unchanged. Not with SEP 6’s dispersive response, and not on devices. One
grin_nonlinearstep: 4.7 × 10⁻¹⁷ of the field’s peak. Inverse transform 2.53 → 1.21 s, respond 0.68 → 0.50 s at one thread.The projection’s block of bins up to the whole row. A host projection team walked the chunk’s rows over at most 64 bins, 1 KiB of each map row in turn; the block now widens to 2048 bins and to the first power of two covering the bins, so at one thread a team streams a mode’s map rows contiguously. No bin’s sum changes order. Project 1.81 → 1.17 s at one thread (2048 bins), 0.20 → 0.17 s at sixteen (512 bins).
The synthesis summed in local blocks. The host synthesis zeroed each row and added every mode’s products into it in memory; it now zeroes only the bins outside the crop and sums blocks of 256 bins in two local arrays before one store, each bin’s sum in the same order. Synthesise 1.47 → 1.31 s at one thread. At one thread the synthesis and the projection each stream a 122 MB map per evaluation at 8–9 GB/s, about what one core of this socket draws from memory.
Each case measured with python -m benchmarks ab against the parent of these changes
(fresh interleaved processes on 18 cores of one socket of a Xeon Gold 6240, passive bound
threads, SOLPROP_FFTW_PLAN=measure, --length 0.05, three rounds of one repeat;
medians of the per-round pairs, range over rounds). Every pair did the same work, and
the final fields agree within the measured-plan floor (2 × 10⁻¹⁵ to 3 × 10⁻¹⁴):
case |
one thread |
sixteen threads |
|---|---|---|
|
0.703 (0.694–0.705): 7.74 → 5.46 s |
0.846 (0.829–0.868) |
|
0.641 (0.627–0.649) |
0.853 (0.844–0.868) |
|
0.865 (0.859–0.886) |
0.982 (0.949–0.984) |
|
0.701 (0.687–0.705): 8.04 → 5.67 s |
0.802 (0.800–0.805) |
|
0.913 (0.911–0.947) |
0.919 (0.901–0.922) |
|
0.945 (0.773–0.951) |
0.958 (0.947–1.019) |
The two averaged cases were measured without the quiet-block skip, which affects the resolved cases only (a separate run).
On 16 cores of an AMD EPYC 7763 (GCC 16.2 -march=znver3, the same protocol on a
fragmented allocation; all but the quiet-block skip), one / sixteen threads:
case |
one thread |
sixteen threads |
|---|---|---|
|
0.760 (0.754–0.770): 6.37 → 4.84 s |
0.927 (0.924–0.929) |
|
0.755 (0.744–0.765) |
0.937 (0.904–0.940) |
|
0.884 (0.874–0.885) |
0.975 (0.963–1.020) |
|
0.750 (0.750–0.766): 5.53 → 4.16 s |
0.872 (0.845–0.881) |
|
0.918 (0.912–0.927) |
0.922 (0.906–0.929) |
|
0.985 (0.943–1.056) |
0.967 (0.941–0.975) |
With the quiet-block skip as well (another allocation, the same CPUs): argon_blueshift
0.720 (0.715–0.724) / 0.937, ctc 0.754 (0.751–0.755) / 0.907, rdw 0.883 / 0.989,
grin_nonlinear 0.749 / 0.918 (0.835–0.918).
Not adopted: summing each block of eight scan increments locally before the running
prefix, so that only block totals form a dependent chain (ctc column 0.538 → 0.534 s,
within noise, for a change of association).
The native performance push: main at 740b24d against its result#
The native performance push started from main at 740b24d, the merge of SEP 39’s
native Adams march, and changed only the native backend and its build. It left the
benchmark cases and stepper settings unchanged. The sections above record each adopted
change with its own A/B. This section compares the two ends.
Complete runs (ab --length 1.0, three interleaved rounds, fresh processes) on a
dual Intel Xeon Gold 6240, timed on the 18 physical cores of one socket. Every case took the same steps.
Case |
One thread (field difference) |
Sixteen threads (field difference) |
|---|---|---|
|
0.841 (\(3.6\times10^{-13}\)) |
0.765 (\(1.0\times10^{-12}\)) |
|
0.802 (\(9.4\times10^{-14}\)) |
0.796 (\(7.9\times10^{-14}\)) |
|
0.659 (\(7.9\times10^{-14}\)) |
0.704 (\(1.7\times10^{-13}\)) |
|
0.608 (\(2.4\times10^{-10}\)) |
0.505 (\(2.1\times10^{-10}\)) |
|
0.563 (\(5.4\times10^{-14}\)) |
0.366 (\(1.1\times10^{-13}\)) |
|
0.605 (\(5.3\times10^{-14}\)) |
0.697 (\(1.3\times10^{-13}\)) |
|
0.724 (\(2.0\times10^{-12}\)) |
0.818 (\(1.7\times10^{-12}\)) |
|
0.645 (\(1.4\times10^{-13}\)) |
0.387 (\(9.5\times10^{-14}\)) |
|
0.623 (\(4.7\times10^{-15}\)) |
0.627 (\(4.5\times10^{-15}\)) |
|
0.684 (\(1.7\times10^{-12}\)) |
0.792 (\(6.9\times10^{-13}\)) |
|
0.559 (\(1.1\times10^{-15}\)) |
0.640 (\(1.1\times10^{-15}\)) |
|
0.545 (\(9.4\times10^{-16}\)) |
0.659 (\(9.5\times10^{-16}\)) |
|
not run (hours) |
0.679 (\(2.0\times10^{-10}\)) |
geometric mean |
0.649 |
0.627 (without |
The geometric means are 0.649 at one thread and 0.627 at sixteen. Short windows
(--length 0.1) gave 0.663 and 0.613, but individual cases differ. rdw at sixteen
threads is 0.337 in a window and 0.704 over a complete run, because the window’s
baseline is dominated by per-run costs that the push removed (an idle NumPy BLAS pool
spinning beside the native team).
Short windows on an exclusive AMD EPYC 7763 node (--length 0.1, three rounds, 128
cores, timing at one and sixteen threads). Every case took the same steps, with fields
within \(2\times10^{-13}\).
Case |
One thread |
Sixteen threads |
|---|---|---|
|
0.839 |
0.784 |
|
0.886 |
0.882 |
|
0.685 |
0.934 |
|
0.663 |
0.785 |
|
0.662 |
0.448 |
|
0.595 |
0.718 |
|
0.863 |
0.928 |
|
0.618 |
0.351 |
|
0.574 |
0.748 |
|
0.528 |
0.751 |
|
0.763 |
0.811 |
|
0.817 |
0.858 |
|
0.824 |
0.875 |
geometric mean |
0.707 |
0.735 |
Complete runs on an NVIDIA A40 (ab --device --length 1.0, three rounds). Every case
took the same steps.
Case |
|
branch s |
Ratio |
Field difference |
|---|---|---|---|---|
|
1.330 |
0.476 |
0.358 |
\(1.2\times10^{-10}\) |
|
6.691 |
2.731 |
0.408 |
\(1.7\times10^{-13}\) |
|
10.119 |
6.171 |
0.610 |
\(1.1\times10^{-13}\) |
|
3.302 |
1.377 |
0.417 |
\(7.9\times10^{-14}\) |
|
0.701 |
0.528 |
0.753 |
\(3.6\times10^{-15}\) |
|
6.455 |
5.036 |
0.791 |
\(1.7\times10^{-15}\) |
On this card main runs some cases at one of two speeds from process to process. For
rdw and radial_filament_256, the main column is therefore the faster of two
complete-run jobs, and the ratio is the branch’s median over it, which makes it
conservative. The final tree’s own job measured 0.403 and 0.392 directly. The other
ratios are that job’s direct medians of paired rounds.
On the A40 the complete-run gains are larger than the short windows’. A window spends a larger share of its time in solprop’s per-run host work, which both arms share.
The other wheel platforms (ab --length 0.2, three interleaved rounds, each case at
one thread and at the machine’s physical cores; the same steps everywhere, fields within
\(2.1\times10^{-12}\)). The push was tuned on Linux x86-64; a first pass on these platforms
found two regressions at full width, both on one-column problems: argon_blueshift 1.31
on macOS and gnlse_raman_shock 1.13 on Linux aarch64. Their causes and fixes are in
a stable kernel team, compact CPU teams and
one-mode products. The table compares main with the branch with
those fixes: the CI wheels of both on macOS and Windows; on aarch64 main’s CI wheel
against a local DISTRIBUTION build of the same commit.
Case |
macOS arm64, Apple M5 Max, 1 / 18 threads |
Linux aarch64, Google Cloud C4A (Neoverse V2), 1 / 16 |
Windows x86-64, Google Cloud C4D (AMD EPYC 9B45), 1 / 8 |
|---|---|---|---|
|
0.969 / 0.769 |
0.982 / 0.704 |
0.773 / 0.941 |
|
0.983 / 0.979 |
0.978 / 0.982 |
0.889 / 0.893 |
|
0.952 / 0.995 |
0.973 / 0.518 |
0.795 / 0.858 |
|
0.989 / 1.010 |
1.005 / 0.569 |
0.774 / 0.877 |
|
0.874 / 0.975 |
0.880 / 0.619 |
0.861 / 0.648 |
|
0.954 / 0.974 |
0.968 / 0.953 |
0.714 / 0.826 |
|
0.981 / 1.004 |
1.004 / 1.014 |
0.925 / 0.970 |
|
1.010 / 0.858 |
0.991 / 0.593 |
0.686 / 0.770 |
|
– / 0.754 |
– / 0.878 |
– / 0.717 |
|
0.715 / 0.846 |
0.722 / 0.586 |
0.564 / 0.764 |
|
– / 0.872 |
– / 0.896 |
– / 0.764 |
|
– / 0.954 |
– / 0.726 |
– / 0.827 |
|
0.902 / – |
0.820 / – |
0.725 / – |
geometric mean |
0.929 / 0.911 |
0.928 / 0.733 |
0.764 / 0.816 |
The first-pass tables before the fixes, the bisection and the sizing
records are kept with the project’s development records. The M5 Max ran with about 1.5
cores of background load (each round’s ranges stay within a few per cent); on an Intel Xeon
Gold 6240 the fixes leave every case within the A/A spread (geometric mean 0.999 against the
unfixed branch; main against the fixed branch 0.646 over both widths).
Accuracy. Every A/B took the same steps.
On a Xeon Gold 6240, complete runs of
he_rdw_averagedandctcmoved the final field by about \(2\times10^{-10}\), against \(10^{-13}\) in short windows.mainagainst itself, over the same complete runs, gives \(6\times10^{-11}\) to \(2\times10^{-10}\): measured FFTW plans differ between processes at about \(10^{-16}\), and the adaptive controller amplifies the difference over a long run. The branch’s differences are at that floor.On the A40, fields are reproducible bitwise between processes. The device’s contraction of the Adams passes, its reduction order and the split transforms each change a step by rounding. Together they move
he_rdw_averagedby \(10^{-11}\) in a window and by \(1.2\times10^{-10}\) over a complete run, with the same steps: the adaptive controller amplifies the rounding, as on the CPU.Every other case agrees to within about \(2\times10^{-12}\).
Measured and not adopted.
Compiler options:
-fno-math-errno -fno-trapping-math -fcx-fortran-rules,-O2, IPO off, 512-bit vectors, SLEEF’s AVX-512 functions on a Xeon Gold 6240, branch padding.Threading: spinning OpenMP waits, binding threads by default (on an exclusive EPYC allocation,
OMP_PROC_BIND=close OMP_PLACES=coresis worth about 11 % on average; it was neutral on a Xeon Gold 6240), a BLAS width per product, serial or full-width medium operations.Sixteen-thread Raman and one-column team kernels: they gained on one CPU and lost on the other.
Hand-written skinny products, Adams lane blocks of 16 and 64, band statistics through vector maths.
On devices: a whole-transform node chunk, eight rows a thread in the compute capability 8.6 GEMM, other device reduction block sizes, band column tilings, and quadrature partials in chunks.
Outside the native backend. On complete runs, solprop’s host work outside the steps
is a fixed cost per run, not per step: 0.1–0.3 s on the capillary and radial cases,
about 6 s on kerr_beam_384. That is 2–9 % of a complete one-thread capillary run, and
up to a third of the complete radial_filament_256 (sixteen threads) and
kerr_beam_384 runs, which take one to twenty seconds. The configuration hash is
computed once, at the first output write: 0.07–0.27 s, or 1.7 s for kerr_beam_384’s
configuration. The cost that repeats is a saved field plane of a large Cartesian field.
For kerr_beam_384’s 1.77 GB field that is about 3 s per plane:
the saved plane’s copy, 1.1 s;
the conversion to the optics convention, 1.0 s;
the output store’s copy, 0.6 s;
the native observation, 0.35 s.
A capillary or radial plane costs 7–25 ms.
The run record per plane#
#427 bounds the controller’s norm history
(a deque of the controller’s lag plus two, on the per-step Runge–Kutta and batch path)
and makes the per-plane run record incremental: one RunOutcome and one validation of
its record per plane, CallLedger.orders run-length encoded as it grows, and
/meta/outcome rewritten in place. Before: main at 76eb2e57; after: the same with
the pull request merged. One thread, numba_cpu, on an Apple M5 Max (18 cores, 128 GB,
macOS 27.0.1, Python 3.14.5, NumPy 2.5.2, numba 0.67.0, h5py 3.16.0), a machine in
ordinary use (load 4–10): a labelled A/B, to be read as ratios, not times.
python -m benchmarks ab --length 0.02 --rounds 4 --repeats 7 (the harness’s
MemoryOutput, its 11 planes inside the window, so a plane every one to four steps):
case |
stepper |
steps (rejected) |
before → after |
ratio |
A/A |
|---|---|---|---|---|---|
|
|
12 (0) |
14 → 14 ms |
0.962 [0.906, 1.127] |
1.009 [0.994, 1.057] |
|
|
18 (1) |
16 → 15 ms |
0.955 [0.887, 0.987] |
0.991 [0.982, 1.034] |
|
|
12 (0) |
46 → 46 ms |
1.002 [0.992, 1.012] |
0.990 [0.924, 1.036] |
|
|
37 (1) |
54 → 54 ms |
0.990 [0.882, 1.026] |
0.992 [0.986, 1.034] |
Geometric mean 0.977 (A/A 0.996); the same steps and final fields in both builds. The
harness writes no file, so the in-place /meta/outcome was timed by a hand script with
the same alternation (fresh processes, four rounds of five repeats after a warm-up): the
first centimetre of gnlse_soliton’s fibre (1536 bins) with 40 save planes, through
run_problem into an HDF5Output:
stepper |
steps |
before → after |
ratio |
A/A |
file |
|---|---|---|---|---|---|
|
243 |
101.2 → 95.0 ms |
0.947 [0.930, 0.968] |
0.988 [0.837, 1.012] |
1.50 → 1.35 MB |
|
48 |
48.6 → 48.2 ms |
0.987 [0.903, 1.001] |
0.955 [0.714, 1.025] |
1.21 → 1.13 MB |
Nothing measured is slower. The Multistep file run, a plane every six steps, is 5 % faster, outside its A/A range; the others sit inside theirs. The record’s old cost grew with the steps times the planes, so these windows show only its start.
A run’s planes without a copy#
The fix of #448 hands an output each plane
without copying its field, by adopting the observation’s arrays, and adds one branch to
StepDriver.observe for the held plane of
#345. Before: f004a662; after:
643db910 on the pull request’s branch. An exclusive node, AMD EPYC 7763, 16 cores in one
NUMA domain, --threading passive-bound, SOLPROP_FFTW_PLAN=measure.
python -m benchmarks ab --length 0.1 --rounds 5 --repeats 1, as the median of the
per-round paired medians (after / before), with its range over the rounds:
case |
threads |
ratio |
range |
|---|---|---|---|
|
1 |
0.9911 |
0.980 to 0.998 |
|
16 |
0.9982 |
0.990 to 1.007 |
|
1 |
1.0017 |
0.995 to 1.012 |
|
16 |
0.9980 |
0.544 to 0.999 |
Geometric mean 0.9972, the fields within 3.6e-15 and the same work in both builds. The
range of far_field_stats at 16 threads has one outlier round, in which the baseline took
16.2 s. This shows no regression. It does not measure the saving at large fields: these
cases save few planes at a field size where the copy is small.
The saving is in memory, and is measured separately, on a loaded machine and so as field
volumes rather than times. A delay scan through SimulationRunner with a handler that
reduces each plane, against a hand loop over the backend protocol, on native CPU: a
low-storage Kerr beam at 128 x 128 x 80 (20 MiB a volume), a window of 6 steps and 3 planes
per input, the peak above the level before each input after a warm first one, on an Intel
Xeon Gold 6240. The change is this fix together with
#435 and
#447, which release the input:
measure |
hand loop |
runner, before |
runner, after |
|---|---|---|---|
peak by |
1.006 |
3.003 |
1.063 |
peak resident, volumes |
0.94 to 0.99 |
2.86 to 2.94 |
0.93 to 1.00 |
numpy_ref, whose stages allocate per pass, went from 14.50 to 12.50 volumes by
tracemalloc, the hand loop’s figure. Not measured at full size, and not extrapolated to it.
Single-precision collar and batches#
#428 keeps the reference backend’s split
collar (apply_temporal_rate) in binary32 under single precision, as the correction
a + F(F^-1(a)(f - 1)); before, the product promoted to binary64 and was narrowed. Its
single-precision batches now run through the plain LockstepRunner, each member its own
binary32 trial; before, NumpyBatchBackend computed them in binary64 and narrowed the
result, so the before column is the wrong arithmetic’s cost and the fields are not
compared. ab selects neither a precision nor a batch, so both were timed by the hand
script above, same machine and builds (main at 76eb2e57, then #428 merged), one
thread, numpy_ref, on the same soliton.
The collar, 5 mm of RungeKutta() (43 accepted steps, one collar pass each),
absorber_scheme="split_step", four rounds of five repeats:
bins |
before → after |
ratio |
A/A |
|---|---|---|---|
1536 |
34.0 → 34.0 ms |
1.003 [0.999, 1.169] |
0.993 [0.957, 1.003] |
16384 (8 ps, 300 nm–4 µm) |
183.1 → 180.2 ms |
0.989 [0.971, 1.019] |
0.992 [0.960, 1.020] |
The pass alone, same process (best of seven times 200 calls): 18.3 → 15.9 µs at 1536 bins and 191 → 125 µs at 16384, 2–5 % of a step, which the runs do not resolve.
Batches, ten fixed steps over 2 cm with no collar, members 1 % apart in peak power,
the stepping alone timed (propagate_to; the members’ construction is not):
bins |
members |
rounds |
before → after |
ratio |
A/A |
|---|---|---|---|---|---|
1536 |
4 |
4 |
29.8 → 30.1 ms |
1.003 [0.963, 1.029] |
1.025 [1.011, 1.059] |
1536 |
16 |
4 |
116.5 → 122.3 ms |
1.048 [1.036, 1.075] |
1.028 [0.958, 1.072] |
1536 |
16 |
8 |
136.5 → 141.5 ms |
1.059 [0.984, 1.119] |
0.981 [0.930, 1.030] |
16384 |
8 |
8 |
496.6 → 389.7 ms |
0.779 [0.676, 0.852] |
0.971 [0.890, 1.017] |
On a small grid with many members the member-by-member trials cost 3–6 %, at the edge of
the A/A spread; on a 16384-bin grid they are 22 % faster, binary32 transforms replacing
the binary64 ones. What vectorising over the members is worth was read from the binary64
batch, NumpyBatchBackend against LockstepRunner on the same members (unpaired medians
of the same runs): 108.7 against 117.5 ms (16 members, 1536 bins) and 469 against 491 ms
(8 members, 16384 bins), 4–8 %. A vectorised binary32 batch would recover at most that,
so the reference keeps the plain runner.
A spectral walk-off absorber against the temporal collar#
What the spectral walk-off absorber
saves is the temporal pass, which a zero temporal rate compiles away; this entry says what
that is worth, in place of a figure measured on a backend since retired. A hand script, on the
one grid, one backend and one build: a field grid over 40 µm of fused silica, 143 nm to 2 µm,
trange 250 fs at the default collar_fraction, so a 326 fs window of 2048 samples; envelope
Kerr, an 800 nm 30 fs launch; ten fixed RungeKutta() steps of 1 µm, each case translated once
beforehand and the runs alone timed. The cases are the default collar under each scheme, and
custom_absorber(grid, rate, collar=absorber(grid, temporal=False)). Native CPU, eight threads
bound to eight cores of a dual Intel Xeon Gold 6240 (18 cores a socket), built for
-march=cascadelake. The machine was shared and in use (load 16–58 of 72), so these are to be
read as ratios of medians, and each is given over its separate sessions, cycling the three cases
within a session:
grid |
sessions (repeats) |
spectral / |
spectral / in-stage |
|---|---|---|---|
line, 256 columns |
2 (5, 9) |
0.92, 0.90 |
0.58, 0.53 |
Cartesian, 64 × 64 |
2 (5, 5) |
0.91, 0.89 |
0.47, 0.53 |
Single runs carried outliers of up to three times the median, which the median sets aside. The ratio against the split repeats to within 3 %; the one against the in-stage term less well, between 0.47 and 0.58. Over these ten steps the three runs’ fields agree to 1e-15 of the on-axis column’s norm inside the request, because no light has reached a collar: the entry times the pass and compares no absorption. It is a window, and the temporal pass’s share of a step moves with the response’s: a heavier response gives a smaller saving.
The default check on a device: reads, occupancy and sums#
A field-free point of a free-space Kerr beam at 768 x 768 x 600 (5.27 GiB), eight fixed low-storage
RK4IP steps and four planes, with a stated input identity, took 12 to 7 times as long
with the default boundary_check as without it. python -m benchmarks.device_split 768, the second point
of one SimulationRunner, on an NVIDIA H200 (CUDA 13.1) and an AMD Instinct MI325X (ROCm 10). Before is
7a224ea5, measured the same way that morning with the probe that became this module, on another machine
of the same type; after is this entry’s commit, both cards the same day:
H200, before |
H200, after |
MI325X, before |
MI325X, after |
|
|---|---|---|---|---|
Point, |
5.9 |
5.7 |
4.2 |
3.8 |
Point, default check, s |
70 |
51 |
29 |
14 |
State crossings in a point |
13 |
9 |
13 |
9 |
|
184 |
10 |
1,240 |
13 |
|
55 |
2.3 |
80 |
6.6 |
|
134 |
77 |
164 |
86 |
What changed: a saved plane that the step reaching it already read takes no second read, which was a whole
state’s copy thrown away; a replay decides its reads by its next step, not by dz_init; the occupancy is
a reduction per column, without an atomic per element; energy, the peak and a low-storage step’s norm are real
reductions, not sums on one thread of a device; a runner’s points share the occupancy’s table; an
Adams run past its snapshot limit takes no snapshot to learn its size.
HIP’s smallest block is 64 threads, and a reduction’s value has to fit (64 + 2) * sizeof(value) of 64 KiB
with the kernel’s own static memory beside it, which Kokkos’s check doesn’t count. So a reading there holds 64
buckets a pass and takes two passes of 6.4 ms, not one of 128: 124 passed Kokkos’s check and the dispatch then faulted
(“Illegal opcode in command stream”). On a CPU, python -m benchmarks ab against main on an Intel Xeon Gold 6240
at 1 and 16 threads, three rounds, on kerr_beam_192, line and planar: geometric mean 1.002, each case
within 4 %, fields to 1.5e-15, the same work.
The check still reads a host copy of the state, on the host, which is what is left of its cost above: the entries that follow take that.
The state’s crossing: a ring of pinned chunks#
A state crossing to the host was slow for one reason: a fresh pageable buffer takes its first touch inside the driver’s copy, 1.38 million page faults at 768 x 768 x 600. A bare copy of 5.27 GiB, in GiB/s:
NVIDIA H200 |
AMD Instinct MI325X |
|
|---|---|---|
Fresh pageable buffer, as it was |
1.8 |
7.9 |
Reused pageable buffer |
16.5 |
52 |
Pinned, which is the link |
51.5 |
52.6 |
64 MiB pinned ring, a worker per host thread, fresh huge-page destination |
33 |
50 |
The same ring, no huge pages |
25 |
33 |
Every state-sized crossing now takes that ring, both ways. python -m benchmarks.device_split, against the previous
entry’s commit, the same day, on the same cards and window:
H200, before |
H200, after |
MI325X, before |
MI325X, after |
|
|---|---|---|---|---|
|
2,929 |
175 |
506 |
107 |
Its page faults |
1,382,401 |
2,758 |
1,382,401 |
2,755 |
The point’s crossings together, s |
26.5 |
1.6 |
4.3 |
0.96 |
Point, default check, s |
51 |
25 |
14 |
9.7 |
Against main, with the previous entry, python -m benchmarks ab --device on kerr_beam_384, three rounds of one repeat,
which saves its fields: H200 13.451 → 4.672 s, ratio 0.347 (0.347–0.349); MI325X 5.700 → 2.033 s, ratio 0.359
(0.338–0.362). Fields identical and the same work on both. On a longer window, 384 x 384 x 600 with 51 steps and 17 planes,
the point with the check takes 14.2 s against 3.9 s without on the H200 (49.8 s before these two entries), and 7.1 s against 3.7 s on
the MI325X. The NVIDIA A40 sees less (PCIe Gen 4): 183 → 147 ms at 384 x 384.
A kept plane’s conversion and its finiteness check take threads too, above PARALLEL_BYTES, and a plane converts straight
into the store’s block rather than through a copy. A run that keeps no field allocates no ring: memory_ledger’s
host_crossing_ring stays zero, which a device test holds.
On a CPU a crossing is a plain copy and takes no ring. python -m benchmarks ab against main, with the previous entry, on an Intel
Xeon Gold 6240 (18 cores of one socket, passive threads, three rounds of one repeat, a shared machine): kerr_beam_192 0.976
at one thread and 0.882 at sixteen, radial_filament_256 0.999 and 0.994, line 0.983 and 0.990, planar 0.997 and 0.980; geometric
mean 0.975, fields to 4.2e-15, the same work.
Reference tables#
These tables back the indicative results above.
Kokkos CPU sizing thresholds#
Counts are elements or scalar product work, never allocated bytes. Stages and reductions use the field’s element count; batch updates use the fused batch’s total. Packing uses matrix entries and temporal copies use output samples, without multiplying by the dense transform’s inner dimension. Responses use active columns × components × fine-grid samples. Dense products use output rows × inner dimension × samples; coupled matrix functions instead use active spectral bins × modes³, and the exponential Adams trial’s passes the field’s elements × history rows. FFT and Raman choices use each plan’s actual real-side batch dimensions, including a kernel transform and the last partial chunk.
Operation |
Serial below |
Full pool from |
Intermediate rule |
|---|---|---|---|
Stage/update/reduction |
8,192 |
8,192 |
none |
Packing/temporal copy |
65,536 |
65,536 |
none |
Response |
32,768 |
196,608 |
up to four threads; at least 40 component columns or 65,536 fine samples per column also enables the full pool |
Native/custom GEMM |
1,048,576 |
1,048,576 |
none |
General-modal synthesis/projection/maps |
65,536 |
65,536 |
none |
Coupled matrix functions |
32,768 |
32,768 |
none |
Exponential Adams trial passes |
8,192 |
65,536 |
up to four threads |
Modal/spatial FFT |
32,768 |
131,072 |
up to four threads |
Raman FFT |
65,536 |
262,144 |
up to four threads |
A response column is one active node and one component. Every width is capped by the
global thread setting. A kernel pool that already holds a full-width instance gives it to
the medium-width kernels too (a stable kernel team); the FFT pools
keep their medium widths. A problem of one column on one node takes at most eight
threads in every pool, its transforms on its kernels’ team
(compact CPU teams). Explicit per-pool requests bypass this table, including the
short-FFT rule. build_info() exposes the constants and override mask; backend metadata
exposes effective widths and sizing inputs. Non-OpenMP host builds report that
per-operation sizing is unsupported and keep their existing execution policy.
Resident Adams allocations#
F = N² × 600 × 16 bytes is one complex128 field of the reduced kerr_beam_384 above
and C = 5592 × 600 × 16 bytes one node chunk (a 1 GiB node budget). Each pair is
numba_cpu / Kokkos capacity in GiB; arrows show the 29 → 18 volume change. The
384² capacities are calculated. These are owned allocations, not resident pages.
Allocation |
Field count, |
128² |
256² |
384² |
|---|---|---|---|---|
Adams history ring |
7 / 7 |
1.025 / 1.025 |
4.102 / 4.102 |
9.229 / 9.229 |
Adams moments |
8 / 7 |
1.172 / 1.025 |
4.688 / 4.102 |
10.547 / 9.229 |
Adams flow / propagator |
1 / 1 |
0.146 / 0.146 |
0.586 / 0.586 |
1.318 / 1.318 |
Native Adams source, exponent, saved state |
0 / 3 |
0 / 0.439 |
0 / 1.758 |
0 / 3.955 |
Dense slopes |
0 / 7 → 0 |
0 / 1.025 → 0 |
0 / 4.102 → 0 |
0 / 9.229 → 0 |
Dense start, end, exponent, held observation |
0 / 4 → 0 |
0 / 0.586 → 0 |
0 / 2.344 → 0 |
0 / 5.273 → 0 |
Current state |
1 / 1 |
0.146 / 0.146 |
0.586 / 0.586 |
1.318 / 1.318 |
RK stages |
7 / 7 |
1.025 / 1.025 |
4.102 / 4.102 |
9.229 / 9.229 |
Solution, error, advanced, recycled stage |
4 / 4 |
0.586 / 0.586 |
2.344 / 2.344 |
5.273 / 5.273 |
RK exponent / weighted residual |
1 / 1 |
0.146 / 0.146 |
0.586 / 0.586 |
1.318 / 1.318 |
RK propagators |
2 / 1 |
0.293 / 0.146 |
1.172 / 0.586 |
2.637 / 1.318 |
Transverse FFT scratch |
1 / 1 |
0.146 / 0.146 |
0.586 / 0.586 |
1.318 / 1.318 |
Field-volume subtotal |
32 / 44 → 33 |
4.688 / 6.445 → 4.834 |
18.750 / 25.781 → 19.336 |
42.188 / 58.008 → 43.506 |
Temporal FFT buffers ( |
fixed chunk |
0.200 / 0.200 |
0.200 / 0.200 |
0.200 / 0.200 |
Absorber scratch ( |
fixed chunk |
0 / 0.100 |
0 / 0.100 |
0 / 0.100 |
Statistics spectrum, time, power ( |
fixed chunk |
0 / 0.125 |
0 / 0.125 |
0 / 0.125 |
The remaining ledger entries (reduction partials, axes, spatial coefficients, statistics
outputs, host problem tables, masks, weights and plan heap) total a few MiB. Outside the
ledgers, the input field adds one F; Kokkos keeps another F of host state, and field
export passes through two more F at the Python boundary. Host Adams trials form about
4F of temporary arrays. Capacities and transients do not all become resident at once,
so their sum overstates the measured peak.
Native CPU FFT and diagnostic scheduling#
This campaign uses the frozen benchmarks ab harness and unchanged registry recipes,
under an approved exception to standard-run and whole-suite performance reporting.
These are lightweight measurements, not a standard-run adoption verdict. Every
reported propagation window contains ten prescribed steps: fixed-rk for modal and
radial cases, fixed-rk4ip for Cartesian Kerr. Construction, first-window cost and
observations are recorded separately; no window is extrapolated to a full run.
The baseline is 9521a88; the retained implementation is b3bdd0f. Each arm has a
separate source/environment/native wheel. On the Apple M5 Max (18 cores, 128 GiB,
macOS 27.0.1), Homebrew Clang 23.1.3 uses Accelerate BLAS and one LLVM OpenMP runtime.
The source-built FFTW 3.3.11 control and NEON library have identical M1 flags,
deployment target 14.0 and cycle-counter support, with both precisions and coherent
threading libraries. The two-socket Xeon Gold 6240 uses exclusive 72-logical-CPU
allocations and one hardware thread per selected physical core. Its independent
bundles are GCC 16.2/OpenBLAS 0.3.34/FFTW 3.3.11 with GNU OpenMP, and IntelLLVM
2026.1.1/MKL 2026.1 (including MKL FFT) with Intel OpenMP. Timed arms run sequentially,
without concurrent builds. Python is 3.14.5 on an Apple M5 Max and 3.14.7 on a Xeon Gold 6240;
NumPy is 2.5.2, Kokkos 5.2.1, Kokkos Kernels 5.2.0 and Kokkos-FFT 2.0.0.
NEON codelets and Raman plans#
A same-source control with matched compiler flags changes only FFTW’s explicit NEON codelets. Its eight-case screen at budgets 1 and 18 has geometric mean ratio 0.933; this one-round/two-repetition screen isolates the library effect but is not a confirmation. Full and tail microchecks include H₂’s 65536×21/12 and CtC’s 131072×3/1 rows at serial and parallel widths, raw FFT normalization and exact padding zeros. ESTIMATE and MEASURE both retain short parallel shapes where vDSP is faster, so complete vDSP removal is rejected.
With NEON in both arms, replacing the previous Raman choice with tuned Kokkos-FFT has the following three-round/five-repetition warmed-window ratios. The rest of the source is held fixed for this provider/planning comparison.
Case |
1 thread |
18 threads |
|---|---|---|
CtC |
0.698 [0.694, 0.699] |
0.966 [0.961, 0.971] |
H₂ Raman |
0.480 [0.459, 0.485] |
0.788 [0.767, 0.795] |
GNLSE Raman shock |
0.828 [0.817, 0.830] |
0.839 [0.839, 0.846] |
Measured planning has a real setup cost. In this ordered Raman comparison, median
serial CtC construction changes from 3.00 to 22.83 s, while a warmed ten-step window
changes from 6.00 to 4.18 s. H₂ construction changes from 1.09 to 1.81 s and its
window from 1.28 to 0.62 s. Wisdom is process-local and previous constructions affect
these costs; they are not universal constants. Explicit SOLPROP_FFTW_PLAN=estimate
trades this measured setup for cheaper construction. Parallel default planning stays
ESTIMATE. No full-propagation payback is inferred from these ten-step windows.
Retained Mac combination#
The fresh current-library baseline and retained NEON/provider/scheduling candidate use three alternating rounds and five repetitions at each budget. Ratios are the median of paired round medians, with the range across rounds in brackets.
Case |
1 thread |
18 threads |
|---|---|---|
RDW |
0.888 [0.877, 0.901] |
0.831 [0.810, 0.835] |
CtC |
0.595 [0.577, 0.604] |
0.859 [0.858, 0.878] |
Radial 256 |
0.893 [0.853, 0.912] |
0.962 [0.934, 0.967] |
Resolved argon |
0.954 [0.950, 0.961] |
0.886 [0.879, 0.898] |
H₂ Raman |
0.472 [0.457, 0.474] |
0.718 [0.712, 0.718] |
Averaged helium RDW |
0.891 [0.880, 0.901] |
0.902 [0.891, 0.916] |
Averaged argon |
0.918 [0.909, 0.922] |
0.915 [0.905, 0.920] |
GNLSE soliton |
0.889 [0.882, 0.893] |
0.870 [0.867, 0.871] |
The geometric mean is 0.792 at one thread and 0.865 at the production budget of 18
(0.828 across both budgets). Using each fresh process’s warmed median as one sample,
the conservative aggregate (candidate median + 2 MAD) / (baseline median − 2 MAD)
is 0.805 and 0.878 respectively. All denominators are positive. These spread checks
support the retained change within the lightweight scope. All fixed windows retain
ten accepted steps, no rejected steps and 61 source evaluations; field differences
are at most 2.1e-16 in relative L2 norm. A one-round/two-repetition guard screen at budget 18 gives radial
512 1.012, Cartesian plasma 128² 0.894, line 0.976, general modal 0.892, scalar wall
0.936 and vector wall 0.990. The 1.2% radial variation does not exceed the A/A spread.
The retained library also passes the Cartesian Kerr confirmation (three rounds,
two repetitions, ten fixed-rk4ip steps). No Cartesian traversal change is included:
Case |
1 thread |
18 threads |
|---|---|---|
Kerr 192² |
0.843 [0.832, 0.849] |
0.869 [0.718, 0.924] |
Kerr 384² |
0.837 [0.834, 0.860] |
0.880 [0.880, 0.895] |
The geometric mean is 0.840 at one thread and 0.874 at 18 (0.857 combined); the corresponding conservative aggregates are 0.860 and 0.919. Fields differ by at most 1.4e-18 and work matches. The 192² parallel spread is wider, so its precise gain is less certain; all three paired rounds still favour the candidate. These results measure the library change with the original traversal.
Diagnostic scheduling#
The affected set is ordinary DOPRI5 modal CtC, RDW, resolved argon and H₂ Raman. The implementation defers coarse projections to the final source and their FFTs and held-current assembly to accepted scoring. General-modal, dispersive, low-storage, Adams and envelope-plasma scheduling remains eager. A serial full-source profile reduced RDW’s forward real FFT calls from 427 to 274 per ten-step window; CtC’s fell from 1464 to 1311. Persistent field-buffer ledgers are unchanged. FFT plan heap estimates can vary with planning and allocator state.
These confirmations use three alternating rounds and five repetitions. Each ratio is the median of the per-round paired medians; brackets give the range over rounds. A ratio below one favours the candidate.
Case |
M5 Max, 18 threads |
GCC Xeon, 1 thread |
GCC Xeon, 18 threads |
Intel Xeon, 1 thread |
Intel Xeon, 18 threads |
|---|---|---|---|---|---|
RDW |
0.803 [0.755, 0.845] |
0.930 [0.925, 0.946] |
0.823 [0.820, 0.843] |
0.900 [0.887, 0.916] |
0.822 [0.813, 0.828] |
CtC |
0.906 [0.888, 0.917] |
0.897 [0.894, 0.901] |
0.946 [0.945, 0.949] |
0.904 [0.893, 0.910] |
0.937 [0.933, 0.937] |
Resolved argon |
— |
0.998 [0.984, 1.002] |
0.949 [0.941, 0.953] |
0.966 [0.963, 0.976] |
0.966 [0.956, 0.972] |
H₂ Raman |
0.933 [0.914, 0.967] |
1.007 [0.986, 1.082] |
0.982 [0.981, 0.988] |
0.985 [0.976, 1.044] |
0.973 [0.957, 0.977] |
At one thread the GCC affected-set geometric mean is 0.957 and Intel’s is 0.938; at 18 threads they are 0.923 and 0.922. The corresponding conservative aggregates from fresh-process medians are 0.972/0.931 for GCC and 0.951/0.929 for Intel, with positive denominators. Both production-budget gains exceed 5% and satisfy the conservative spread criterion. Mac’s isolated 18-thread ratio is 0.879, with a conservative aggregate of 0.950. Across both budgets, GCC is 0.940 and Intel 0.930. The matched fixed windows retain ten accepted steps, no rejections and 61 source evaluations; their field differences are at rounding level (at most 1.9e-16 in these confirmations). The M5 Max’s first 18-thread screen and one A/A round showed large host timing variation and remain inconclusive. The runner’s busy-at-launch flag does not establish its cause; load averages also include prior owned work. A subsequent three-round A/A control gave RDW 1.010 [1.008, 1.054] and H₂ 1.015 [0.995, 1.040]. Those controls do not justify interpreting a few-percent change as a gain.
Retained Xeon combination#
Each compiler bundle also compares the complete retained candidate with its frozen baseline. Raman cases use three alternating rounds/five repetitions at budgets 1 and 18; other main cases and geometry guards use one round/two repetitions. The Raman confirmations are:
Case |
GCC, 1 thread |
GCC, 18 threads |
Intel, 1 thread |
Intel, 18 threads |
|---|---|---|---|---|
CtC |
0.790 [0.789, 0.792] |
0.963 [0.953, 0.966] |
0.912 [0.908, 0.914] |
0.939 [0.934, 0.942] |
H₂ Raman |
0.824 [0.817, 0.841] |
1.003 [0.988, 1.030] |
0.977 [0.966, 0.986] |
0.976 [0.974, 0.977] |
The geometric means at budgets 1/18 are 0.807/0.983 for GCC and 0.944/0.957 for Intel (combined 0.890 and 0.951). GCC’s parallel Raman-only conservative aggregate is 1.004, so that subset does not establish a gain beyond spread; the confirmed shared scheduling gain is assessed over its complete affected set above. The serial GCC improvement includes measured FFTW planning; Intel uses MKL FFT, whose FFTW compatibility layer ignores planning flags. No benefit is inferred for unmeasured Linux Arm codelets.
Main-case guard screens, followed by A/A controls for the small averaged and GNLSE windows, are reported separately from the confirmed affected-set gains:
Case |
GCC, 1 thread |
GCC, 18 threads |
Intel, 1 thread |
Intel, 18 threads |
|---|---|---|---|---|
RDW |
0.928 |
0.834 |
0.929 |
0.827 |
Radial 256 |
1.002 |
0.962 |
1.012 |
0.991 |
Resolved argon |
1.000 |
0.947 |
0.988 |
0.965 |
Averaged helium RDW |
1.014 |
1.051 |
1.011 |
1.001 |
Averaged argon |
1.004 |
1.025 |
0.994 |
1.017 |
GNLSE soliton |
1.008 |
1.005 |
1.019 |
1.039 |
The initial GCC averaged-helium 18-thread ratio of 1.051 and Intel GNLSE ratio of 1.039 triggered separate three-round/five-repetition A/A and A/B controls:
Control case |
A/A, 1 thread |
A/B, 1 thread |
A/A, 18 threads |
A/B, 18 threads |
|---|---|---|---|---|
GCC averaged helium |
0.997 [0.989, 0.998] |
1.014 [1.005, 1.016] |
1.016 [0.998, 1.128] |
1.012 [1.012, 1.014] |
GCC averaged argon |
0.987 [0.984, 0.994] |
1.014 [1.009, 1.031] |
1.001 [1.000, 1.001] |
1.010 [0.983, 1.017] |
GCC GNLSE |
0.991 [0.818, 0.997] |
1.005 [1.003, 1.016] |
0.999 [0.995, 0.999] |
1.010 [0.992, 1.017] |
Intel GNLSE |
0.981 [0.746, 0.993] |
1.005 [0.996, 1.008] |
1.018 [0.985, 1.061] |
0.999 [0.991, 1.018] |
The initial 4–5% slowdowns do not repeat. GCC’s small-window confirmation aggregate is 1.011, a roughly 1% cost that remains visible; this does not justify additional specialisation. Large A/A outliers make those controls inconclusive at fine resolution. No improvement is claimed for these unchanged small-window routes.
At budget 18, GCC’s six geometry guards span 0.977–1.018 (aggregate 1.000), and Intel’s span 0.998–1.017 (aggregate 1.007). These cover radial 512, Cartesian plasma 128², line, general modal and scalar/vector walls. Cartesian Kerr 192²/384² guards are 0.997/0.999 for GCC and 1.001/1.004 for Intel, with ten steps and matched work. They support the absence of a large regression; they do not establish percent-level improvements on routes whose implementation is unchanged.
Verification scope#
Focused checks cover raw FFT normalization, full/tail shapes, threaded execution, provider selection, planning overrides, coherent library coexistence, repeated construction/closure, source/step parity, held-plasma diagnostics after discarded trials, FSAL, reset, accepted values and peaks, and band-filter boundaries and high dynamic range. Native suites passed on both Xeon bundles: GCC has 137 C++ tests and 378 Python tests passing; Intel has 133 and 376. Provider/platform/device skips remain explicit. Mac final focused Python checks have 118 passes plus 13 lifetime and import checks. Independent bounded smooth-law adaptive fixtures pass on both arms and all three builds against accuracy envelopes fixed from tighter NumPy references; these small fixtures do not certify full production trajectories.
The macOS distribution helper was exercised with freshly built dependencies and wheel repair. A clean installation discovers both bundled FFTW precisions as NEON, one bundled OpenMP runtime and the required third-party licences, with the Apple M1 baseline and macOS 14 target. No workflow edit is needed: the existing macOS wheel job calls this dependency recipe. Full gates, slow and coverage lanes are deferred to CI at the maintainer’s request; GPU/device checks were unavailable on these CPU hosts. Focused formatting, lint, spelling and diff checks passed. Supplemental CPU/wall, observation, load and memory receipts are collected outside A/B timing; no additional speed claim is taken from those resource measurements.
Rejected leads#
Lead |
Bounded evidence |
Decision |
|---|---|---|
Remove vDSP completely |
NEON FFTW still loses on short parallel rows, including 8192×1 and 16384×1, under both ESTIMATE and MEASURE |
Keep vDSP for short parallel power-of-two rows; use FFTW for serial and long rows with NEON |
Tap-first band filter, preserving each sample’s tap order |
Mac serial RDW 0.973 [0.970, 0.982], argon 1.079 [1.061, 1.090]; GCC/Intel RDW/argon aggregates 0.911/0.900 across 1/18 threads |
Reject the shared traversal and avoid an architecture-specific fork; retain the new boundary/dynamic-range tests |
Cartesian 8×32 tile plus unused-load elimination |
192² Kerr: serial 0.980 [0.939, 0.997], 18 threads 0.980 [0.959, 1.005], three rounds/two repetitions |
Reject the approximately 2% gain; keep 32×16 and the existing loads |
Force one Accelerate BLAS thread |
Five-case confirmation at budget 18: geometric mean 1.000, three rounds/five repetitions |
Keep the existing provider-managed startup policy |
The post-FFTW 192² serial profile assigns 4.62 s to the separable fold, 4.72 s to complex FFTs and 2.11 s to temporal pack/crop within a 12.16 s instrumented window. A separate non-IPO compiler diagnostic reports control-flow and call barriers in the crossing and final reduction loops; the measured production binaries retain IPO. The tile experiment changes locality without changing stored layouts or per-column summation order, but its complete-window gain does not meet the greater-than-5% threshold. Instrumented profiles are diagnostic evidence, not timings used for retaining a change.
Independent startup-only pool screens at budget 18 varied kernel, ordinary FFT, Raman full, Raman tail and BLAS widths separately to 1 or 4. Both Xeon bundles showed aggregate slowdowns for every reduced-width screen (the smallest was 1.095 for Intel’s four-thread Raman tail). Mac responses were mixed, including opposite RDW and argon directions. No shared dimension threshold or pool policy is changed; explicit overrides and global caps retain their existing semantics. A 36-core sizing confirmation is unnecessary because no sizing change is retained.
The run record’s cost#
#309 measured how a run’s record grows with
its grid. Moving the record’s large arrays beside its text (RECORD_ARRAY_LIMIT) takes the
hash from the record’s text to its arrays’ bytes.
Measured on an Intel Xeon Gold 6240, pinned to one socket with 16 threads, on a shared
machine with a load average of about 17, so treat small differences as noise. One run each,
built and not stepped, on numpy_ref, with #309’s Cartesian problem of 96 frequency bins.
The figures are for the record alone, not for a run.
Grid |
First hash, s |
|
|
Reading the file’s record, s |
Hashing a read record, s |
JSON, MB |
File, MB |
|
|---|---|---|---|---|---|---|---|---|
256² |
before |
0.442 |
0.163 |
0.171 |
0.177 |
0.174 |
5.61 |
5.62 |
after |
0.010 |
0.001 |
0.006 |
0.021 |
0.001 |
0.03 |
3.19 |
|
512² |
before |
1.763 |
0.652 |
0.711 |
0.755 |
0.777 |
22.66 |
22.67 |
after |
0.032 |
0.001 |
0.011 |
0.065 |
0.001 |
0.05 |
12.64 |
|
768² |
before |
4.250 |
1.588 |
1.711 |
2.155 |
1.735 |
55.59 |
55.60 |
after |
0.075 |
0.002 |
0.017 |
0.144 |
0.002 |
0.07 |
28.40 |
A default radial run of 256 nodes and 8192 frequency bins, which carries input noise, holds
83.21 MB of JSON before and 0.04 MB after, and its first hash goes from 2.603 s to 0.077 s.
A 64-node grid is about the same before and after, since its arrays sit close to the limit.
A run into MemoryOutput computes no hash at all.
A free-space point’s setup#
Building and translating a large free-space launch took longer than the steps of a short point:
the passes over a beam’s elements ran on one thread, its admittance ran twice, and the transverse
residual took a Python Fraction per node.
Measured on an Intel Xeon Gold 6240, one socket’s 18 cores, 18 threads, on a shared machine, twice each, unprofiled:
a 768 × 768 × 600 envelope launch through a Sellmeier glass with a Kerr response, e49925d1 against ece75dd7.
before (s) |
after (s) |
|
|---|---|---|
|
169, 179 |
14.9, 14.9 |
|
30.3, 27.5 |
7.2, 7.8 |
|
3.8, 3.0 |
3.5, 3.3 |
A launch’s bits don’t change, which tests/test_pulses.py and tests/test_cartesian_precision.py hold across
thread counts. The sum that scales the field stays one sum in one order for that reason, so it stays on one thread.