a fast CPU FFT

8 Sep 2026

The fast Fourier transform (FFT) is one of the most important algorithms in computer science. Modern telephony, image compression, signal analysis, and many other applications rely on the FFT at their core. It’s correspondingly well researched, and has been implemented many times over. I will be reimplementing it myself, (1) because it’s fun, and (2) because I’ll need an unusual version of it for my ocean water simulation. This document serves as a follow-along derivation of my optimized CPU implementation, which exceeds the performance of rustfft’s scalar code at the input sizes I care about.

Background

The FFT is just an efficient way of computing something called the “discrete Fourier transform.” What is that, and why do we care? Essentially, a Fourier transform decomposes an input “signal”, such as a sound wave or an image, into a bunch of sines and cosines. When you add those sines and cosines together, you get back the original signal. In the real world, signals are typically considered to be “continuous”, meaning they aren’t composed of blocks of a minimum size (if you ignore quantum mechanics). The classical Fourier transform deals with those kinds of signals. However, our digital lives are composed of blocks of minimum size: 1s and 0s, or bits. The discrete Fourier transform (DFT) deals with this kind of signal.

This representation is extremely useful in a number of applications. For example, to compress an image, you can simply strip away all the high-frequency components of the signal. It turns out the human eye can’t really tell, and you can make an image much, much smaller before it becomes obvious that it’s been compressed. The same goes for music, telephony, and video. I will be using it to implement a realtime ocean water simulation - it turns out that the sum-of-sines representation is actually a highly accurate way to represent the dynamics of so-called “fully developed” oceans. More on that in a followup article - for now, we focus on the FFT.

The DFT

The DFT is defined as follows1

Xk=n=0N1xne2πiNkn X_k = \sum_{n=0}^{N-1} x_n e^{-\frac{2 \pi i}{N} k n}

Let’s unpack that.

Our input signal xx is comprised of NN samples: x=[x0,x1,...xN1]x = [x_0, x_1, ... x_{N-1}]. A typical 1-second sound wave would have 44,100 samples, where each sample represents the air pressure that a microphone measured at that point in time. For that reason, we say that the input signal is in the time domain.

The DFT version of our signal, XX, is also comprised of NN samples, but we index them with kk instead: Xk=[X0,X1,...XN1]X_k = [X_0, X_1, ... X_{N-1}].

We can simplify our expression a bit to get a sense of what’s happening:

Xk=n=0N1xnWNnk X_k = \sum_{n=0}^{N-1} x_n W^{nk}_N

To get the kkth term of the DFT, we have to add up every component of the input signal multiplied by some term WNnkW^{nk}_N. Since there are NN terms in the DFT, we must do (N additions of the input signal) * (N times for the output signal). Thus this is an O(N2)O(N^2) algorithm. We will get back to this later!

The inner term, ei...e^{-i ...} might be a head scratcher. What does it mean to exponentiate by an imaginary number? Where are the sines and cosines? Well, there’s a famous formula2 from calculus which tells us that:

eit=cost+isint e^{it} = \cos{t} + i \sin{t}

(This formula drops out of the Maclaurin series for exe^x, cosx\cos x, and sinx\sin x. By rearranging terms you wind up with this identity.)

So although our expression is expressed in the form eie^{i \dots}, it is really representing a sum of sines and cosines. Neat!

Finally, there is something unintuitive to reflect on. In the real numbers, there are at most two solutions to this equation, assuming that kk is a natural number (1, 2, 3…):

1=xk 1 = x^k

If kk is even, the only solution is x=1x = 1; if kk is even, there is also the solution x=1x = -1.

In the complex numbers, we can have more than one solution. In general, for any natural number kk, there are kk solutions, and they are of the form:

1=e2πpki 1 = e^{\frac{2 \pi p}{k} i}

… where p[1,k]p \in [1, k]

(Read as “pp is an element of the range of numbers starting at 1 and ending at kk”).

For k=1k=1:

e2πi1=cos2π+isin2π=1+0=1 e^{\frac{2 \pi i}{1}} = \cos{2\pi} + i \sin{2\pi} = 1 + 0 = 1

For k=2k=2:

e2πi21=cosπ+isinπ=1+0=1e2πi22=cos2π+isin2π=1+0=1 \begin{array}{rclclcl} e^{\frac{2 \pi i}{2} 1} &=& \cos{\pi} + i \sin{\pi} &=& -1 + 0 &=& -1 \\ e^{\frac{2 \pi i}{2} 2} &=& \cos{2\pi} + i \sin{2\pi} &=& 1 + 0 &=& 1 \end{array}

So for a given kk, the set of complex numbers that satisfy the relationship 1=xk1 = x^k are called the kkth roots of unity, and there are kk of them. (“Unity” is just another word for 1.) We can visualize them as simply dividing a circle in the complex plane:

Visualizing the roots of unity for k=3.

In summary:

Implementing the DFT

The DFT is fairly straightforward to implement in code. Here it is in Rust:

# Converts a `usize` to a float.
fn usize_to_float<T: Float>(value: usize) -> T {
    num::cast(value).unwrap()
}

# Evaluates the DFT of `data`.
fn naive_dft<T: Float + FloatConst>(data: &mut [Complex<T>]) {
    let big_n = data.len();
    let mut result = vec![Complex::new(T::zero(), T::zero()); big_n];
    for k in 0..big_n {
        for n in 0..big_n {
            let k_t     = usize_to_float::<T>(k);
            let n_t     = usize_to_float::<T>(n);
            let big_n_t = usize_to_float::<T>(big_n);
            let phase = -T::TAU() * k_t * n_t / big_n_t;
            let factor = Complex::<T>::cis(phase);
            result[k] = result[k] + data[n] * factor;
        }
    }
    data.copy_from_slice(&result);
}

This is technically correct, but there are many problems with this code:

  1. The factors WNnkW_N^{nk} are recomputed each time we call this function, even though they do not change with respect to data. We should hoist that computation out.
  2. WNnkW_N^{nk}, also called twiddles, are computed with type T, which may be a low-precision float. We should compute them in high precision, then cast to low-precision at the end. Hoisting them out of this function also justifies running that computation in high precision, since it’s no longer on the hot path.
  3. We accumulate floating point adds sequentially, which accumulates more error than if we accumulated them via a binary tree.
  4. The phase calculation does several floating point multiplications and divisions in the hot path. Had we hoisted our twiddles out, we could get away with no divisons and a single multiply. More on that later.
  5. The copy at the end is expensive, and we’d like to avoid it if possible.
  6. Converting floats to ints in the hot path is not free.
  7. We allocate and initialize an array, result, on the hot path. It’s better than doing it on the heap, but it’s still slow. The allocation should be hoisted out.

We won’t be addressing those until we get into our fast Fourier transform, but I want to start pointing out the kinds of issues we need to think about. The name of the game is doing as little work as possible in the hot path.

DFT Evaluation

Let’s take a look at the DFT’s numerical accuracy and speed. We will be comparing against rust’s rustfft crate as our speed of light. We will also be using a 4096-element array of randomized elements to measure both our numeric accuracy and speed. When measuring performance, we use Criterion to minimize the effects of cache hotness, scheduling noise, etc. To measure error, we use rustfft on a 64-bit signal as our source of truth. Finally, we will disable all vectorization (AVX/SSE) when measuring performance, since our end goal is a GPU-friendly algorithm which won’t have access to those intrinsics.

The results are as follows:

Algorithm Duration Max. error Avg. error
Naive DFT 83.513 ms 0.33024592 0.00950057
rustfft 14.791 us 0.00009481 0.00000397

(Input size 4096, type f32.)

