go-fft roadmap¶
go-fft/fft is a pure-Go (cgo-free) FFT library — the numpy.fft / scipy.fft
equivalent for Go, with no dependency on the native FFTW3 C library.
Motivation¶
- Ruby has no cgo-free FFT. Every Ruby option binds the FFTW3 C library, so
an embedded-Ruby runtime that wants to stay
CGO_ENABLED=0cannot use them.go-fftis the FFT backend for go-embedded-ruby (planned binding, Phase 4). gonum/dsp/fourieris pure Go but its optimized assembly is amd64-only.go-ffttargets a portable scalar core first, then SIMD kernels generated by go-asmgen across all six 64-bit Go targets (amd64, arm64, riscv64, loong64, ppc64le, s390x).
Conventions (org HARD RULES)¶
- Pure Go,
CGO_ENABLED=0, no vendoring, build from source. - 100% statement coverage, enforced in CI (gate
>= 100.0); the test suite must also exit 0, not merely report coverage. - Validated on all six 64-bit targets in CI (amd64/arm64 native, the rest under qemu-user).
- English only.
Phase 0 — correct pure-Go FFT — DONE¶
- Complex FFT/IFFT over
[]complex128, any length. - Radix-2 iterative Cooley–Tukey for power-of-two lengths (bit-reversal permutation + in-place butterfly stages).
- Bluestein's chirp-z algorithm for arbitrary
N, reducing the DFT to a power-of-two convolution. - Unnormalized forward convention; IFFT divides by
NsoIFFT(FFT(x)) ≈ x. FFTReal(x []float64) []complex128— full complex spectrum.- Edge cases: empty input, length 1; inputs are never mutated.
- Butterfly/bit-reversal loops isolated in
internal/kernelsso SIMD variants can drop in behind the same signatures. - Tests: round-trip (power-of-two and non-power-of-two), cross-check against a naive O(N²) reference DFT, impulse → flat spectrum, constant → DC spike, sinusoid → single bin spike, linearity, no-mutation.
Phase 1 — real-optimized RFFT — DONE¶
RFFT(x []float64) []complex128returns only the non-redundantN/2+1bins (numpy.fft.rfftconvention);IRFFT(spectrum, n) []float64reconstructs the length-nreal signal (numpy.fft.irfft), normalized bynsoIRFFT(RFFT(x), len(x)) ≈ x.- Even lengths use the half-length complex packing trick: pack the real
signal into a length-
N/2complex array, take one size-N/2FFT, then untangle — ~2× throughput overFFTReal. Odd lengths fall back to the full complex transform and slice the kept bins. - First benchmark suite added (
FFT/RFFT/FFTReal/IRFFTvs the naiveO(N²)DFT baseline) to anchor the Phase 4 SIMD work. - Tests: cross-check against the full complex spectrum and the naive DFT,
conjugate symmetry of the dropped half, real DC/Nyquist bins, an analytic
cosine spike, round-trip for even and odd
N, explicit Hermitian-mirror reconstruction, edge cases, and no-mutation.
Phase 2 — multi-dimensional transforms — DONE¶
FFTN(data, shape)/IFFTN(data, shape)over a flat row-major (C-order)[]complex128paired with an explicit[]intshape: the separable N-dimensional DFT, applying the 1-D FFT along each axis in turn (numpy.fft.fftn/ifftn). Forward is unnormalized; the inverse divides by the product of the transformed axis lengths soIFFTN(FFTN(x)) ≈ x. A mismatched or non-positive shape panics, mirroring numpy.FFT2(data, [2]int)/IFFT2(data, [2]int)— the 2-D specialization (numpy.fft.fft2/ifft2), a thin wrapper overFFTN.- Real 2-D variants
RFFT2(data, [2]int)/IRFFT2(spectrum, [2]int)for image-style workloads (numpy.fft.rfft2/irfft2): the real transform runs along the last axis (keepingcols/2+1bins per row), the full complex transform down the columns;IRFFT2inverts and is normalized byrows*cols. The caller passes the target shape explicitly. - None of these mutate the caller's input.
- Tests: a separable naive-DFT N-D oracle, round-trip, FFT2≡FFTN, explicit row-then-column separability, impulse→flat, constant→DC spike, linearity, RFFT2 vs the kept bins of FFT2, RFFT2/IRFFT2 round-trip, zero-padded short spectra, empty/length-1 shapes, shape-validation panics, and no-mutation.
Phase 3 — windowing and spectral helpers — DONE¶
- Symmetric window functions returning
[]float64of a given length:Hann,Hamming,Blackman(matchingnumpy.hanning/hamming/blackman),Bartlett(numpy.bartlett), and the 4-termBlackmanHarris(scipy.signal.windows.blackmanharris).n <= 0returns an empty slice,n == 1returns[1]. FFTFreq(n, d)andRFFTFreq(n, d)bin-frequency helpers matchingnumpy.fft.fftfreq/rfftfreqexactly — including the[0, …, (n-1)//2, -(n//2), …, -1] / (d·n)ordering forfftfreqand then//2+1non-negative bins forrfftfreq.PSD(x, d)one-sided power spectral density (periodogram, density scaling with the negative-half folding, matchingscipy.signal.periodogram), andSpectrogram(x, segment, overlap, window, d)which windows successive overlapping segments and returns a slice of one-sided PSD frames (STFT convention: trailing partial segments dropped).- Tests assert the EXACT numpy/scipy window and frequency values computed by hand, window symmetry, a Parseval check tying PSD back to the signal mean square (even and odd N, exercising the Nyquist-doubling branch), spectrogram framing against an independent per-segment PSD, edge cases, validation panics, and no-mutation.
Phase 4 — SIMD kernels via go-asmgen — IN PROGRESS¶
The hot kernel chosen first is the pointwise (Hadamard) complex multiply,
a[i] *= b[i] over []complex128, which drives Bluestein's spectral product.
The scalar CMulScalar is the portable correctness oracle; CMul is the stable
dispatch seam the FFT core calls; per-arch cmulSIMD wrappers (over go-asmgen
assembly) are validated bit-for-bit against the oracle.
Generated kernels — amd64 (SSE2), arm64 (NEON), s390x (vector), riscv64 (RVV), all bit-identical¶
internal/kernels/asmgen/amd64/gen.godrives go-asmgen to emitcmul_amd64.s: an SSE2 loop, onecomplex128per iteration, computing(ar·br − ai·bi, ar·bi + ai·br)withMULPD/SHUFPD/XORPD/ADDPDand a low-lane sign mask built in-register (no read-only data table). It is bit-for-bit identical to the scalar oracle because every product and the final add are separately rounded exactly as the scalar code rounds them — no fused multiply-add (GOAMD64=v1 does not fuse).internal/kernels/asmgen/arm64/gen.godrives go-asmgen to emitcmul_arm64.s: a 2-wide NEON loop processing twocomplex128per iteration.VLD2deinterleaves a pair into a vector of reals and a vector of imaginaries, the arithmetic runs two-lane, andVST2re-interleaves on store; an odd final element is handled by a scalar tail. It is bit-for-bit identical to the scalar oracle — see the fusion note below.internal/kernels/asmgen/s390x/gen.godrives go-asmgen to emitcmul_s390x.s: a 2-wide z/Architecture vector-facility loop processing twocomplex128per iteration.VLloads the pairs,VMRHG/VMRLGdeinterleave them into a vector of reals and a vector of imaginaries (big-endian lane order),VFMDB/VFMSDB/VFMADBrun the fused arithmetic two-lane, andVMRHG/VMRLG+VSTre-interleave on store; an odd final element is handled by a scalar tail. It is bit-for-bit identical to the scalar oracle on the project's only big-endian target — see the fusion note below.internal/kernels/asmgen/riscv64/gen.godrives go-asmgen to emitcmul_riscv64.s: a strip-mined RVV 1.0 loop. Each iteration asks the hardware forvl = min(remaining, VLMAX)viaVSETVLI(E64, M1), thenVLSEG2E64Vdeinterleaves a run of{re, im}pairs into a vector of reals and a vector of imaginaries, the fused arithmetic runsvl-wide (VFMUL.VV/VFNMSAC.VV/VFMACC.VV), andVSSEG2E64Vre-interleaves on store. Strip-mining folds the tail (the last iteration just gets a smallervl), so there is no separate scalar tail. It is bit-for-bit identical to the scalar oracle, hardware-validated on cfarm95 — see the fusion and soundness notes below.- The generators live in a separate Go module
(
internal/kernels/asmgen/go.mod, requiringgo-asmgen), so the FFT library itself keeps zero third-party dependencies.go generate(or the per-arch CI job) regenerates the committed.sand CI fails if it is stale.
Bit-identity decides which arches ship a kernel (correctness over coverage)¶
The contract is bit-for-bit equality with the scalar oracle, and which floating-point form the gc compiler gives the oracle differs by arch:
- amd64:
GOAMD64=v1does not fuse, so the oracle is separately-rounded multiplies + a rounded add/sub. SSE2MULPD/ADDPDreproduce that exactly → bit-identical. - arm64: FMA is baseline, so the gc compiler fuses the oracle itself
(
FMSUBDforar·br − ai·bi,FMADDDforar·bi + ai·br; verified by disassemblingCMulScalarwith-gcflags=-S). A NEON kernel is bit-identical only if it reproduces that same fusion — which it does: each product is accumulated into a zeroed register withVFMLA(adding+0.0leaves the product's rounding unchanged, matching the oracle'sFMULD), and the second term is folded in withVFMLS/VFMLA(the fused subtract/add the oracle performs). The lesson is fusion is matched, not avoided — a non-fused NEON kernel, or one whose fusion form differed, diverges by up to 1 ULP, caught by the random SIMD-vs-scalar test (Go's arm64 assembler exposes no non-fused vector multiply — there is noVFMUL— so matching the fused form is the only sound option anyway). - s390x: FMA is baseline and — uniquely among the four qemu-only targets —
the Go assembler exposes 2-wide double-precision vector FMA. The gc compiler
fuses the oracle (
FMULthenFMSUB/FMADD; verified by disassembly), and the vector-facility kernel reproduces that fusion two-lane (VFMDBfor the exactly-rounded product,VFMSDB/VFMADBfor the fused subtract/add), withVL/VMRHG/VMRLG/VSTdeinterleaving and re-interleaving the complex pairs. s390x is the project's only big-endian target, so its per-arch qemu job proving the kernel bit-identical (correct lane order included) is the highest-value validation in the suite. Ships a kernel. ✅ - riscv64: FMA is baseline, so the gc compiler fuses the oracle
(
FMULDthenFNMSUBDforar·br − ai·bi;FMULDthenFMADDDforar·bi + ai·br; verified by disassemblingCMulScalarwithGOARCH=riscv64 -gcflags=-S). The RVV 1.0 kernel reproduces that fusionvl-wide —VFMUL.VVfor each exactly-rounded product, thenVFNMSAC.VV(vd = −(vs1·vs2)+vd) andVFMACC.VV(vd = +(vs1·vs2)+vd) for the fused subtract/add — the precise RVV analogue of the arm64 NEON kernel. Crucially, the V extension is OPTIONAL on riscv64, so unlike the other three SIMD archescmulSIMDdetects V at run time (parsing/proc/cpuinfo's isa string) and calls the RVV kernel only when V is present; on a non-V CPU it falls back to scalar (callingVSETVLIthere would SIGILL). The kernel is hardware-validated bit-identical on cfarm95 (GCC Compile Farm: a SpacemiT X60, RVA22 + RVV 1.0), where the SIMD-vs-scalar test runs and passes on structured and random inputs across a length sweep. Under CI's non-Vqemu-riscv64the scalar path is taken and the SIMD test skips its RVV comparison (t.Skip("no RVV")), so CI stays green and the RVV instructions never execute there. Ships a kernel (hardware-validated). ✅ - loong64 / ppc64le: keep the validated scalar path, each for a concrete, checked reason — NOT for lack of trying:
- loong64: the Go loong64 assembler exposes LSX vector floating-point
only as unary ops (
VFSQRTD,VFRINTD,VFRECIPD, …) — there is no vector float add/multiply/FMA to build a complex product from, so a bit-identical vector kernel is not expressible. - ppc64le: the Go ppc64 assembler exposes no vector double arithmetic
(the VSX surface is loads/stores, logicals, permutes, conversions —
XVMADDDP/XVMULDP/XVADDDPare not assemblable), so likewise no bit-identical vector kernel is expressible.
Per "on n'a pas le droit de se tromper", a 1-ULP-different kernel is a correctness divergence the project rejects; partial-arch SIMD with identical results beats full-arch SIMD with a divergence.
Measured perf (honest delta)¶
On Apple-silicon arm64 the Go compiler's scalar loop reaches ~48 GB/s (L1) and
out-runs the hand-written 2-wide NEON kernel at this width (measured ~3×
slower; the VLD2/VST2 deinterleave dominates). amd64 SSE2 is similar. So even
where a kernel is bit-identical, routing the hot path through it would be a
regression. The dispatch (CMul) therefore keeps the scalar implementation on
the hot path; each SIMD kernel is retained as a per-arch-validated artifact and
the reference for future wider kernels (AVX2/AVX-512, or arm64 SVE / unrolled
NEON), where a win is plausible. Benchmarks: BenchmarkCMulScalar* vs
BenchmarkCMulSIMD*.
Split CI + coverage policy¶
CI is split into a pure-Go coverage gate and per-arch execution jobs, mirroring the go-asmgen org:
- The
testgate builds, vets, race-tests, and enforces 100% statement coverage of the portable core. It runs on amd64 (ubuntu/windows) and arm64 (macos); on each host exactly onecmulSIMDwrapper compiles and is exercised by the SIMD-vs-scalar test, so the gate reaches 100% without excluding any reachable line. - The generated
.scarries no Go statements (it cannot be statement- covered), so it is validated by the per-arch jobs:arch-native(amd64/arm64) regenerates the committed assembly and fails if stale, then vet (asmdecl cross-checksname+offset(FP)), build (cmd/asmencodes the mnemonics), and runs the suite whoseTestCMul*assert SIMD == scalar bit-for-bit;arch-qemuruns riscv64/loong64/ppc64le/s390x under qemu-user (s390x is the big-endian guard; riscv64's RVV.sis regen-checked there but runs scalar, since that qemu CPU lacks V — the RVV bit-identity proof is run on real hardware, cfarm95).
Every line is either statement-covered by the gate or executed by a per-arch job; the gate is never lowered and there is no coverage-gaming knob.
Progress¶
- amd64 (SSE2) — bit-identical SIMD kernel, validated by the native job. ✅
- arm64 (NEON) — bit-identical 2-wide SIMD kernel, validated by the native
job (reproduces the oracle's
FMSUBD/FMADDDfusion). ✅ - s390x (vector facility) — bit-identical 2-wide SIMD kernel
(
VFMDB/VFMSDB/VFMADBreproducing the oracle's fusedFMSUB/FMADD), validated by the qemu-s390x job on the project's only big-endian target. ✅ - riscv64 (RVV 1.0) — bit-identical strip-mined SIMD kernel (
VLSEG2E64V+VFMUL.VV/VFNMSAC.VV/VFMACC.VVreproducing the oracle's fusedFNMSUBD/FMADDD), with run-time V detection, hardware-validated on cfarm95 (SpacemiT X60, RVV 1.0); under CI's non-V qemu it runs scalar and the SIMD test skips. ✅ - loong64 / ppc64le — validated scalar fallback (qemu jobs); no SIMD kernel, each for a concrete checked reason (no assemblable vector double arithmetic in the Go assembler — see above). 🔒
Remaining¶
- loong64/ppc64le SIMD await Go-assembler support for vector double arithmetic (or a different, still bit-identical formulation). riscv64 RVV is done — hardware-validated on cfarm95; a future improvement would be an RVV-capable CI runner so the bit-identity proof also runs in CI (today it runs scalar there and the proof is on real hardware).
- Wider kernels (amd64 AVX2/AVX-512, arm64 SVE / unrolled NEON, multiple complex
per iteration) and a measurement to decide if/when to route
CMulthrough SIMD on the hot path. - A bit-identical vectorized butterfly (
Radix2inner loop) once the pointwise multiply is solid across arches.
Phase 5 — Ruby binding — DONE¶
- Exposed through
go-embedded-ruby as an
FFTmodule, so Ruby gets a cgo-free FFT matching thenumpy.fft/scipy.fftsurface:fft/ifft/rfft/irfft, the N-D and 2-D transforms (fftn/ifftn/fft2/ifft2), the bin-frequency helpers (fftfreq/rfftfreq), the window functions (hann/hamming/blackman/blackman_harris/bartlett), and the spectral helpers (psd/spectrogram). Spectra are returned as RubyComplexvalues. The binding required a RubyComplextype and the::scope operator, both added to go-embedded-ruby; it is differentially validated against thenumpy.fftconventions and held to the same 100%-coverage gate.