
{"id":2192,"date":"2026-07-25T16:31:35","date_gmt":"2026-07-25T14:31:35","guid":{"rendered":"https:\/\/www.pcjv.de\/?p=2192"},"modified":"2026-07-25T16:31:35","modified_gmt":"2026-07-25T14:31:35","slug":"fast-fourier-transforms-at-awkward-lengths","status":"publish","type":"post","link":"https:\/\/www.pcjv.de\/?p=2192","title":{"rendered":"Fast Fourier transforms at awkward lengths"},"content":{"rendered":"\n\n<p class=\"wp-block-paragraph\">Every FFT library is fast at 1024. The interesting question is what happens at 1009.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/github.com\/CWBudde\/algo-fft\" target=\"_blank\" rel=\"noopener\">algo-fft<\/a> \u2014 the FFT library in my <a href=\"\/?page_id=2036\">algo-* suite<\/a> \u2014 is at <code>v0.7.3<\/code>. This post is a full measurement of where it stands: against FFTW3, against the other Go FFT libraries, at powers of two and at the lengths that are actually awkward, with and without its assembly.<\/p>\n\n\n\n<!--more-->\n\n\n\n<h2 class=\"wp-block-heading\">How this was measured<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The numbers come from <a href=\"https:\/\/github.com\/CWBudde\/go-fft-bench\" target=\"_blank\" rel=\"noopener\">go-fft-bench<\/a>, a harness that runs five FFT libraries over the same lengths in one process:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>algo-fft<\/strong> \u2014 this library, both precisions.<\/li>\n<li><strong>FFTW3<\/strong>, through cgo bindings. The reference implementation of &#8220;fast&#8221;, and the baseline everything else is divided by. It is C with generated codelets and decades of tuning; the distance to it is the honest measure.<\/li>\n<li><strong>gonum<\/strong> (<code>dsp\/fourier<\/code>) \u2014 the pure-Go library most Go projects actually reach for.<\/li>\n<li><strong>go-dsp<\/strong> \u2014 pure Go, arbitrary lengths via Bluestein, no reusable plan.<\/li>\n<li><strong>takatoh\/fft<\/strong> \u2014 a compact radix-2 implementation, power-of-two only.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Five methodological points, because a benchmark you cannot criticise is a benchmark you cannot trust:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Steady state, plan reused.<\/strong> Every library that has a plan object gets one, built outside the timing loop. go-dsp and takatoh have no such API and allocate on every call \u2014 that is their design, and it shows up as a fixed tax rather than as a measurement artefact.<\/li>\n<li><strong>Libraries are interleaved per length<\/strong>, not run in five long blocks. This is a 15 W laptop part that throttles under sustained load, so absolute numbers drift downward over a sweep. Interleaving puts that drift on every library at a given length equally, which is what makes the <em>ratios<\/em> trustworthy even where the absolute figures are a laptop&#8217;s.<\/li>\n<li><strong>The runner refuses to start on a busy machine.<\/strong> It reads the load average and declines above a threshold derived from the core count. Nothing downstream can distinguish a throttled result from a slow library, so the check has to happen before the run rather than in a caveat afterwards.<\/li>\n<li><strong>Correctness is a precondition, not a footnote.<\/strong> Every non-power-of-two length is cross-checked against gonum&#8217;s transform of the same input before it is allowed into the benchmark. A fast wrong answer is not a result, and this is exactly where FFT libraries quietly fail: hand a radix-2 implementation n = 1000 and it will cheerfully return something.<\/li>\n<li><strong>The y-axis on the throughput charts is pseudo-MFLOPS<\/strong>, the <code>5\u00b7n\u00b7log&#8322;n \/ t<\/code> convention from the FFTW benchmarks. Nobody executes exactly that many operations. Its only job is to divide out the O(n log n) growth so that n = 8 and n = 32768 can share an axis: a flat curve means &#8220;scaling as the algorithm says it should&#8221;, and a dip means a length fell off a fast path.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Everything below is one machine \u2014 an i7-1255U, Go 1.26.1, Linux, <code>GOAMD64=v3<\/code> \u2014 and one machine is not a benchmark suite. Treat the ratios as real and the absolute figures as indicative.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Powers of two<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1100\" height=\"660\" src=\"\/wordpress\/wp-content\/uploads\/algo-fft-throughput-pow2.png\" alt=\"Throughput of five FFT libraries at power-of-two lengths\" class=\"wp-image-2191\" srcset=\"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-throughput-pow2.png 1100w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-throughput-pow2-300x180.png 300w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-throughput-pow2-1024x614.png 1024w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-throughput-pow2-768x461.png 768w\" sizes=\"auto, (max-width: 1100px) 100vw, 1100px\" \/><figcaption class=\"wp-element-caption\">Complex128 forward transforms, powers of two. Higher is better.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two groups, and the gap between them is the whole story of writing numerical code in Go. algo-fft and FFTW3 are in one; gonum, go-dsp and takatoh are in the other, a factor of ten to fifty below. As a ratio against FFTW3 across all thirteen lengths, geometric mean: <strong>algo-fft 1.37\u00d7, gonum 0.09\u00d7, go-dsp 0.02\u00d7, takatoh 0.03\u00d7<\/strong>.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1100\" height=\"660\" src=\"\/wordpress\/wp-content\/uploads\/algo-fft-speedup-vs-fftw.png\" alt=\"Speed of each Go FFT library relative to FFTW3\" class=\"wp-image-2190\" srcset=\"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-speedup-vs-fftw.png 1100w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-speedup-vs-fftw-300x180.png 300w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-speedup-vs-fftw-1024x614.png 1024w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-speedup-vs-fftw-768x461.png 768w\" sizes=\"auto, (max-width: 1100px) 100vw, 1100px\" \/><figcaption class=\"wp-element-caption\">The same data as a ratio against FFTW3. Note the log scale: the lower three libraries are one to two orders of magnitude down, not a few lines down.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">algo-fft is at or above FFTW3 across the whole power-of-two ladder. It wins by a lot below n = 64 \u2014 <strong>5.3\u00d7 at n = 8<\/strong>, where the transform is 6 ns and FFTW3&#8217;s 34 ns is mostly the cost of dispatching through a plan at all \u2014 and then tracks it within about \u00b110% from 128 upward, dipping to 0.91\u00d7 at n = 1024 and rising to 1.27\u00d7 at 8192.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Against the rest of the Go field it was never close. At n = 1024, one forward complex128 transform costs <strong>1.35 \u00b5s in algo-fft, 22.0 \u00b5s in gonum, 45.0 \u00b5s in go-dsp and 76.9 \u00b5s in takatoh<\/strong>, against FFTW3&#8217;s 1.24 \u00b5s.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What the assembly buys<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">algo-fft ships 229 registered codelets \u2014 kernels bound to one specific transform length, so that a length like 1024 runs straight into a routine that knows it is 1024 and skips every dispatch decision. They are distributed unevenly on purpose:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Tier<\/th><th>Build<\/th><th>complex64<\/th><th>complex128<\/th><\/tr><\/thead><tbody>\n<tr><td>generic Go<\/td><td>every build<\/td><td>32<\/td><td>32<\/td><\/tr>\n<tr><td>AVX2<\/td><td>amd64, default<\/td><td>33<\/td><td>27<\/td><\/tr>\n<tr><td>SSE3 \/ SSE2<\/td><td>amd64, default<\/td><td>22<\/td><td>22<\/td><\/tr>\n<tr><td>NEON<\/td><td>arm64, default<\/td><td>22<\/td><td>22<\/td><\/tr>\n<tr><td>AVX-512<\/td><td>amd64, default<\/td><td>11<\/td><td>6<\/td><\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The <strong>SSE tier is split by precision<\/strong>: SSE3 carries complex64 and SSE2 carries complex128, because the complex64 multiply idiom wants <code>ADDSUBPS<\/code>, which is SSE3. Machines with SSE2 but not SSE3 stopped being made around 2005, so they keep the generic Go path, deliberately. The <strong>NEON ladder is complete but untuned<\/strong> \u2014 every power of two from 4 to 32768 has an arm64 codelet, verified for correctness under emulation, with priorities mirrored from the amd64 ordering rather than measured, because QEMU timings are not performance data. And the <strong>AVX-512 row reaches only n = 128 in double precision<\/strong>, because the machine everything here was measured on has no AVX-512 at all; that tier lives on a Xeon I can borrow at weekends, which is not a tuning loop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two things that are <em>not<\/em> switches, since both get assumed. There is no <code>asm<\/code> build tag \u2014 SIMD is part of the default build, selected at runtime by CPU feature detection. And <code>GOAMD64<\/code> gates none of it: that variable controls what Go&#8217;s own compiler emits for the portable code, not which hand-written kernel the registry hands you. The single opt-out is <code>-tags purego<\/code>, which removes every assembly package and every SIMD codelet registrar and leaves the portable engine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Which makes &#8220;how much of this is algorithms and how much is assembly?&#8221; a question you can answer by building twice.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1100\" height=\"660\" src=\"\/wordpress\/wp-content\/uploads\/algo-fft-simd-vs-purego.png\" alt=\"Speedup of the default SIMD build over the pure-Go build\" class=\"wp-image-2189\" srcset=\"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-simd-vs-purego.png 1100w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-simd-vs-purego-300x180.png 300w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-simd-vs-purego-1024x614.png 1024w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-simd-vs-purego-768x461.png 768w\" sizes=\"auto, (max-width: 1100px) 100vw, 1100px\" \/><figcaption class=\"wp-element-caption\">The default build divided by the same code compiled with -tags purego.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The assembly is worth <strong>2.95\u00d7 on geometric mean for complex128<\/strong> and <strong>4.78\u00d7 for complex64<\/strong>, peaking at 8.15\u00d7 at n = 256. Single precision gains more because a 256-bit register holds eight complex64 lanes against four complex128, and because the complex64 codelet ladder is the more complete of the two. Arbitrary lengths give <strong>2.45\u00d7<\/strong>, with a few where it buys nothing at all \u2014 more on those below.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1100\" height=\"660\" src=\"\/wordpress\/wp-content\/uploads\/algo-fft-purego-vs-competitors.png\" alt=\"The pure-Go build compared against the other Go FFT libraries\" class=\"wp-image-2188\" srcset=\"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-purego-vs-competitors.png 1100w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-purego-vs-competitors-300x180.png 300w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-purego-vs-competitors-1024x614.png 1024w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-purego-vs-competitors-768x461.png 768w\" sizes=\"auto, (max-width: 1100px) 100vw, 1100px\" \/><figcaption class=\"wp-element-caption\">With every hand-written kernel removed, against the other pure-Go libraries.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Strip the assembly out entirely and algo-fft is still <strong>4.97\u00d7 ahead of gonum<\/strong>, 19.0\u00d7 ahead of go-dsp and 14.3\u00d7 ahead of takatoh, and it does not lose at any length \u2014 the narrowest margin is 3.35\u00d7 at n = 16384. That surprised me, because the plan machinery and the strategy ladder are the parts of this library I have spent the least time defending.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A note on what &#8220;AVX2&#8221; is worth<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">One caveat on those figures, because the label is easy to trust too far. algo-fft&#8217;s power-of-two workhorses used to be per-size AVX2 radix-4 codelets: VEX-encoded, dispatched only on machines reporting AVX2 \u2014 and loading their operands with <code>VMOVSD<\/code>, 64 bits at a time, with every butterfly done in <code>X<\/code> registers. They were scalar kernels in VEX clothing, with the three-operand encoding and none of the width. A grep settles it: YMM arithmetic across the whole family came to <strong>0\u20132 instructions per file<\/strong>, against <strong>100\u2013560<\/strong> for the kernels that are genuinely vectorized.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Replacing them with one size-generic kernel per precision \u2014 covering 16 to 65536, further than any per-size codelet reached \u2014 was worth 2.2\u00d7 to 4.0\u00d7. The surprise inside that work was where the time had been going: at n = 16384 the bit-reversal permutation, which does no arithmetic whatsoever, was a third of the kernel&#8217;s runtime. So &#8220;has an AVX2 codelet&#8221; and &#8220;is fast&#8221; turn out to be almost independent claims.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Awkward lengths<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A transform length is easy or hard depending on one thing: whether it factors into radices you have butterflies for. algo-fft has three answers, and choosing between them is most of the planner&#8217;s job.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The mixed-radix engine<\/strong> factors n into 2, 3, 5, 7 and 11 and runs it exactly. Nothing is padded, nothing is wasted.<\/li>\n<li><strong>Rader&#8217;s algorithm<\/strong> turns a prime-length transform into a cyclic convolution of length p&minus;1. A good trade only when p&minus;1 itself factors nicely, which for any given prime is a coin flip you can check at plan time.<\/li>\n<li><strong>Bluestein&#8217;s algorithm<\/strong> handles anything at all by re-expressing the DFT as a convolution and padding to a convenient length of at least 2n&minus;1. It is the universal fallback, and it costs roughly three transforms of a <em>larger<\/em> length to do the work of one.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Neither Rader nor mixed-radix is a strategy you can ask for by name \u2014 both are decisions the planner makes underneath the Bluestein plumbing, each gated by a measured win condition rather than by the mathematical test alone. You can ask a plan what it decided, which is how the benchmark labels its own results:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">    97  Bluestein  rader          1153  Bluestein  rader\n   257  Bluestein  rader          1009  Bluestein  bluestein\n   448  DIT        dit_fallback   2205  Stockham   stockham\n  1152  Stockham   stockham      44100  Stockham   stockham<\/pre>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1100\" height=\"660\" src=\"\/wordpress\/wp-content\/uploads\/algo-fft-nonpow2-by-class.png\" alt=\"Geometric mean speed against FFTW3 for each class of non-power-of-two length\" class=\"wp-image-2185\" srcset=\"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-nonpow2-by-class.png 1100w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-nonpow2-by-class-300x180.png 300w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-nonpow2-by-class-1024x614.png 1024w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-nonpow2-by-class-768x461.png 768w\" sizes=\"auto, (max-width: 1100px) 100vw, 1100px\" \/><figcaption class=\"wp-element-caption\">Non-power-of-two lengths, grouped by the property that decides which algorithm can be used.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This chart is the most useful thing in the post, and it is backwards from the naive expectation. <strong>The class where algo-fft does best is the primes<\/strong> \u2014 1.05\u00d7 of FFTW3 for primes with smooth p&minus;1, which is to say it wins that class outright \u2014 against 0.38\u00d7 for 5-smooth lengths and 0.48\u00d7 for the 7\/11-smooth ones. The composite lengths ought to be the comfortable case and the primes the hard one. They are not.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1400\" height=\"700\" src=\"\/wordpress\/wp-content\/uploads\/algo-fft-nonpow2-detail.png\" alt=\"Every non-power-of-two length benchmarked, coloured by class\" class=\"wp-image-2186\" srcset=\"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-nonpow2-detail.png 1400w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-nonpow2-detail-300x150.png 300w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-nonpow2-detail-1024x512.png 1024w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-nonpow2-detail-768x384.png 768w\" sizes=\"auto, (max-width: 1400px) 100vw, 1400px\" \/><figcaption class=\"wp-element-caption\">Every length measured. The purple block is Rader&#8217;s algorithm.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Per length, Rader reaches <strong>1.58\u00d7 of FFTW3 at n = 12289<\/strong>, 1.33\u00d7 at 641 and 1.06\u00d7 at 4001 \u2014 for prime-length transforms, in Go. The composite side runs 0.2\u00d7 to 0.6\u00d7, and the worst single result in the sweep is n = 2205 at 0.16\u00d7.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three lengths \u2014 257, 1009 and 2003 \u2014 are where the SIMD build has no advantage at all over the pure-Go one (0.94\u00d7, 1.02\u00d7, 0.95\u00d7). All three route through Bluestein or Rader, whose padded sub-transforms <em>are<\/em> powers of two and therefore run in the fastest assembly in the library. Since the totals do not benefit, the time cannot be in the sub-transforms; it is in the chirp construction and the pointwise products around them, which are still ordinary scalar Go. That is a concrete lead rather than a mystery, and it is the next thing on that path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The one length I want to call out by name is <strong>44100<\/strong>, the canonical audio sample rate. It is 2\u00b2\u00b73\u00b2\u00b75\u00b2\u00b77\u00b2, it factors perfectly, and it takes 745 \u00b5s \u2014 0.25\u00d7 of FFTW3, and 3.1\u00d7 faster than gonum. Not a victory, but the routing is finally right: a gate in the planner used to exclude lengths whose power-of-two part is only 2 or 4 from the mixed-radix engine, sending 44100 to Bluestein instead. That gate had a measurement behind it, and the measurement was real; what it had actually measured was a defect in the mixed-radix driver, which dispatched every sub-transform larger than a single point to a codelet \u2014 including the size-2 and size-4 recursion leaves that have a perfectly good Go butterfly. A profile at n = 1000 found <strong>1.9% of runtime in the codelet assembly and roughly 40% in dispatch overhead<\/strong>. A threshold fitted to a bug is a good way to lose a length for a year.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Single versus double precision<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1100\" height=\"660\" src=\"\/wordpress\/wp-content\/uploads\/algo-fft-precision.png\" alt=\"Single versus double precision throughput in algo-fft\" class=\"wp-image-2187\" srcset=\"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-precision.png 1100w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-precision-300x180.png 300w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-precision-1024x614.png 1024w, https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/algo-fft-precision-768x461.png 768w\" sizes=\"auto, (max-width: 1100px) 100vw, 1100px\" \/><figcaption class=\"wp-element-caption\">complex64 against complex128, with FFTW3&#8217;s double-precision curve for scale.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Halving the element width does not halve the time. Single precision buys <strong>1.53\u00d7 on geometric mean<\/strong>, unevenly: 1.07\u00d7 at n = 8, where plan dispatch dominates and the element type barely matters, rising to 2.17\u00d7 at 32768, with the middle of the range around 1.4\u20131.5\u00d7. Worth knowing before you convert a pipeline to float32 on the assumption of a free doubling \u2014 on this hardware, for this workload, it is about a half.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The complex64 curve sits above FFTW3&#8217;s double-precision line at every length measured, which is the one place in this post where the comparison is not apples to apples: FFTW can be built for single precision too, and this harness does not link that build. Read the green curve against the blue one, and the orange one only as a sense of scale.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One thing worth knowing if you write numerical Go, single-precision or not: <strong>Go does not implement scalar <code>complex64 * complex64<\/code> in single precision.<\/strong> It widens all four float32 components to float64, multiplies in double precision, and rounds the two results back \u2014 twelve instructions where the complex128 multiply needs six. Any FFT stage written as plain Go therefore costs <em>more<\/em> in the narrower type, which is a genuinely surprising thing to discover in your own library and an easy one to carry for years without noticing. algo-fft now routes those products through a helper that stays in single precision; the price is 3\u20139% more relative L2 error against a float64 reference, all of it still at float32 epsilon. You are no longer getting accidental double-precision intermediates, and no longer paying for them.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What is not done<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">An honest list, because a benchmark post that only reports wins is an advertisement:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Composite lengths are the weak class.<\/strong> 0.38\u00d7 and 0.48\u00d7 of FFTW3 against Rader&#8217;s 1.05\u00d7. The mixed-radix engine is the part of the library furthest from the reference.<\/li>\n<li><strong>Bluestein&#8217;s scalar overhead is unaddressed<\/strong>, which is why the assembly buys nothing at 257, 1009 and 2003.<\/li>\n<li><strong>arm64 is unmeasured.<\/strong> The NEON codelet ladder is complete and correct, and its selection priorities are guesses copied from the amd64 ordering. It needs an afternoon on real hardware.<\/li>\n<li><strong>AVX-512 is barely used<\/strong>, and reaches only n = 128 in double precision \u2014 the development machine has none, so that tier is tuned on borrowed weekend access.<\/li>\n<li><strong>134 functions that use YMM or ZMM registers never execute <code>VZEROUPPER<\/code><\/strong>, which leaves the processor in a state where subsequent legacy-SSE instructions pay a transition penalty. The sweep that found them exists; acting on it does not.<\/li>\n<li><strong>Plan-level overhead now dominates small transforms<\/strong> \u2014 a consequence of the kernels getting faster, and a category of work I had not needed to think about before.<\/li>\n<li><strong>WebAssembly gets no SIMD<\/strong>, a toolchain limit rather than a decision: Go&#8217;s SIMD support is still experimental and amd64-first.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Reproducing it<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Everything here is a checkout and two commands:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>git clone https:\/\/github.com\/CWBudde\/go-fft-bench\ncd go-fft-bench\njust sweep    # both builds, waits for an idle machine\njust plot     # renders every chart in this post<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The charts are drawn by a small tool in that repository using <a href=\"\/?page_id=2046\">matplotlib-go<\/a>, my Go port of the Matplotlib model, which makes this post a fairly demanding test of that library as well as of this one. Log-base-2 axes, grouped bars with categorical labels and multi-series legends all came out of it without a fight. It found one bug in return, and it was mine: bars on a logarithmic axis run down to zero, and log(0) is not a coordinate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">algo-fft itself is at <a href=\"https:\/\/github.com\/CWBudde\/algo-fft\" target=\"_blank\" rel=\"noopener\">github.com\/CWBudde\/algo-fft<\/a>, and there is a <a href=\"https:\/\/cwbudde.github.io\/algo-fft\/\" target=\"_blank\" rel=\"noopener\">WebAssembly demo<\/a> if you would rather watch a transform than read about one.<\/p>\n\n","protected":false},"excerpt":{"rendered":"<p>Every FFT library is fast at 1024. The interesting question is what happens at 1009. algo-fft \u2014 the FFT library in my algo-* suite \u2014 is at v0.7.3. This post is a full measurement of where it stands: against FFTW3, against the other Go FFT libraries, at powers of two and at the lengths that [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":2193,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[4,41],"tags":[],"class_list":["post-2192","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-dsp","category-go"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.6 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Fast Fourier transforms at awkward lengths - Christian&#039;s private site<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/www.pcjv.de\/?p=2192\" \/>\n<meta name=\"twitter:label1\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data1\" content=\"12 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192\"},\"author\":{\"name\":\"\",\"@id\":\"\"},\"headline\":\"Fast Fourier transforms at awkward lengths\",\"datePublished\":\"2026-07-25T14:31:35+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192\"},\"wordCount\":2366,\"image\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.pcjv.de\\\/wordpress\\\/wp-content\\\/uploads\\\/teaser-algo-fft.jpg\",\"articleSection\":[\"DSP\",\"Go\"],\"inLanguage\":\"en-US\"},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192\",\"url\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192\",\"name\":\"Fast Fourier transforms at awkward lengths - Christian&#039;s private site\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.pcjv.de\\\/wordpress\\\/wp-content\\\/uploads\\\/teaser-algo-fft.jpg\",\"datePublished\":\"2026-07-25T14:31:35+00:00\",\"author\":{\"@id\":\"\"},\"breadcrumb\":{\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/www.pcjv.de\\\/?p=2192\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192#primaryimage\",\"url\":\"https:\\\/\\\/www.pcjv.de\\\/wordpress\\\/wp-content\\\/uploads\\\/teaser-algo-fft.jpg\",\"contentUrl\":\"https:\\\/\\\/www.pcjv.de\\\/wordpress\\\/wp-content\\\/uploads\\\/teaser-algo-fft.jpg\",\"width\":480,\"height\":360},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/?p=2192#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/www.pcjv.de\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Fast Fourier transforms at awkward lengths\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/www.pcjv.de\\\/#website\",\"url\":\"https:\\\/\\\/www.pcjv.de\\\/\",\"name\":\"Christian&#039;s private site\",\"description\":\"Private site of Christian-W. Budde\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/www.pcjv.de\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Fast Fourier transforms at awkward lengths - Christian&#039;s private site","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/www.pcjv.de\/?p=2192","twitter_misc":{"Est. reading time":"12 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/www.pcjv.de\/?p=2192#article","isPartOf":{"@id":"https:\/\/www.pcjv.de\/?p=2192"},"author":{"name":"","@id":""},"headline":"Fast Fourier transforms at awkward lengths","datePublished":"2026-07-25T14:31:35+00:00","mainEntityOfPage":{"@id":"https:\/\/www.pcjv.de\/?p=2192"},"wordCount":2366,"image":{"@id":"https:\/\/www.pcjv.de\/?p=2192#primaryimage"},"thumbnailUrl":"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/teaser-algo-fft.jpg","articleSection":["DSP","Go"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/www.pcjv.de\/?p=2192","url":"https:\/\/www.pcjv.de\/?p=2192","name":"Fast Fourier transforms at awkward lengths - Christian&#039;s private site","isPartOf":{"@id":"https:\/\/www.pcjv.de\/#website"},"primaryImageOfPage":{"@id":"https:\/\/www.pcjv.de\/?p=2192#primaryimage"},"image":{"@id":"https:\/\/www.pcjv.de\/?p=2192#primaryimage"},"thumbnailUrl":"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/teaser-algo-fft.jpg","datePublished":"2026-07-25T14:31:35+00:00","author":{"@id":""},"breadcrumb":{"@id":"https:\/\/www.pcjv.de\/?p=2192#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/www.pcjv.de\/?p=2192"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.pcjv.de\/?p=2192#primaryimage","url":"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/teaser-algo-fft.jpg","contentUrl":"https:\/\/www.pcjv.de\/wordpress\/wp-content\/uploads\/teaser-algo-fft.jpg","width":480,"height":360},{"@type":"BreadcrumbList","@id":"https:\/\/www.pcjv.de\/?p=2192#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/www.pcjv.de\/"},{"@type":"ListItem","position":2,"name":"Fast Fourier transforms at awkward lengths"}]},{"@type":"WebSite","@id":"https:\/\/www.pcjv.de\/#website","url":"https:\/\/www.pcjv.de\/","name":"Christian&#039;s private site","description":"Private site of Christian-W. Budde","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/www.pcjv.de\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"}]}},"_links":{"self":[{"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/posts\/2192","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=2192"}],"version-history":[{"count":0,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/posts\/2192\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=\/wp\/v2\/media\/2193"}],"wp:attachment":[{"href":"https:\/\/www.pcjv.de\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=2192"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=2192"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.pcjv.de\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=2192"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}