The speed-of-light implementation is not only ~5,690x faster, it’s ~2,190x more accurate in the worst case, and ~2,353x more accurate on average.

So, how are we going to bridge this gap?

The fast Fourier transform

As highlighted above, naively evaluating a DFT takes O(N2)O(N^2) time, where NN is the length of the input signal. There is an algorithm appropriately named the fast Fourier transform (FFT) which evaluates the same result in O(NlogN)O(N \log N) time. It works by dividing the input into two parts, evaluating the FFT on each part (which is now half as big), then using some clever math to efficiently combine the results. Let’s get into it.

Recall the definition of the DFT:

Xk=n=0N1xne2πiNkn X_k = \sum_{n=0}^{N-1} x_n e^{-\frac{2 \pi i}{N} k n}

We can split this by even and odd indices nn:

Xk=n=0N/21x2ne2πiNk(2n)+n=0N/21x2n+1e2πiNk(2n+1) X_k = \sum_{n=0}^{N/2-1} x_{2n} e^{-\frac{2 \pi i}{N} k (2n)} + \sum_{n=0}^{N/2-1} x_{2n+1} e^{-\frac{2 \pi i}{N} k (2n+1)}

Next, factor out e2πiNke^{-\frac{2\pi i}{N}k} from the second sum:

Xk=n=0N/21x2ne2πiNk2n+e2πiNkn=0N/21x2n+1e2πiNk2n X_k = \sum_{n=0}^{N/2-1} x_{2n} e^{-\frac{2 \pi i}{N} k 2n} + e^{-\frac{2\pi i}{N}k} \sum_{n=0}^{N/2-1} x_{2n+1} e^{-\frac{2 \pi i}{N} k 2n}

(This factoring follows from the fact that, in general, ab+1=aaba^{b+1} = a a^b.)

Inside the sum, multiply the exponent by 1/21/2\frac{1/2}{1/2}, i.e. 1:

Xk=n=0N/21x2ne2πiN/2kn+e2πiNkn=0N/21x2n+1e2πiN/2kn=Ek+e2πiNkOk \begin{align*} X_k &= \sum_{n=0}^{N/2-1} x_{2n} e^{-\frac{2 \pi i}{N/2} k n} + e^{-\frac{2\pi i}{N}k} \sum_{n=0}^{N/2-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} k n} \\ &= E_k + e^{-\frac{2\pi i}{N}k} O_k \end{align*}

Note what just happened: we have represented the kkth term of the DFT in terms of the sums of two DFT’s with half as many terms! That is the essence of how the FFT runs in O(NlogN)O(N \log N) time. The only lurking issue is that this only holds for kk in the range [0,N/2)[0, N/2). To get kk in the range [N/2,N)[N/2, N), we have to do some analysis. We will replace every instance of kk with k+N/2k+N/2, then attempt to refactor the expression to get a result that only deals with indices of kk:

Xk+N/2=n=0N21x2ne2πiN/2(k+N2)n+e2πiN(k+N2)n=0N21x2n+1e2πiN/2(k+N2)n=e2πiN/2nke2πiN/2nN2+=e2πiN/2nke2πin+=e2πiN/2nk+=+e2πiNke2πiNN2=+e2πiNkeπi=+e2πiNk(1)=+n=0N21x2n+1e2πiN/2nke2πiN/2nN2=+e2πin=+1=n=0N21x2ne2πiN/2nke2πiNkn=0N21x2n+1e2πiN/2nk \begin{array}{rcllllll} X_{k+N/2} &=& \sum_{n=0}^{\frac{N}{2}-1} x_{2n} & e^{-\frac{2 \pi i}{N/2} (k + \frac{N}{2}) n} & + & e^{-\frac{2\pi i}{N} (k + \frac{N}{2})} & \sum_{n=0}^{\frac{N}{2}-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} (k + \frac{N}{2}) n} & \\ &=& \dots & e^{-\frac{2 \pi i}{N/2} nk} e^{-\frac{2 \pi i}{N/2} n \frac{N}{2}} & + & \dots & & \\ &=& \dots & e^{-\frac{2 \pi i}{N/2} nk} e^{-2 \pi i n} & + & \dots & & \\ &=& \dots & e^{-\frac{2 \pi i}{N/2} nk} & + & \dots & \\ &=& \dots & & + & e^{-\frac{2\pi i}{N} k} e^{-\frac{2\pi i}{N}\frac{N}{2}} & \dots & \\ &=& \dots & & + & e^{-\frac{2\pi i}{N} k} e^{-\pi i} & \dots & \\ &=& \dots & & + & e^{-\frac{2\pi i}{N} k} (-1) & \dots & \\ &=& \dots & & + & \dots & \sum_{n=0}^{\frac{N}{2}-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} nk} & e^{-\frac{2 \pi i}{N/2} n\frac{N}{2}} \\ &=& \dots & & + & \dots & & e^{2 \pi i n} \\ &=& \dots & & + & \dots & & 1 \\ &=& \sum_{n=0}^{\frac{N}{2}-1} x_{2n} & e^{-\frac{2\pi i}{N/2} nk} & - & e^{-\frac{2\pi i}{N} k} & \sum_{n=0}^{\frac{N}{2}-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} n k} & \\ \end{array}

In conclusion:

Xk=Ek+WNKOkXk+N/2=EkWNKOk \begin{align*} X_k &= E_k + W_N^K O_k \\ X_{k+N/2} &= E_k - W_N^K O_k \end{align}

Let’s reflect on a couple things.

First, we divide the input into evens and odds. This only works if the input is divisible by 2. Since we’re going to be doing this recursively, we actually need it to be a power of 2. We can relax this by dividing the input into thirds, fourths, fifths, etc., which we’ll have to get into later. If at all possible, you should try to FFT an input signal with a length whose prime factors are small. This lets us apply various analytic tricks to make it fast. It’s common to pad with 0s, although that can create artifacts in the frequency-domain spectrum.

Second, splitting the input into even and odd terms isn’t the only choice. This approach is called decimation in time, because you still have samples near the beginning and end, but half as many overall. Your sample rate has halved, but the time interval is about the same. We might instead split it into a lower and upper half. This approach is called decimation in frequency: your time intervals halve, but the frequency rate in each half is the same.

Implementing the FFT

The FFT is far less trivial to implement than the DFT. Here is the simplest code I could come up with:

// Checks that `n = k^p`, for some natural number `p`.
fn is_power_of_k(n: usize, k: usize) -> bool {
    match n {
        0 => false,
        1 => true,
        _ => n % k == 0 && is_power_of_k(n / k, k),
    }
}

// Helper to naive_fft. Takes `data` along with 3 numbers that let us recreate an even-odd subset:
//  - `start_idx` tells us where the subset begins;
//  - `big_n` is the number of elements in the subset;
//  - `stride` is the distance between elements.
// We also use a double buffer, `scratch`, to avoid clobbering data while merging results.
#[rustfmt::skip]
fn _naive_fft<T: Float + FloatConst>(data: &mut [Complex<T>], start_idx: usize, big_n: usize, stride: usize, scratch: &mut [Complex<T>]) {
    if big_n == 1 {
        return;
    }
    // Compute DFT of even elements.
    _naive_fft(data, start_idx,        big_n/2, stride*2, scratch);
    // Odd elements.
    _naive_fft(data, start_idx+stride, big_n/2, stride*2, scratch);
    for k in 0..(big_n/2) {
        let p = data[start_idx + 2 * k       * stride];
        let q = data[start_idx + (2 * k + 1) * stride];
        let k_t     = usize_to_float::<T>(k);
        let big_n_t = usize_to_float::<T>(big_n);
        let phase = -T::TAU() * k_t / big_n_t;
        let factor = Complex::<T>::cis(phase);
        scratch[start_idx + k               * stride] = p + q * factor;
        scratch[start_idx + (k + big_n / 2) * stride] = p - q * factor;
    }
    data.copy_from_slice(scratch);
}

