Performance¶
go-fft is benchmarked head-to-head against the implementations that matter:
- FFTW — native fftw-3.3.11 (Homebrew arm64 bottle, NEON, linked from C) — the absolute C gold standard, single-threaded.
- pocketfft via
numpy.fft2.5.0 andscipy.fft1.18.0 — the other hand-tuned C reference, single-threaded. gonum.org/v1/gonum/dsp/fourierv0.16.0 — the fairest comparison: the other pure-Go (CGO_ENABLED=0) FFT for Go. go-fft beats it at every size.
Numerical correctness is gated first: every go-fft transform is checked against
numpy.fft within rtol=1e-9, atol=1e-7 before any timing is reported, so every
number below is for a correct transform.
The full methodology, every size, GFLOP/s, the before→after tables for each optimization round, and the per-op action items live in BENCHMARKS.md in the library repo. Reproduce the whole sweep with
benchmarks/run.sh(go-fft + gonum viago test -bench, native FFTW via a C harness, numpy/scipy via Python; correctness-gated; gonum is isolated in a separatebenchmarks/module so the library stays dependency-free).
Method¶
- Machine: Apple M4 Max (16-core: 12P+4E), macOS 26.5, arm64.
- Toolchains: go1.26.4 darwin/arm64; native FFTW fftw-3.3.11; numpy 2.5.0 / scipy 1.18.0 (pocketfft); pyfftw 0.15.1; gonum v0.16.0.
- Single-threaded for the apples-to-apples core comparison: FFTW planned with
threads=1; numpy/scipy pinned to one thread (OMP_NUM_THREADS=1…,workers=1); Go benchmarks are single-goroutine for 1-D. The 2-D rows are the one place go-fft uses its multicore path (the others are all 1-core). - Plan reuse / steady state: go-fft via its cached
PlanAPI (NewPlan(n).FFT,NewRealPlan(n).RFFT); gonum via its reused object; FFTW via a reusedFFTW_MEASUREplan; scipy via its internal plan cache. Each number is the steady-state transform cost, not planning. - ns/op (lower is better); ratio = go-fft ÷ FFTW (≤ 1.05 = parity). GFLOP/s uses
the standard
5·N·log2(N)convention (real rfft counted at half; 2-D at total points).
Algorithm¶
go-fft uses a split-radix kernel for powers of two (≈⅓ fewer real multiplies
than radix-4), mixed-radix Cooley–Tukey for the other highly-composite lengths
(radix-2/3/4/5 straight-line butterflies plus a general radix-p kernel for small
primes), Rader's algorithm for primes (from N=700) and Bluestein's chirp-z
otherwise, with all twiddle factors cached per length.
Complex 1-D FFT (complex128)¶
ns/op (GFLOP/s). Ratio = go-fft ÷ FFTW (lower is better; ≤ 1.05 = parity).
| N | go-fft | FFTW | numpy.fft | scipy.fft | gonum | go/FFTW | verdict |
|---|---|---|---|---|---|---|---|
| 256 (2⁸) | 716 (14.3) | 419 (24.4) | 2 828 | 2 302 | 3 272 | 1.71× | lags FFTW 1.71× |
| 1 024 (2¹⁰) | 3 395 (15.1) | 2 127 (24.1) | 5 678 | 4 470 | 20 066 | 1.60× | lags FFTW 1.60× |
| 4 096 (2¹²) | 16 383 (15.0) | 11 064 (22.2) | 20 359 | 16 148 | 80 164 | 1.48× | lags FFTW 1.48× |
| 65 536 (2¹⁶) | 377 784 (13.9) | 322 850 (16.2) | 554 088 | 499 500 | 1 901 025 | 1.17× | lags FFTW 1.17× |
| 1 048 576 (2²⁰) | 13 355 742 (7.9) | 8 171 156 (12.8) | 11 044 400 | 8 040 680 | 41 139 865 | 1.63× | lags FFTW 1.63× |
| 1 000 (2³·5³) | 5 850 (8.5) | 2 478 (20.1) | 6 075 | 4 961 | 16 541 | 2.36× | lags FFTW 2.36× |
| 1 080 (2³·3³·5) | 6 802 (8.0) | 2 466 (22.1) | 6 799 | 5 266 | 21 183 | 2.76× | lags FFTW 2.76× |
| 1 920 (2⁷·3·5) | 12 516 (8.4) | 4 340 (24.1) | 11 349 | 9 059 | 36 810 | 2.88× | lags FFTW 2.88× |
| 1 009 (prime) | 17 214 (2.9) | 13 668 (3.7) | 31 560 | 19 004 | 563 199 | 1.26× | lags FFTW 1.26× |
| 1 296 (2⁴·3⁴) | 9 818 (6.8) | 2 998 (22.4) | 8 063 | 6 364 | 25 328 | 3.28× | lags FFTW 3.28× |
| 10 007 (prime) | 369 217 (1.8) | 171 553 (3.9) | 321 309 | 276 031 | 75 674 509 | 2.15× | lags FFTW 2.15× |
Real 1-D RFFT (float64 → complex128, N/2+1 bins)¶
| N | go-fft | FFTW | numpy.rfft | scipy.rfft | gonum | go/FFTW | verdict |
|---|---|---|---|---|---|---|---|
| 256 (2⁸) | 525 (9.8) | 220 (23.3) | 2 432 | 2 422 | 1 882 | 2.39× | lags FFTW 2.39× |
| 1 024 (2¹⁰) | 2 289 (11.2) | 1 000 (25.6) | 4 180 | 3 726 | 8 998 | 2.29× | lags FFTW 2.29× |
| 4 096 (2¹²) | 10 320 (11.9) | 4 890 (25.1) | 11 742 | 10 416 | 39 264 | 2.11× | lags FFTW 2.11× |
| 65 536 (2¹⁶) | 230 167 (11.4) | 143 040 (18.3) | 230 189 | 254 964 | 896 439 | 1.61× | lags FFTW 1.61× |
| 1 048 576 (2²⁰) | 4 465 650 (11.7) | 2 958 859 (17.7) | 4 392 782 | 6 365 402 | 18 259 818 | 1.51× | lags FFTW 1.51× |
| 1 000 (2³·5³) | 3 144 (7.9) | 1 176 (21.2) | 4 289 | 3 663 | 8 604 | 2.67× | lags FFTW 2.67× |
| 1 080 (2³·3³·5) | 3 616 (7.5) | 1 131 (24.1) | 4 426 | 4 196 | 9 933 | 3.20× | lags FFTW 3.20× |
| 1 920 (2⁷·3·5) | 6 555 (8.0) | 2 189 (23.9) | 6 188 | 5 657 | 17 156 | 2.99× | lags FFTW 2.99× |
The forward r2c packs N reals into an N/2-point complex FFT and untangles. The pack/untangle round made that pass allocation-free (a pooled working set, 0 alloc/op steady-state on the power-of-two path) and branchless/vectorizable, cutting the forward mid-range time 1.5–1.65× and the FFTW gap from ~3.3–3.9× down to ~2.1–2.4×. The fused-pack round then folded the real-pair packing into the iterative kernel's bit-reversal gather, removing one O(N) pass and the intermediate buffer (~1.2–1.7× faster on the pack/gather portion at N=256…4096, bit-identical output). A full real split-radix kernel (Sorensen RVFFT) was implemented, validated bit-exact, benchmarked, and reverted (a 2–10× regression — a scalar real-split-radix abandons the SIMD, cache-friendly complex engine the packed path reuses). The residual forward gap is FFTW's codelet-scheduled Hermitian real kernel.
Real inverse 1-D IRFFT (complex128 N/2+1 bins → float64)¶
The c2r inverse now mirrors the forward packing: it reverses the untangle to recover the N/2-point packed spectrum, runs one N/2-point inverse complex FFT, and unpacks — half the arithmetic and memory traffic of the previous full length-N conjugate-symmetric inverse. ns/op (GFLOP/s). Ratio = go-fft ÷ FFTW.
| N | go-fft | FFTW (c2r) | go/FFTW | verdict |
|---|---|---|---|---|
| 256 (2⁸) | 797 (6.4) | 253 (20.2) | 3.15× | lags FFTW 3.15× |
| 1 024 (2¹⁰) | 3 517 (7.3) | 1 184 (21.6) | 2.97× | lags FFTW 2.97× |
| 4 096 (2¹²) | 16 749 (7.3) | 5 526 (22.2) | 3.03× | lags FFTW 3.03× |
| 65 536 (2¹⁶) | 351 731 (7.5) | 155 132 (16.9) | 2.27× | lags FFTW 2.27× |
| 1 048 576 (2²⁰) | 5 062 003 (10.4) | 3 329 422 (15.7) | 1.52× | lags FFTW 1.52× |
| 1 000 (2³·5³) | 4 659 (5.3) | 1 220 (20.4) | 3.82× | lags FFTW 3.82× |
| 1 080 (2³·3³·5) | 4 770 (5.7) | 1 341 (20.3) | 3.56× | lags FFTW 3.56× |
| 1 920 (2⁷·3·5) | 8 786 (5.5) | 2 253 (23.0) | 3.90× | lags FFTW 3.90× |
The real-inverse round replaced the old full-length conjugate-symmetric
inverse (≈2× the work the real symmetry requires) with this half-length packed
inverse, which roughly halves the c2r time at every size (1.74–2.66×, the
real-symmetry 2× realized) and cut the FFTW c2r gap from ~3.6–8.3× down to
~1.5–3.9×. Correctness is held: IRFFT(RFFT(x), len(x)) ≈ x round-trips, the
DC/Nyquist bins reconstruct exactly, and odd N still routes to the full
conjugate-mirror inverse.
2-D complex FFT2 (complex128, multicore)¶
go-fft fans the independent 1-D row/column transforms across goroutines above a work-size threshold; FFTW / numpy / scipy are single-threaded here. This is where a pure-Go library decisively beats single-threaded C. ns/op (GFLOP/s).
| shape | go-fft | FFTW | numpy.fft2 | scipy.fft2 | go/FFTW | verdict |
|---|---|---|---|---|---|---|
| 64×64 | 29 022 (8.5) | 9 064 (27.1) | 19 895 | 14 005 | 3.20× | lags FFTW 3.20× (below parallel threshold) |
| 128×128 | 78 984 (14.5) | 56 858 (20.2) | 76 859 | 64 707 | 1.39× | lags FFTW 1.39× |
| 256×256 | 208 437 (25.2) | 271 098 (19.3) | 359 158 | 207 601 | 0.77× | ≥ parity |
| 512×512 | 633 920 (37.2) | 1 247 215 (18.9) | 1 697 966 | 964 884 | 0.51× | beats all |
| 1024×1024 | 2 784 623 (37.7) | 6 301 688 (16.6) | 9 211 551 | 5 315 022 | 0.44× | beats all |
Plan / setup cost (built once, then amortized)¶
Steady-state transforms above reuse a plan. This is the one-time construction
cost (ns). go-fft and gonum build twiddle tables in Go; FFTW's FFTW_MEASURE
times trial transforms to pick codelets, so its planning is orders of magnitude
more expensive — the price of its steady-state speed, paid back only across many
reuses.
| N | go-fft NewPlan | gonum NewCmplxFFT | FFTW FFTW_MEASURE |
|---|---|---|---|
| 256 | 3 762 | 2 473 | 1 685 000 |
| 1 024 | 14 808 | 9 285 | 3 434 000 |
| 4 096 | 60 228 | 32 714 | 11 604 000 |
| 65 536 | 1 094 262 | 464 499 | 353 280 000 |
| 1 048 576 | 19 298 901 | 6 429 831 | 3 860 636 000 |
| 1 000 | 7 184 | 8 926 | 5 765 000 |
| 1 080 | 7 460 | 9 917 | 14 182 000 |
| 1 920 | 13 310 | 17 266 | 25 377 000 |
| 1 009 | 34 376 | 10 674 | 33 948 000 |
| 1 296 | 8 928 | 11 556 | 11 156 000 |
| 10 007 | 993 365 | 100 654 | 189 451 000 |
Honest read¶
vs gonum (the fair pure-Go, CGO=0 peer): go-fft is faster at every size measured — typically 3–5× on composite N and an order of magnitude on primes (gonum falls back to a naive Bluestein with no Rader path; up to ~59× faster on primes). go-fft is the fastest pure-Go FFT here.
vs pocketfft (numpy/scipy): go-fft wins the small-N rows outright (the Python FFI tax dominates pocketfft's C kernel there) and is competitive-to-winning at large 1-D and large 2-D; the residual losses are the mid-range single-core bands.
vs FFTW (the C gold standard): go-fft wins outright at the large 2-D multicore shapes (256×256 at parity, 512×512 and 1024×1024 beating all), and is within ~1.2–1.6× of FFTW on the large-prime rows relative to FFTW's own cost. FFTW still leads the single-core power-of-two and smooth-composite mid-range — it has hand-written SIMD codelets and a dedicated Hermitian real kernel. go-fft's butterflies are now SIMD too (routed SSE2 on amd64 — 1.34–1.43× faster than the scalar stage there; gc-autovectorized NEON on arm64), but FFTW's codelet generator and r2c real kernel remain ahead on one core. The framing is honest: mixed-radix + plans beats gonum everywhere and is comparable to FFTW at scale.
Lagging ops — root cause + action items¶
- Power-of-two & smooth-composite mid-range (256 … 4096, 1000/1080/1296/1920).
Root cause: scalar Go butterflies vs FFTW's hand-written NEON SIMD codelets.
Action taken (SIMD-butterfly round): the radix-2/radix-4 butterfly inner
loops were lifted into stage-granularity kernels behind a stable seam, with
go-asmgen generating a routed SSE2 stage kernel on amd64 (measured
1.34–1.43× faster than the scalar stage there). On arm64/s390x the Go
assembler exposes vector float only as the FMA family (no vector
VFADD/VFSUB), so a hand kernel only ties the gc autovectorizer, which already extracts the NEON throughput from the simple loop — so off amd64 the hot path stays the autovectorized Go loop. The remaining gap to FFTW on arm64 is its real-FFT kernel and codelet scheduling (items 2–3), not raw complex-mul SIMD. - Real mid-range (1024 … 65536). Root cause: go-fft packs a real signal into a half-length complex FFT and untangles once; FFTW runs a dedicated real (r2c) kernel exploiting Hermitian symmetry at every stage (~2× less arithmetic). Action taken: the real-inverse round packed the c2r inverse symmetrically (reverse-untangle → N/2-point inverse FFT → unpack), roughly halving the c2r time at every size and cutting the FFTW c2r gap from ~3.6–8.3× to ~1.5–3.9×; the pack/untangle round made the forward r2c pass allocation-free and branchless, cutting the forward mid-range 1.5–1.65× and the FFTW gap from ~3.3–3.9× to ~2.1–2.4×; the fused-pack round banked a further ~1.2–1.7× on the pack/gather portion. The deferred native real split-radix kernel (Sorensen RVFFT) was implemented, validated bit-exact, benchmarked, and reverted (2–10× regression). The residual needs a SIMD, cache-blocked real kernel, not a scalar real-FFT port.
- Large primes (1009, 10007). Root cause: Rader/Bluestein convolve at length ≈N−1 on the recursive mixed-radix engine (~1.5× recursion tax); FFTW convolves at exactly N−1 with codelet-fused mixed-radix. go-fft is already 2–2.5× faster than gonum here and within ~1.6× of FFTW. Action: an iterative mixed-radix engine for the smooth convolution lengths (a substantial new engine, lower priority than items 1–2).
- Small 2-D (64×64, 128×128). Root cause: below the parallel threshold, so they run the serial per-line path while FFTW uses fused 2-D codelets. Action: SIMD (item 1) lifts the per-line 1-D cost; the multicore path already makes go-fft win at 512×512 and 1024×1024.
The unifying lever is item 1 (go-asmgen SIMD butterflies): it attacks the pow2/composite mid-range, the per-line cost inside 2-D, and feeds the real and prime paths (which both convolve via complex FFTs).