Backends and batching
Two implementations of the same evaluation, NumPy and compiled, and what a
batch buys on each. Written 2026-09-02. Numbers marked "measured here" were
taken that day on a shared login node, OMP_NUM_THREADS=1,
OMP_PROC_BIND=false, best of five, through the consumer route on the
released v3 artifacts, with the compiled module
aa-fanout/kernel-mode-kokkos/cpp/build-mrsafe (md5 48a74a07, built from
commit 4336162d). Everything else says where it was measured.
Two backends, and which one you get
model = NRHJSurAA.load_h5(RELEASE_AA) # "auto" (default)
model = NRHJSurAA.load_h5(RELEASE_AA, backend="kokkos")
model = NRHJSurAdA.load(RELEASE_ADA) # "auto"
Since 1.1 both classes default to "auto", which takes the compiled
backend when it is usable and NumPy when it is not. NRHJSurAA defaulted
to "numpy" before that.
"numpy" is the reference path and needs nothing beyond the declared
dependencies. "kokkos" routes the time-domain hot path, the tau inversion,
the piecewise-Chebyshev synthesis of every mode at every sample and the
e^{i m s Phi} rotation, through the compiled nrhjkokkos extension; the
coefficient-space work stays in NumPy on both
(nrhjsurrogate/execution/batched_evaluator.py module docstring). The
adiabatic-angle class defaults to "auto": kokkos if the module imports,
NumPy otherwise (nrhjsurrogate/driver/api.py:288-294); measured, the same
call resolved to numpy without a module and kokkos with one.
Where the module comes from. $NRHJ_KOKKOS_DIR names one directory and
nothing else is consulted; otherwise every cpp/build* beside the package
holding an nrhjkokkos*.so is a candidate and the NEWEST wins
(nrhjsurrogate/execution/compiled_module_loader.py:34). The first rule is
why a shell that has sourced one of the checked-in env_*.sh files hands its
compiled build to every process it starts, including one that believed it
was testing a clean install. The wheel is py3-none-any and carries no
compiled object, so a
pip install user has the NumPy backend and only that, until checklist B1
(fetching and compiling Kokkos at install time) lands; the deposit gate
proves the six files plus the wheel suffice on the NumPy route and refuses
to pass if a compiled module is importable.
Agreement between the backends
Measured here on one waveform at (4, 0.3, -0.2), same 15775-sample grid:
| comparison | agreement |
|---|---|
| AA kokkos against NumPy, per mode, of that mode's peak | 4.9e-9 to 2.2e-8, worst on (2, 2) |
AA hybridised f_low = 0.004, kokkos against NumPy, (2, 2) |
2.0e-10 |
| AdA kokkos against NumPy, (2, 2) | 1.36e-8; the two grids differ by 3.2e-9 M |
| batched value evaluator against per-point scalar calls | bitwise for sur_e and sur_h, 5.8e-13 for sur_o (kernel-mode port, 2026-08-27) |
The AA number is the tau-inversion class (kernel Newton against
CubicSpline) and is what tests/test_aa_api.py::test_vs_numpy_reference
gates at 2e-5 of peak (ACCURACY.md section 3). On the derivative side, the
compiled and NumPy paths agree to 1.4e-14 relative, and a batched
against an unbatched contraction to about 1e-10, which is not a defect but
the conditioning of the contraction arriving at evaluation time: the worst
element cancels by 8.3e6, so neither side is accurate below that.
A parity baseline is therefore backend specific. Measured here at the
branch point of this page's own branch: the NumPy-only capture against
parity_baseline_v4.npz fails on 143 of 447 arrays, all of them the 80
adiabatic-angle gradient arrays the NumPy route cannot produce, the 62
action-angle gradient arrays that differ at up to 1e-14, and the sentinel
that records the skip; the capture with the compiled module above is 447 of
447 bitwise. Compare like with like.
Cost, one waveform and a batch
Measured here, milliseconds per waveform:
| NumPy | compiled | |
|---|---|---|
| AA, one waveform | 85.8 | 10.2 |
| AA, batch of 64 (array input), per waveform | 97 | 8.2 |
| AdA, one waveform | 46.9 | 11.5 |
AdA, modes_grad, one point |
no path without the module | 62.4 |
AA, modes_grad, one point, full span |
437 to 688 across runs | (host-parallel batch, below) |
One thread on a shared node is a floor, not a throughput figure. The throughput figures, at scale, recorded where they were taken:
- Batched value evaluator, v3 artifact, 60 threads, exclusive node,
measured 2026-08-27: 5.65 ms per waveform at B = 1, 0.618 at B = 64, 0.451 at
B = 4096, against the v2 artifact's 0.356 at B = 4096, so v3 sits within
1.27x of v2. That branch is merged into main (
5594afb7) and the plan builders on this tree acceptsymmetry_mode="kernel"(compiled_module_loader.py:421,:514). Before it, the shipping route refused the v3 artifacts' kernel mode in both plan builders and fell back to per-waveform NumPy regression, which is the 18x anti-scaling the earlier campaign measured and which must not be quoted as v3 throughput. The paper review's re-read of the same campaign at 127 threads gives 0.389 ms per waveform at B = 4096. - A 120-thread cliff remains: v3 goes from 0.939 to 5.183 ms per waveform between 60 and 120 threads at B = 64 where v2 is flat, and it hits the Python fallback too. Run the batched arm at about 60 threads, not 128.
- Batched gradients, host-parallel route, 8192 samples, full span:
446.9 ms serial, 11.70 ms per waveform at B = 1024 on 60 processes. With the
compiled merger-ringdown gradient kernel and the compiled theta stage,
6.894 ms per waveform at B = 1024 on 60 threads, 1.697x over
host-parallel, at which point the merger-ringdown arm is 2.2 percent of
the route and the inspiral arm 97.8 percent. Process guidance for the
host route: about 32 processes below B = 256, about 60 above B = 512, never
128; the pool is built inside every call, 1.43 s at 60 processes, and
Pool.mappipes 3.7 MB per row (checklist B6, left as is).
Timing on the ms-per-waveform convention throughout; a batched figure is amortised over the batch it names.
Threads
Importing the package sets OMP_PROC_BIND=close and OMP_PLACES=cores if
neither is already set, never OMP_NUM_THREADS
(nrhjsurrogate/__init__.py:30). The runtime reads the binding once, at
its first thread pool, so a value set after the import is silently ignored;
nrhjsurrogate.thread_binding() reads back what the runtime actually did
rather than echoing what was asked. Measured here with OMP_PROC_BIND=false
already in the environment, the import applied nothing and the readback
said false, which is the contract. NRHJ_NO_THREAD_DEFAULTS opts out.
The gain is modest and was restated once: 1.08 to 1.11x on the whole call
at 32 threads on an exclusive AMD EPYC 7742, with a demonstrated 1.3 percent
noise floor; an earlier 1.25 to 1.28x "one stage" figure is WITHDRAWN
because that run was not exclusive (INSTALL.md section 5). Two traps: a
forked worker pool under the default binding pins EVERY process to core 0,
so the checked-in preamble exports OMP_PROC_BIND=false for multi-process
work, and srun without -n 1 fans a command out silently.
What each backend refuses or lacks
- kokkos,
dynamics=Trueon the result dictionary raisesdynamics output is not available on backend='kokkos', measured, rather than dropping the key.get_orbital_dynamics, the remnant and the error estimate all work on a kokkos-backed model, measured, because they are host-side. - NumPy, adiabatic-angle derivatives:
NRHJSurAdA.modes_gradraisesImportErrorwithout the module, measured, andstrain_gradandtime_derivativeare always kernel-fused (api.py:699-716). The paper's compiled-only performance claims were flagged for exactly this. The install-verification suite records that check as SKIP on a NumPy-only install (install-verification.md). - The compiled merger-ringdown gradient stage is opt-in and the
action-angle scalar
modes_gradtakes it when a module is present, so the gradient arrays are the ones a parity baseline sees move between backends. - The normaliser guard. A compiled module is stamped with the
post-Newtonian normaliser it was built for and a stamped artifact is
refused through a module that does not match or does not say
(
compiled_module_loader.py:273,assert_normaliser_matches); the hole it closes was a stale.soserving a silent 1.5e-2 error on (3, 3). - The source guard is broken.
assert_module_matches_source(compiled_module_loader.py:188), the measurement preflight that ties a binary to a source commit, raisesNameError: name 'os' is not definedwhen called (measured directly). Until it is fixed a published timing cannot use it to prove which build it ran on; this page names the build's md5 and provenance commit by hand instead. safeis a compound of two levers,safe_elementfitsandsafe_anchor(aa_api.py:546-565).True, the default, is the canonical route: a correctly rounded anchor contraction and the bitwise Chebyshev loop.Falseselects the BLAS anchor and the fast stacked evaluation throughlibaa_fastpath.sowhen it is present, which the compiler may contract into fused multiply-adds; it is not-ffast-math, and it moves results at the roundoff class, 0 on the waveform and 1e-11 to 6.8e-10 on derivatives and error estimates (install-verification.md). The rename planned for this keyword has not happened; it is stillsafe.physical_batch(..., single_precision=True)opts into float32 device arithmetic at up to 1.9e-4 of peak strain, about 1e-8 in mismatch, for likelihood exploration and never for a gate (driver/aa_batch.py).
Batching, the shapes
- Array input on
model(...), both backends:tstays(N,)and each mode becomes(B, N), measured with B = 2. On the NumPy backend that is a loop. physical_batch/AABatch.physical: rows of(m1, m2, chi1z, chi2z [, distance, inclination, phase, t_c])on ONE shared physical grid, each row zero-padded outside its own span, the shape a batched likelihood consumes; a requested window shorter than a row's span is refused rather than truncated (driver/aa_batch.pymodule docstring, grid policy).modes_grad_batch(driver/aa_batch_gradients.py): a host-parallel fork pool over the scalar gradient chain, bitwise against it (parameter-derivatives.md).- Below-reach
f_lowin a batch on kokkos is a row loop over the scalar arm (hybridisation.md). driver/bilby_batch.pyis the batched-likelihood adapter, 1.5e-14 relative in log-likelihood against the stock serial path (ACCURACY.md).
Where the code is
| what | where |
|---|---|
| module discovery, guards | nrhjsurrogate/execution/compiled_module_loader.py:34, :136, :188, :273 |
| AA compiled evaluator | nrhjsurrogate/execution/batched_evaluator.py |
| AA physical batch | nrhjsurrogate/driver/aa_batch.py |
| AA batched gradients, device stages | nrhjsurrogate/driver/aa_batch_gradients.py, execution/aa_gradient_device.py, execution/mr_gradient_device.py |
| AdA backend selection | nrhjsurrogate/driver/api.py:204-334 |
| thread defaults | nrhjsurrogate/__init__.py, execution/_affinity.py |
| fast path and dispatch table | nrhjsurrogate/execution/fastpath.py, execution/dispatch.py |
| kernels | cpp/evaluator.cpp, cpp/gpr.cpp, cpp/mr.cpp, cpp/pack.cpp |
| user documentation | docs/USAGE.md section 9; INSTALL.md sections 2 and 5; examples/usage/09_backends.py |