// Naive implementation of Cooley-Tukey FFT. Modifies `data`in place. Panics if data.len() is not a power of two.
#[allow(dead_code)]
fn naive_fft<T: Float + FloatConst>(data: &mut [Complex<T>]) {
    assert!(is_power_of_k(data.len(), 2));
    let mut scratch = Vec::from(data.as_ref());
    _naive_fft(data, 0, data.len(), 1, &mut scratch);
}

This code is obviously highly suboptimal, for many of the same reasons as the DFT code. In addition, we also copy the entire array once per recursive call. There are O(N)O(N) recursive calls, so this is extremely wasteful. We’ll fix that later by double-buffering.

Inefficiencies aside, this code still performs vastly better than the naive DFT:

Algorithm Duration Max. error Avg. error
Naive DFT 83.513 ms 0.33024592 0.00950057
Naive FFT 1.3469 ms 0.00018436 0.00000708
rustfft 14.791 us 0.00009481 0.00000397

(Input size 4096, type f32.)

We get a nice 62x speedup, and 1335x improvement on average error. However, the speed-of-light implementation is still ~91x faster than ours, and 1.7x more accurate. Most of our work is going to focus on bridging these two gaps, while retaining as simple an implementation as possible.

Note for a moment the impact of sequential adds. The naive DFT performed 4096 sequential adds for each term, and wound up ~2000x less accurate than the speed-of-light. Due to the FFT’s recursive structure, we use log(4096) = 12 sequential adds, and that brings our accuracy within a factor of 2 of optimal. Quite the stark difference!

Opt. 1: Precompute twiddles

The twiddle factors, WNnkW_N^{nk}, do not depend on the input to the FFT, so we can (and should!) hoist them out of the hot path. Production FFT libraries like fftw and rustfft do this, and we’ll follow in their footsteps. This also lets us precompute the twiddles in high precision before casting to low precision, which as we’ll see, improves the precision of the end result.

First, let’s precompute our twiddle factors:

// Calculates the "twiddle factors" for an n-element FFT, aka all of the nth roots of unity.
fn precompute_twiddles<T: Float + FloatConst>(n: usize) -> Vec<Complex<T>> {
    let mut result = vec![Complex::<T>::new(T::zero(), T::zero()); n];

    let n_f64 = usize_to_float::<f64>(n);
    for i in 0..n {
        let tw_f64 = Complex::<f64>::cis(-f64::TAU() * usize_to_float::<f64>(i) / (n_f64));
        result[i] = Complex::new(T::from(tw_f64.re).unwrap(), T::from(tw_f64.im).unwrap());
    }

    result
}

Next, adjust our function to take these twiddles as input:

fn _fft_v1_hoist<T: Float + FloatConst>(
    data: &mut [Complex<T>],
    start_idx: usize,
    big_n: usize,
    stride: usize,
    scratch: &mut [Complex<T>],
    twiddles: &[Complex<T>],
) {
    if big_n == 1 {
        return;
    }
    // Compute DFT of even elements.
    _fft_v1_hoist(data, start_idx, big_n / 2, stride * 2, scratch, twiddles);
    // Odd elements.
    _fft_v1_hoist(
        data,
        start_idx + stride,
        big_n / 2,
        stride * 2,
        scratch,
        twiddles,
    );
    for k in 0..(big_n / 2) {
        let p = data[start_idx + 2 * k * stride];
        let q = data[start_idx + (2 * k + 1) * stride];
        let factor = twiddles[k * stride];
        scratch[start_idx + k * stride] = p + q * factor;
        scratch[start_idx + (k + big_n / 2) * stride] = p - q * factor;
    }
    data.copy_from_slice(scratch);
}

// Modification of fft_naive: hoist out and precompute twiddles.
pub fn fft_v1_hoist<T: Float + FloatConst>(data: &mut [Complex<T>], twiddles: &[Complex<T>]) {
    assert!(is_power_of_k(data.len(), 2));
    let mut scratch = Vec::from(data.as_ref());
    _fft_v1_hoist(data, 0, data.len(), 1, &mut scratch, &twiddles);
}

We see a modest performance uplift, but our average-case error is now within spitting distance of the speed-of-light, and our worst-case error matches exactly:

Algorithm Duration Max. error Avg. error
Naive DFT 83.513 ms 0.33024592 0.00950057
Naive FFT 1.3469 ms 0.00018436 0.00000708
FFT v1 1.2813 ms 0.00009481 0.00000410
rustfft 14.791 us 0.00009481 0.00000397

Opt. 2: Double buffering

Our FFT algorithms thus far have done a fully length-NN copy at each recurisive step. Because each recursive step divides the length of the array by 2, we make a total of 1+2+4+N/21 + 2 + 4 + \dots N/2 function calls, which sums to N1N-1 total calls. Each one does a copy of length NN, so if each copy takes O(N)O(N) itme, we spend O(N2)O(N^2) time copying buffers overall. Not good!

We can fix this pretty easily with double buffering:

fn _fft_v2_double_buffer<T: Float + FloatConst>(
    src: &mut [Complex<T>],
    dst: &mut [Complex<T>],
    start_idx: usize,
    big_n: usize,
    stride: usize,
    twiddles: &[Complex<T>],
) {
    if big_n == 1 {
        return;
    }
    // Compute DFT of even elements.
    _fft_v2_double_buffer(dst, src, start_idx, big_n / 2, stride * 2, twiddles);
    // Odd elements.
    _fft_v2_double_buffer(
        dst,
        src,
        start_idx + stride,
        big_n / 2,
        stride * 2,
        twiddles,
    );
    for k in 0..(big_n / 2) {
        let p = src[start_idx + 2 * k * stride];
        let q = src[start_idx + (2 * k + 1) * stride];
        let factor = twiddles[k * stride];
        dst[start_idx + k * stride] = p + q * factor;
        dst[start_idx + (k + big_n / 2) * stride] = p - q * factor;
    }
}

#[allow(dead_code)]
pub fn fft_v2_double_buffer<T: Float + FloatConst>(
    src: &mut [Complex<T>],
    dst: &mut [Complex<T>],
    twiddles: &[Complex<T>],
) {
    assert!(is_power_of_k(src.len(), 2));
    dst.copy_from_slice(src);
    // Switching `src` and `dst` means that at the end, the result is in `src` - which is actually
    // what we want! We will be hiding `dst` and `twiddles` in a struct later on :)
    _fft_v2_double_buffer(dst, src, 0, src.len(), 1, twiddles);
}

Note that we only hoist out the allocation of the double-buffer. Initialization still occurs in the hot path.

Accuracy numbers are identical to before, as expected, and performance is vastly improved:

Algorithm Duration Max. error Avg. error
Naive DFT 83.513 ms 0.33024592 0.00950057
Naive FFT 1.3469 ms 0.00018436 0.00000708
FFT v1 1.2813 ms 0.00009481 0.00000410
FFT v2 39.944 us 0.00009481 0.00000410
rustfft 14.791 us 0.00009481 0.00000397

Pretty remarkable result. Minimizing memory writes gets us within a factor of 3 of the state of the art.

Still… we can go faster!

Opt. 3: Iterative instead of recursive

The recursive implementation we’re using is good for the classroom, but bad for performance. If we switch to an iterative implementation, we’ll be able to share work each time we step down a layer of recursion. It will also make it much easier to map this algorithm to the GPU (more on that later).

