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, backend="numpy")    # AA default
model = NRHJSurAA.load_h5(RELEASE_AA, backend="kokkos")
model = NRHJSurAdA.load(RELEASE_ADA)                       # "auto"

"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:

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

Batching, the shapes

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