Skip to content
Performance

Performance

Single-threaded and parallel timings against SciPy 1.18.1's ufuncs, including where the Go loop is slower.

The comparison is with SciPy 1.18.1’s vectorised ufuncs (C/C++, one thread) on the same 10^6 points per function.

Method

  • nd/perf_test.go (go test -run TestPerfReport -perf.report=... in nd/) starts bench/scipy_server.py once through uv and drives it over a pipe. For every function and every round it times, back to back: the SciPy call on its 10^6-point array, a plain Go loop calling the scalar function on the same points, and the nd array function (which splits the work across GOMAXPROCS goroutines).
  • The points are generated identically on both sides (lo + (hi-lo)·frac(0.618…·i), the same IEEE operations), over a representative range per function (listed in both files).
  • Rounds are interleaved function by function, so a change in the load of the host hits both sides alike; the table reports the median of the rounds. SciPy’s time includes allocating its result array, as the Go side does.
  • The host is shared and was heavily loaded (load average in the table header, on 16 cores). Absolute times are therefore inflated for both sides and the parallel column in particular understates what an idle machine gives; the single-thread ratio is the more robust number.

Results

1000000 points per call, median of 5 interleaved rounds; darwin/arm64, GOMAXPROCS=16; host load at start: 23:36 up 17 days, 10:34, 1 user, load averages: 7.67 16.16 32.74

functionSciPy (ns/elem)Go scalar loop (ns/elem)Go nd parallel (ns/elem)scalar speed-upparallel speed-up
Gamma13.445.56.40.30x2.10x
LogGamma7.026.13.90.27x1.78x
Digamma9.122.63.30.40x2.75x
Beta43.1149.820.40.29x2.11x
LogBeta24.4132.017.30.19x1.41x
Erf8.827.73.60.32x2.45x
Erfc7.935.14.40.22x1.78x
Erfcx5.519.82.70.28x2.09x
ErfInv10.145.95.90.22x1.71x
ErfcInv9.145.35.90.20x1.53x
Ndtr9.035.54.70.25x1.90x
Ndtri7.770.18.80.11x0.88x
LogNdtr9.271.29.10.13x1.01x
Expit3.827.43.90.14x0.98x
Logit5.139.65.30.13x0.95x
Log1pExp8.024.63.40.33x2.36x
Xlogy3.814.83.30.26x1.13x
Zeta15.121.53.00.70x5.09x
HurwitzZeta130.8988.7113.10.13x1.16x
Polygamma1136.0221.328.10.61x4.85x
GammaP136.5301.536.50.45x3.74x
GammaPInv469.01498.7131.40.31x3.57x
BetaInc234.83241.7305.60.07x0.77x
J012.6130.416.80.10x0.75x
Y012.9143.317.10.09x0.76x
I015.8199.925.60.08x0.62x
K015.672.89.30.21x1.67x
Jv1347.67427.2717.00.18x1.88x
Kv494.56923.1651.30.07x0.76x
EllipK9.597.412.40.10x0.77x
EllipE8.897.012.40.09x0.71x

Reading the table

  • Single thread, the price of accuracy. A plain Go loop is 1.4x to 15x slower than SciPy’s C: about 3x for the gamma and error-function families, 5–9x for the inverse normal and the logistic helpers, 10–15x for the Bessel functions and BetaInc. Almost all of it is double-double arithmetic, which is what turns SciPy’s errors of 2–1e15 ulp into about half an ulp (see accuracy.md). Zeta is the closest (1.4x).
  • Across cores, faster. The nd functions split large arrays across goroutines; on this 16-core host (lightly loaded at the start of the run) they beat SciPy for 21 of the 31 functions, typically 1.5–3x. They do not for Expit, Logit and Ndtri (0.88–0.98x, near parity) nor for J0, Y0, I0, Kv, BetaInc, EllipK, EllipE (0.62–0.77x), where the single-thread gap is larger than what the cores recover on a shared host.
  • The slow outliers. Jv, Yv, Kv (7 µs per call) run Steed’s continued fractions in double-double; HurwitzZeta (1 µs) sums up to ~0.7(s+30) Euler–Maclaurin terms with a double-double power each; BetaInc (3 µs) carries TOMS 708 with double-double prefixes. These are the first candidates for float64 fast paths where an error analysis shows the double-double is not needed.

Created on by tannevaled

Modified on by tannevaled