Let’s do it:

pub fn fft_v3_iterative<T: Float + FloatConst>(
    src: &mut [Complex<T>],
    dst: &mut [Complex<T>],
    twiddles: &[Complex<T>],
) {
    assert!(is_power_of_k(src.len(), 2));
    dst.copy_from_slice(src);
    let n_iter = log_k_of::<2>(src.len());

    if n_iter % 2 != 0 {
        dst.copy_from_slice(src);
    }

    let (mut input, mut output) = if n_iter % 2 == 0 {
        (dst, src)
    } else {
        (src, dst)
    };
    let mut stride = input.len();
    let mut big_n = 1;
    for _ in 0..n_iter {
        stride /= 2;
        big_n *= 2;
        std::mem::swap(&mut input, &mut output);

        for start_idx in 0..stride {
            for k in 0..big_n / 2 {
                // Get odd and even elements.
                let p = input[start_idx + 2 * k * stride];
                let q = input[start_idx + (2 * k + 1) * stride];
                // Combine.
                let factor = twiddles[k * stride];
                output[start_idx + k * stride] = p + q * factor;
                output[start_idx + (k + big_n / 2) * stride] = p - q * factor;
            }
        }
    }
}

This is essentially identical to the v2 code, except that we use iteration instead of recursion. Regardless, the performance uplift is dramatic:

Algorithm Duration Max. error Avg. error
Naive DFT 83.513 ms 0.33024592 0.00950057
Naive FFT 1.3469 ms 0.00018436 0.00000708
FFT v1 1.2813 ms 0.00009481 0.00000410
FFT v2 39.944 us 0.00009481 0.00000410
FFT v3 23.626 us 0.00009481 0.00000410
rustfft 14.791 us 0.00009481 0.00000397

We’re well within a factor of 2 of SOTA now! No, we’re not done.

Aside: the radix-4 FFT

Let’s think, for a moment, what our FFT would look like if instead of splitting the input into 2 parts at each stage, we broke it into 4:

Xk=n=0N/41x4ne2πiN(4n)k+n=0N/41x4n+1e2πiN(4n+1)k+n=0N/41x4n+2e2πiN(4n+2)k+n=0N/41x4n+3e2πiN(4n+3)k \begin{array}{rcll} X_k = & \sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & \sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n+1)k} & + \\ & \sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n+2)k} & + \\ & \sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n+3)k} & \end{array}

Apply the usual factoring trick:

Xk=n=0N/41x4ne2πiN(4n)k+e2πiNkn=0N/41x4n+1e2πiN(4n)k+e2πiN2kn=0N/41x4n+2e2πiN(4n)k+e2πiN3kn=0N/41x4n+3e2πiN(4n)k \begin{array}{rclll} X_k = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & \end{array}

This is only valid for kk on [0,N/4)[0, N/4). To get the others we have to do the same analysis as before - replace every kk with k+N/4k + N/4, then do some eliminations and factoring.

Calculating k+N/4k+N/4

Xk+N/4=n=0N/41x4ne2πiN(4n)(k+N/4)+e2πiN(k+N/4)n=0N/41x4n+1e2πiN(4n)(k+N/4)+e2πiN2(k+N/4)n=0N/41x4n+2e2πiN(4n)(k+N/4)+e2πiN3(k+N/4)n=0N/41x4n+3e2πiN(4n)(k+N/4) \begin{array}{rclll} X_{k+N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & + \\ & e^{-\frac{2\pi i}{N}(k+N/4)} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & + \\ & e^{-\frac{2\pi i}{N}2(k+N/4)} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & + \\ & e^{-\frac{2\pi i}{N}3(k+N/4)} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & \end{array}

Simplify the shared inner term:

e2πiN(4n)(k+N/4)=e2πiN(4n)ke2πiN(4n)N/4=e2πiN(4n)ke2πin=e2πiN(4n)k \begin{align*} e^{-\frac{2\pi i}{N}(4n)(k+N/4)} &= e^{-\frac{2\pi i}{N}(4n)k} e^{-\frac{2\pi i}{N}(4n)N/4} \\ &= e^{-\frac{2\pi i}{N}(4n)k} e^{-2\pi i n} \\ &= e^{-\frac{2\pi i}{N}(4n)k} \end{align*}

Simplify the first outer term:

e2πiN(k+N/4)=e2πiNke2πiNN/4=e2πiNke2πi4=e2πiNk(i) \begin{align*} e^{-\frac{2\pi i}{N}(k+N/4)} &= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{N}N/4} \\ &= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{4}} \\ &= e^{-\frac{2\pi i}{N}k} (-i) \\ \end{align*}

By inspection, we can see that the second and third terms will be of this form as well. We’re basically just multiplying by a vector that’s rotating 90 degrees clockwise in the complex plane:

e2πiN(2k+2N/4)=e2πiN2ke2πi24=e2πiN2k(1)e2πiN(3k+3N/4)=e2πiN3ke2πi34=e2πiN3k(i) \begin{align*} e^{-\frac{2\pi i}{N}(2k+2N/4)} &= e^{-\frac{2\pi i}{N}2k} e^{-\frac{2\pi i 2}{4}} \\ &= e^{-\frac{2\pi i}{N}2k} (-1) \\ e^{-\frac{2\pi i}{N}(3k+3N/4)} &= e^{-\frac{2\pi i}{N}3k} e^{-\frac{2\pi i 3}{4}} \\ &= e^{-\frac{2\pi i}{N}3k} (i) \\ \end{align*}

Plugging in:

Xk+N/4=n=0N/41x4ne2πiN(4n)k+(i)e2πiNkn=0N/41x4n+1e2πiN(4n)k+(1)e2πiN2kn=0N/41x4n+2e2πiN(4n)k+(i)e2πiN3kn=0N/41x4n+3e2πiN(4n)k \begin{array}{rrlll} X_{k+N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & (-i) e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & (-1) e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & (i) e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & \end{array}

Using WW syntax:

Xk+N/4=n=0N/41x4nWN4nk+(i)WNkn=0N/41x4n+1WN4nk+(1)WN2kn=0N/41x4n+2WN4nk+(i)WN3kn=0N/41x4n+3WN4nk \begin{array}{rrlll} X_{k+N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & W_N^{4nk} & + \\ & (-i) W_N^k &\sum_{n=0}^{N/4-1} x_{4n+1} & W_N^{4nk} & + \\ & (-1) W_N^{2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & W_N^{4nk} & + \\ & (i) W_N^{3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & W_N^{4nk} & \end{array}

Calculating k+N/2k+N/2

Xk+N/2=n=0N/41x4ne2πiN(4n)(k+N/2)+e2πiN(k+N/2)n=0N/41x4n+1e2πiN(4n)(k+N/2)+e2πiN2(k+N/2)n=0N/41x4n+2e2πiN(4n)(k+N/2)+e2πiN3(k+N/2)n=0N/41x4n+3e2πiN(4n)(k+N/2) \begin{array}{rclll} X_{k+N/2} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & + \\ & e^{-\frac{2\pi i}{N}(k+N/2)} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & + \\ & e^{-\frac{2\pi i}{N}2(k+N/2)} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & + \\ & e^{-\frac{2\pi i}{N}3(k+N/2)} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & \end{array}

Simplify the shared inner term:

e2πiN(4n)(k+N/2)=e2πiN(4n)ke2πiN(4n)N/2=e2πiN(4n)ke2πi2n=e2πiN(4n)k \begin{align*} e^{-\frac{2\pi i}{N}(4n)(k+N/2)} &= e^{-\frac{2\pi i}{N}(4n)k} e^{-\frac{2\pi i}{N}(4n)N/2} \\ &= e^{-\frac{2\pi i}{N}(4n)k} e^{-2\pi i 2n} \\ &= e^{-\frac{2\pi i}{N}(4n)k} \end{align*}

(We can see from the above that the last quarter will also have the same simplification applied, so we will skip deriving it later.)

Simplify the first outer term:

e2πiN(k+N/2)=e2πiNke2πiNN/2=e2πiNke2πi2=e2πiNk(1) \begin{align*} e^{-\frac{2\pi i}{N}(k+N/2)} &= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{N}N/2} \\ &= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{2}} \\ &= e^{-\frac{2\pi i}{N}k} (-1) \\ \end{align*}

Let’s pause here to reflect. In the [0,N/4)[0, N/4), we rotated our outer terms by a quarter turn in the complex plane for each term. Now we’re rotating by a half turn. The next leg, we will rotate by 3/4 of a turn.

I will truncate the derivation there. The reader may do the rest as an exercise if needed.

Plugging in:

Xk+N/2=n=0N/41x4ne2πiN(4n)k+(1)e2πiNkn=0N/41x4n+1e2πiN(4n)k+(+1)e2πiN2kn=0N/41x4n+2e2πiN(4n)k+(1)e2πiN3kn=0N/41x4n+3e2πiN(4n)k \begin{array}{rrlll} X_{k+N/2} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & (-1) e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & (+1) e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & (-1) e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & \end{array}

Using WW syntax:

Xk+N/2=n=0N/41x4nWN4nk+(1)WNkn=0N/41x4n+1WN4nk+(+1)WN2kn=0N/41x4n+2WN4nk+(1)WN3kn=0N/41x4n+3WN4nk \begin{array}{rrlll} X_{k+N/2} = & &\sum_{n=0}^{N/4-1} x_{4n} & W_N^{4nk} & + \\ & (-1) W_N^k &\sum_{n=0}^{N/4-1} x_{4n+1} & W_N^{4nk} & + \\ & (+1) W_N^{2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & W_N^{4nk} & + \\ & (-1) W_N^{3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & W_N^{4nk} & \end{array}

Calculating k+3N/4k+3N/4

Per the lemmas in the last section, we can jump right to the result:

Xk+3N/4=n=0N/41x4ne2πiN(4n)k+(+i)e2πiNkn=0N/41x4n+1e2πiN(4n)k+(1)e2πiN2kn=0N/41x4n+2e2πiN(4n)k+(i)e2πiN3kn=0N/41x4n+3e2πiN(4n)k \begin{array}{rrlll} X_{k+3N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & (+i) e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & (-1) e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ & (-i) e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & \end{array}

Using WW syntax:

Xk+3N/4=n=0N/41x4nWN4nk+(+i)WNkn=0N/41x4n+1WN4nk+(1)WN2kn=0N/41x4n+2WN4nk+(i)WN3kn=0N/41x4n+3WN4nk \begin{array}{rrlll} X_{k+3N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & W_N^{4nk} & + \\ & (+i) W_N^k &\sum_{n=0}^{N/4-1} x_{4n+1} & W_N^{4nk} & + \\ & (-1) W_N^{2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & W_N^{4nk} & + \\ & (-i) W_N^{3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & W_N^{4nk} & \end{array}

Summary

The radix-4 FFT’s merge step works as follows:

kk range Term 0 Term 1 Term 2 Term 3
[0,N/4)[0,N/4) +1 +1 +1 +1
[N/4,N/2)[N/4,N/2) +1 -i -1 +i
[N/2,3N/4)[N/2,3N/4) +1 -1 +1 -1
[3N/4,N)[3N/4,N) +1 +i -1 -i

And for each stage, the twiddles are:

Opt. 4: Radix-4

With the above in mind, we can now implement the radix-4 FFT:

#[inline(always)]
fn mul_ni<T: Float + FloatConst>(x: Complex<T>) -> Complex<T> {
    Complex::new(x.im, -x.re)
}

pub fn fft_v4_radix_4<T: Float + FloatConst>(
    src: &mut [Complex<T>],
    dst: &mut [Complex<T>],
    twiddles: &[Complex<T>],
) {
    assert!(is_power_of_k(src.len(), 4));
    let n_iter = log_k_of::<4>(src.len());

    dst.copy_from_slice(src);

    let (mut input, mut output) = if n_iter % 2 == 0 {
        (dst, src)
    } else {
        (src, dst)
    };
    let big_n = input.len();
    let mut stride = big_n;
    let mut big_n = 1;
    for _ in 0..n_iter {
        stride /= 4;
        big_n *= 4;
        std::mem::swap(&mut input, &mut output);

        for start_idx in 0..stride {
            for k in 0..big_n / 4 {
                // Collect inputs.
                let i0 = input[start_idx + 4 * k * stride];
                let i1 = input[start_idx + (4 * k + 1) * stride];
                let i2 = input[start_idx + (4 * k + 2) * stride];
                let i3 = input[start_idx + (4 * k + 3) * stride];
                // Collect relevant twiddles.
                let ot1 = twiddles[1 * k * stride];
                let ot2 = twiddles[2 * k * stride];
                let ot3 = twiddles[3 * k * stride];

                let a = i0;
                let b = ot1 * i1;
                let c = ot2 * i2;
                let d = ot3 * i3;

                // To derive this, write the expression below in terms of
                // a/b/c/d, then factor out!
                let ac_sum  = a + c;
                let ac_diff = a - c;
                let bd_sum = b + d;
                let bd_diff_ni = mul_ni(b - d);

                output[start_idx + k * stride] = ac_sum + bd_sum;
                output[start_idx + (k + big_n / 4) * stride] =
                    ac_diff + bd_diff_ni;
                output[start_idx + (k + big_n / 2) * stride] =
                    ac_sum - bd_sum;
                output[start_idx + (k + 3 * big_n / 4) * stride] =
                    ac_diff - bd_diff_ni;
            }
        }
    }
}

As a quick aside - note that we could simply multiply [a, b, c, d] by a 4x4 matrix holding the terms we derived in the previous section. Possibly useful for a GPU implementation!

With this we pick up another ~10% speedup, and actually beat the reference implementation’s average-case error!

Algorithm Duration Max. error Avg. error
Naive DFT 83.513 ms 0.33024592 0.00950057
Naive FFT 1.3469 ms 0.00018436 0.00000708
FFT v1 1.2813 ms 0.00009481 0.00000410
FFT v2 39.944 us 0.00009481 0.00000410
FFT v3 23.626 us 0.00009481 0.00000410
FFT v4 20.231 us 0.00009481 0.00000396
rustfft 14.791 us 0.00009481 0.00000397

A few remarks:

Opt. 5: Special-case first stage

The twiddles we look up in our inner loop are just WNksW_N^{k s}. For the first iteration, big_n =4= 4, so kk is always 0 – therefore, we use WN0W_N^0, which is just 1. We can special-case this and save a few more complex multiplies:

fn fft_butterfly_radix_4<T: Float + FloatConst>(
    input: &mut [Complex<T>],
    output: &mut [Complex<T>],
    stride: usize,
    big_n: usize,
    twiddles: &[Complex<T>],
) {
    for start_idx in 0..stride {
        for k in 0..big_n / 4 {
            // Collect inputs.
            let i0 = input[start_idx + 4 * k * stride];
            let i1 = input[start_idx + (4 * k + 1) * stride];
            let i2 = input[start_idx + (4 * k + 2) * stride];
            let i3 = input[start_idx + (4 * k + 3) * stride];
            // Collect relevant twiddles.
            let ot1 = twiddles[1 * k * stride];
            let ot2 = twiddles[2 * k * stride];
            let ot3 = twiddles[3 * k * stride];

            let a = i0;
            let b = ot1 * i1;
            let c = ot2 * i2;
            let d = ot3 * i3;

            // To derive this, write the output assignments in terms of
            // a/b/c/d, then factor out!
            let ac_sum = a + c;
            let ac_diff = a - c;
            let bd_sum = b + d;
            let bd_diff_ni = mul_ni(b - d);

            output[start_idx + k * stride] = ac_sum + bd_sum;
            output[start_idx + (k + big_n / 4) * stride] = ac_diff + bd_diff_ni;
            output[start_idx + (k + big_n / 2) * stride] = ac_sum - bd_sum;
            output[start_idx + (k + 3 * big_n / 4) * stride] = ac_diff - bd_diff_ni;
        }
    }
}

fn fft_butterfly_radix_4_s0<T: Float + FloatConst>(
    input: &mut [Complex<T>],
    output: &mut [Complex<T>],
    twiddles: &[Complex<T>],
) {
    let stride = input.len() / 4;
    let big_n = 4;

    for start_idx in 0..stride {
        for k in 0..big_n / 4 {
            // Collect inputs.
            let i0 = input[start_idx + 4 * k * stride];
            let i1 = input[start_idx + (4 * k + 1) * stride];
            let i2 = input[start_idx + (4 * k + 2) * stride];
            let i3 = input[start_idx + (4 * k + 3) * stride];

            let a = i0;
            let b = i1;
            let c = i2;
            let d = i3;

            // To derive this, write the output assignments in terms of
            // a/b/c/d, then factor out!
            let ac_sum = a + c;
            let ac_diff = a - c;
            let bd_sum = b + d;
            let bd_diff_ni = mul_ni(b - d);

            output[start_idx + k * stride] = ac_sum + bd_sum;
            output[start_idx + (k + big_n / 4) * stride] = ac_diff + bd_diff_ni;
            output[start_idx + (k + big_n / 2) * stride] = ac_sum - bd_sum;
            output[start_idx + (k + 3 * big_n / 4) * stride] = ac_diff - bd_diff_ni;
        }
    }
}

pub fn fft_v5_s0_opt<T: Float + FloatConst>(
    src: &mut [Complex<T>],
    dst: &mut [Complex<T>],
    twiddles: &[Complex<T>],
) {
    assert!(is_power_of_k(src.len(), 4));
    let n_iter = log_k_of::<4>(src.len());

    dst.copy_from_slice(src);

    let (mut input, mut output) = if n_iter % 2 == 0 {
        (dst, src)
    } else {
        (src, dst)
    };
    let big_n = input.len();
    let mut stride = big_n;
    let mut big_n = 1;
    for stage in 0..n_iter {
        stride /= 4;
        big_n *= 4;
        std::mem::swap(&mut input, &mut output);

        if stage == 0 {
            fft_butterfly_radix_4_s0(input, output, twiddles);
        } else {
            fft_butterfly_radix_4(input, output, stride, big_n, twiddles);
        }
    }
}

Here I refactored the inner loop of our FFT - called a butterfly in FFT research parlance - and made a variant which avoids those complex multiplies in stage 1. We get a few more microseconds out of this, with no change to our accuracy:

Algorithm Duration Max. error Avg. error
Naive DFT 83.513 ms 0.33024592 0.00950057
Naive FFT 1.3469 ms 0.00018436 0.00000708
FFT v1 1.2813 ms 0.00009481 0.00000410
FFT v2 39.944 us 0.00009481 0.00000410
FFT v3 23.626 us 0.00009481 0.00000410
FFT v4 20.231 us 0.00009481 0.00000396
FFT v5 16.383 us 0.00009481 0.00000396
rustfft 14.791 us 0.00009481 0.00000397

Within 12% of our speed-of-light! No, we’re not done yet :)

Opt. 6: Unsafe

Our butterfly does 8 array lookups, each of which Rust will bounds-check for us. However, we know by inspection that they will never go out of bounds. So we can tell Rust this with the unsafe keyword, and enable more compiler optimizations.

fn fft_butterfly_radix_4_unsafe<T: Float + FloatConst>(
    input: &mut [Complex<T>],
    output: &mut [Complex<T>],
    stride: usize,
    big_n: usize,
    twiddles: &[Complex<T>],
) {
    let input_ptr = input.as_ptr();
    let output_ptr = output.as_mut_ptr();
    for start_idx in 0..stride {
        for k in 0..big_n / 4 {
            unsafe {
                // Collect inputs.
                let i0 = *input_ptr.add(start_idx + 4 * k * stride);
                let i1 = *input_ptr.add(start_idx + (4 * k + 1) * stride);
                let i2 = *input_ptr.add(start_idx + (4 * k + 2) * stride);
                let i3 = *input_ptr.add(start_idx + (4 * k + 3) * stride);
                // Collect relevant twiddles.
                let ot1 = twiddles.get_unchecked(1 * k * stride);
                let ot2 = twiddles.get_unchecked(2 * k * stride);
                let ot3 = twiddles.get_unchecked(3 * k * stride);

                let a = i0;
                let b = ot1 * i1;
                let c = ot2 * i2;
                let d = ot3 * i3;

                // To derive this, write the output assignments in terms of
                // a/b/c/d, then factor out!
                let ac_sum = a + c;
                let ac_diff = a - c;
                let bd_sum = b + d;
                let bd_diff_ni = mul_ni(b - d);

                *output_ptr.add(start_idx + k * stride) = ac_sum + bd_sum;
                *output_ptr.add(start_idx + (k + big_n / 4) * stride) = ac_diff + bd_diff_ni;
                *output_ptr.add(start_idx + (k + big_n / 2) * stride) = ac_sum - bd_sum;
                *output_ptr.add(start_idx + (k + 3 * big_n / 4) * stride) = ac_diff - bd_diff_ni;
            }
        }
    }
}

fn fft_butterfly_radix_4_s0_unsafe<T: Float + FloatConst>(
    input: &mut [Complex<T>],
    output: &mut [Complex<T>],
) {
    let stride = input.len() / 4;
    let big_n = 4;
    let input_ptr = input.as_ptr();
    let output_ptr = output.as_mut_ptr();
    for start_idx in 0..stride {
        for k in 0..big_n / 4 {
            unsafe {
                // Collect inputs.
                let i0 = input[start_idx + 4 * k * stride];
                let i1 = input[start_idx + (4 * k + 1) * stride];
                let i2 = input[start_idx + (4 * k + 2) * stride];
                let i3 = input[start_idx + (4 * k + 3) * stride];

                let a = i0;
                let b = i1;
                let c = i2;
                let d = i3;

                // To derive this, write the output assignments in terms of
                // a/b/c/d, then factor out!
                let ac_sum = a + c;
                let ac_diff = a - c;
                let bd_sum = b + d;
                let bd_diff_ni = mul_ni(b - d);

                *output_ptr.add(start_idx + k * stride) = ac_sum + bd_sum;
                *output_ptr.add(start_idx + (k + big_n / 4) * stride) = ac_diff + bd_diff_ni;
                *output_ptr.add(start_idx + (k + big_n / 2) * stride) = ac_sum - bd_sum;
                *output_ptr.add(start_idx + (k + 3 * big_n / 4) * stride) = ac_diff - bd_diff_ni;
            }
        }
    }
}

pub fn fft_v6_unsafe<T: Float + FloatConst>(
    src: &mut [Complex<T>],
    dst: &mut [Complex<T>],
    twiddles: &[Complex<T>],
) {
    assert!(is_power_of_k(src.len(), 4));
    assert_eq!(src.len(), dst.len());
    assert_eq!(twiddles.len(), src.len());
    let n_iter = log_k_of::<4>(src.len());

    dst.copy_from_slice(src);

    let (mut input, mut output) = if n_iter % 2 == 0 {
        (dst, src)
    } else {
        (src, dst)
    };
    let big_n = input.len();
    let mut stride = big_n;
    let mut big_n = 1;
    for stage in 0..n_iter {
        stride /= 4;
        big_n *= 4;
        std::mem::swap(&mut input, &mut output);

        if stage == 0 {
            fft_butterfly_radix_4_s0_unsafe(input, output);
        } else {
            fft_butterfly_radix_4_unsafe(input, output, stride, big_n, twiddles);
        }
    }
}

Note that “add” just means “add a value to this pointer.” Seems to be the canonical way to do pointer arithmetic in Rust. With this, we have nearly reached the speed of light!

Algorithm Duration Max. error Avg. error
Naive DFT 83.513 ms 0.33024592 0.00950057
Naive FFT 1.3469 ms 0.00018436 0.00000708
FFT v1 1.2813 ms 0.00009481 0.00000410
FFT v2 39.944 us 0.00009481 0.00000410
FFT v3 23.626 us 0.00009481 0.00000410
FFT v4 20.231 us 0.00009481 0.00000396
FFT v5 16.383 us 0.00009481 0.00000396
FFT v6 14.830 us 0.00009481 0.00000396
rustfft 14.791 us 0.00009481 0.00000397

No, we’re not done.

Opt. 7: Radix-8

Why stop at radix-4? If we extend to radix-8, we still get the desirable analytic property of our factors not requiring complex multiplies, as they’re just 45-degree rotations, but we also reduce the number of stages.

For a length-4096 input, aka 2122^12, radix-4 requires 6 stages, where radix-8 requires only 4. If each stage does 3 and 7 complex multiplies respectively, we wind up with 18ss vs. 1414 total complex multiplies.

We also reduce the number of times that we need to read the full data buffer from 6 to 4.

I can derive the radix-8 twiddles by inspection - it’s left as an exercise to the reader if needed. (Tip: visualize the rotations through the complex plane.)

Let p=12p = \frac{1}{\sqrt{2}}. Then:

kk range Term 0 Term 1 Term 2 Term 3 Term 4 Term 5 Term 6 Term 7
[0,N/8)[0,N/8) +1 +1 +1 +1 +1 +1 +1 +1
[N/8,N/4)[N/8,N/4) +1 +pip+p-ip -i pip-p-ip -1 p+ip-p+ip +i +p+ip+p+ip
[N/4,3N/8)[N/4,3N/8) +1 -i -1 +i +1 -i -1 +i
[3N/8,N/2)[3N/8,N/2) +1 pip-p-ip +i pipp-ip -1 p+ipp+ip -i p+ip-p+ip
[N/2,5N/8)[N/2,5N/8) +1 -1 +1 -1 +1 -1 +1 -1
[5N/8,3N/4)[5N/8,3N/4) +1 p+ip-p+ip -i p+ipp+ip -1 pipp-ip +i pip-p-ip
[3N/4,7N/8)[3N/4,7N/8) +1 +i -1 -i +1 +i -1 -i
[7N/8,N)[7N/8,N) +1 p+ipp+ip +i p+ip-p+ip -1 pip-p-ip -i pipp-ip

And the twiddles are 1,WNk,WN2k,,WN7k1, W_N^k, W_N^{2k}, \dots, W_N^{7k}.

First, we need an optimized way to rotate by 45 degrees, as well as every multiple of 90 degrees. The standard 2D rotation matrix3 makes this easy:

#[inline(always)]
fn rot_45<T: Float + FloatConst>(c: Complex<T>) -> Complex<T> {
    let s = T::FRAC_1_SQRT_2();
    // The standard 2D rotation matrix gives:
    //  [ cos(pi/4) -sin(pi/4)]   [ s -s ]
    //  [ sin(pi/4)  cos(pi/4)] = [ s  s ]
    Complex::<T>::new(c.re - c.im, c.re + c.im) * s
}

#[inline(always)]
fn rot_90<T: Float + FloatConst>(c: Complex<T>) -> Complex<T> {
    // The standard 2D rotation matrix gives:
    //  [ cos(pi/2) -sin(pi/2)]   [ 0 -1 ]
    //  [ sin(pi/2)  cos(pi/2)] = [ 1  0 ]
    Complex::<T>::new(-c.im, c.re)
}

#[inline(always)]
fn rot_180<T: Float + FloatConst>(c: Complex<T>) -> Complex<T> {
    // The standard 2D rotation matrix gives:
    //  [ cos(pi) -sin(pi)]   [ -1  0 ]
    //  [ sin(pi)  cos(pi)] = [  0 -1 ]
    -c
}

#[inline(always)]
fn rot_270<T: Float + FloatConst>(c: Complex<T>) -> Complex<T> {
    // The standard 2D rotation matrix gives:
    //  [ cos(3pi/2) -sin(3pi/2)]   [  0 1 ]
    //  [ sin(3pi/2)  cos(3pi/2)] = [ -1 0 ]
    Complex::<T>::new(c.im, -c.re)
}

Next, we just write out our big radix-8 butterflies:

fn fft_butterfly_radix_8_unsafe<T: Float + FloatConst>(
    input: &mut [Complex<T>],
    output: &mut [Complex<T>],
    stride: usize,
    big_n: usize,
    twiddles: &[Complex<T>],
) {
    let input_ptr = input.as_ptr();
    let output_ptr = output.as_mut_ptr();
    for start_idx in 0..stride {
        for k in 0..big_n / 8 {
            unsafe {
                // Collect inputs.
                let i0 = *input_ptr.add(start_idx + 8 * k * stride);
                let i1 = *input_ptr.add(start_idx + (8 * k + 1) * stride);
                let i2 = *input_ptr.add(start_idx + (8 * k + 2) * stride);
                let i3 = *input_ptr.add(start_idx + (8 * k + 3) * stride);
                let i4 = *input_ptr.add(start_idx + (8 * k + 4) * stride);
                let i5 = *input_ptr.add(start_idx + (8 * k + 5) * stride);
                let i6 = *input_ptr.add(start_idx + (8 * k + 6) * stride);
                let i7 = *input_ptr.add(start_idx + (8 * k + 7) * stride);

                // Collect relevant twiddles.
                let ot1 = twiddles.get_unchecked(1 * k * stride);
                let ot2 = twiddles.get_unchecked(2 * k * stride);
                let ot3 = twiddles.get_unchecked(3 * k * stride);
                let ot4 = twiddles.get_unchecked(4 * k * stride);
                let ot5 = twiddles.get_unchecked(5 * k * stride);
                let ot6 = twiddles.get_unchecked(6 * k * stride);
                let ot7 = twiddles.get_unchecked(7 * k * stride);

                let a = i0;
                let b = ot1 * i1;
                let c = ot2 * i2;
                let d = ot3 * i3;
                let e = ot4 * i4;
                let f = ot5 * i5;
                let g = ot6 * i6;
                let h = ot7 * i7;

                let ae_sum  = a + e;
                let ae_diff = a - e;
                let bf_sum  = b + f;
                let bf_diff = b - f;
                let cg_sum  = c + g;
                let cg_diff = c - g;
                let dh_sum  = d + h;
                let dh_diff = d - h;

                let w00 = ae_sum + cg_sum;
                let w01 = ae_sum - cg_sum;
                let w10 = ae_diff + rot_270(cg_diff);
                let w11 = ae_diff - rot_270(cg_diff);
                let x00 = bf_sum + dh_sum;
                let x01 = rot_270(bf_sum) + rot_90(dh_sum);
                let x10 = rot_45(rot_270(bf_diff) + rot_180(dh_diff));
                let x11 = rot_45(rot_180(bf_diff) + rot_270(dh_diff));

                *output_ptr.add(start_idx + k * stride)                   = w00 + x00;
                *output_ptr.add(start_idx + (k + big_n / 8) * stride)     = w10 + x10;
                *output_ptr.add(start_idx + (k + big_n / 4) * stride)     = w01 + x01;
                *output_ptr.add(start_idx + (k + 3 * big_n / 8) * stride) = w11 + x11;
                *output_ptr.add(start_idx + (k + big_n / 2) * stride)     = w00 - x00;
                *output_ptr.add(start_idx + (k + 5 * big_n / 8) * stride) = w10 - x10;
                *output_ptr.add(start_idx + (k + 3 * big_n / 4) * stride) = w01 - x01;
                *output_ptr.add(start_idx + (k + 7 * big_n / 8) * stride) = w11 - x11;
            }
        }
    }
}

fn fft_butterfly_radix_8_s0_unsafe<T: Float + FloatConst>(
    input: &mut [Complex<T>],
    output: &mut [Complex<T>],
) {
    let stride = input.len() / 8;
    let big_n = 8;
    let input_ptr = input.as_ptr();
    let output_ptr = output.as_mut_ptr();
    for start_idx in 0..stride {
        for k in 0..big_n / 8 {
            unsafe {
                // Collect inputs.
                let i0 = *input_ptr.add(start_idx + 8 * k * stride);
                let i1 = *input_ptr.add(start_idx + (8 * k + 1) * stride);
                let i2 = *input_ptr.add(start_idx + (8 * k + 2) * stride);
                let i3 = *input_ptr.add(start_idx + (8 * k + 3) * stride);
                let i4 = *input_ptr.add(start_idx + (8 * k + 4) * stride);
                let i5 = *input_ptr.add(start_idx + (8 * k + 5) * stride);
                let i6 = *input_ptr.add(start_idx + (8 * k + 6) * stride);
                let i7 = *input_ptr.add(start_idx + (8 * k + 7) * stride);

                let a = i0;
                let b = i1;
                let c = i2;
                let d = i3;
                let e = i4;
                let f = i5;
                let g = i6;
                let h = i7;

                let ae_sum  = a + e;
                let ae_diff = a - e;
                let bf_sum  = b + f;
                let bf_diff = b - f;
                let cg_sum  = c + g;
                let cg_diff = c - g;
                let dh_sum  = d + h;
                let dh_diff = d - h;

                let w00 = ae_sum + cg_sum;
                let w01 = ae_sum - cg_sum;
                let w10 = ae_diff + rot_270(cg_diff);
                let w11 = ae_diff - rot_270(cg_diff);
                let x00 = bf_sum + dh_sum;
                let x01 = rot_270(bf_sum) + rot_90(dh_sum);
                let x10 = rot_45(rot_270(bf_diff) + rot_180(dh_diff));
                let x11 = rot_45(rot_180(bf_diff) + rot_270(dh_diff));

                *output_ptr.add(start_idx + k * stride)                   = w00 + x00;
                *output_ptr.add(start_idx + (k + big_n / 8) * stride)     = w10 + x10;
                *output_ptr.add(start_idx + (k + big_n / 4) * stride)     = w01 + x01;
                *output_ptr.add(start_idx + (k + 3 * big_n / 8) * stride) = w11 + x11;
                *output_ptr.add(start_idx + (k + big_n / 2) * stride)     = w00 - x00;
                *output_ptr.add(start_idx + (k + 5 * big_n / 8) * stride) = w10 - x10;
                *output_ptr.add(start_idx + (k + 3 * big_n / 4) * stride) = w01 - x01;
                *output_ptr.add(start_idx + (k + 7 * big_n / 8) * stride) = w11 - x11;
            }
        }
    }
}

pub fn fft_v7_radix_8<T: Float + FloatConst>(
    src: &mut [Complex<T>],
    dst: &mut [Complex<T>],
    twiddles: &[Complex<T>],
) {
    assert!(is_power_of_k(src.len(), 8));
    assert_eq!(src.len(), dst.len());
    assert_eq!(twiddles.len(), src.len());
    let n_iter = log_k_of::<8>(src.len());

    dst.copy_from_slice(src);

    let (mut input, mut output) = if n_iter % 2 == 0 {
        (dst, src)
    } else {
        (src, dst)
    };
    let big_n = input.len();
    let mut stride = big_n;
    let mut big_n = 1;
    for stage in 0..n_iter {
        stride /= 8;
        big_n *= 8;
        std::mem::swap(&mut input, &mut output);

        if stage == 0 {
            fft_butterfly_radix_8_s0_unsafe(input, output);
        } else {
            fft_butterfly_radix_8_unsafe(input, output, stride, big_n, twiddles);
        }
    }
}

FYI, I started by just writing the naive expressions based on the table at the top of this section. Then I did one level of subexpression elimination, pairing up a with e, b with f, etc. Then I did another level, giving us the final result. Without the common subexpression elimination, this performs worse than the radix-4 kernel!

With this in place - we actually beat the speed-of-light!

Algorithm Duration Max. error Avg. error
Naive DFT 83.513 ms 0.33024592 0.00950057
Naive FFT 1.3469 ms 0.00018436 0.00000708
FFT v1 1.2813 ms 0.00009481 0.00000410
FFT v2 39.944 us 0.00009481 0.00000410
FFT v3 23.626 us 0.00009481 0.00000410
FFT v4 20.231 us 0.00009481 0.00000396
FFT v5 16.383 us 0.00009481 0.00000396
FFT v6 14.830 us 0.00009481 0.00000396
FFT v7 13.235 us 0.00009481 0.00000398
rustfft 14.791 us 0.00009481 0.00000397

Our average-case error has slightly regressed, but honestly I don’t care.

Validating other input sizes

For my use-case, I only care about FFTs of size 256, 512, 1024, and 4096. Let’s check how we perform vs. rustfft:

Input size Algorithm Runtime
256 FFT v6 587.71 ns
256 rustfft 620.66 ns
512 FFT v7 1.1278 us
512 rustfft 1.3608 us
1024 FFT v6 2.9249 us
1024 rustfft 3.0233 us
4096 FFT v7 13.235 us
4096 rustfft 14.791 us

Our algorithms mog rustfft at every relevant input size, and are exceptionally simple. Our work here is done.

Closing thoughts

These algorithms - v6 and v7 - will not scale well to large inputs (say, above 16k or so). An in-place algorithm would exhibit far better cache locality and would scale better. I did try that out, but for my input sizes, it wound up costing more than it saves.

Additionally, I did not take the time to study mixed-radix solutions. These would be needed to support e.g. size-2048 inputs, or non-power-of-2 inputs. I will probably revisit this later, but today’s not that day. My greatest aspiration for this project was to get within a factor of 2 of rustfft’s scalar performance with simple code; exceeding it was a very pleasant surprise.

Source code

All source code is available here.

AI disclosure

I used AI to check my code for errors and investigate likely high-value optimizations. All committed code, and all prose and math in this article, was written entirely by me. (Even the typesetting! 😩)


  1. Wikipedia. Discrete Fourier transform. Accessed 5 Sep 2026. Webpage↩︎

  2. Wikipedia. Euler’s formula. Accessed 5 Sep 2026. Webpage↩︎

  3. Wikipedia. Rotation matrix. Accessed 8 Sep 2026. Webpage↩︎

back to main page