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.
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 is defined as follows1
Let’s unpack that.
Our input signal is comprised of samples: . 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, , is also comprised of samples, but we index them with instead: .
We can simplify our expression a bit to get a sense of what’s happening:
To get the th term of the DFT, we have to add up every component of the input signal multiplied by some term . Since there are terms in the DFT, we must do (N additions of the input signal) * (N times for the output signal). Thus this is an algorithm. We will get back to this later!
The inner term, 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:
(This formula drops out of the Maclaurin series for , , and . By rearranging terms you wind up with this identity.)
So although our expression is expressed in the form , 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 is a natural number (1, 2, 3…):
If is even, the only solution is ; if is even, there is also the solution .
In the complex numbers, we can have more than one solution. In general, for any natural number , there are solutions, and they are of the form:
… where
(Read as “ is an element of the range of numbers starting at 1 and ending at ”).
For :
For :
So for a given , the set of complex numbers that satisfy the relationship are called the th roots of unity, and there are of them. (“Unity” is just another word for 1.) We can visualize them as simply dividing a circle in the complex plane:
In summary:
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:
data. We should hoist that
computation out.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.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.
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?
As highlighted above, naively evaluating a DFT takes time, where is the length of the input signal. There is an algorithm appropriately named the fast Fourier transform (FFT) which evaluates the same result in 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:
We can split this by even and odd indices :
Next, factor out from the second sum:
(This factoring follows from the fact that, in general, .)
Inside the sum, multiply the exponent by , i.e. 1:
Note what just happened: we have represented the th 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 time. The only lurking issue is that this only holds for in the range . To get in the range , we have to do some analysis. We will replace every instance of with , then attempt to refactor the expression to get a result that only deals with indices of :
In conclusion:
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.
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 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!
The twiddle factors, , 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 |
Our FFT algorithms thus far have done a fully length- copy at each recurisive step. Because each recursive step divides the length of the array by 2, we make a total of function calls, which sums to total calls. Each one does a copy of length , so if each copy takes itme, we spend 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!
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.
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:
Apply the usual factoring trick:
This is only valid for on . To get the others we have to do the same analysis as before - replace every with , then do some eliminations and factoring.
Simplify the shared inner term:
Simplify the first outer term:
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:
Plugging in:
Using syntax:
Simplify the shared inner term:
(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:
Let’s pause here to reflect. In the , 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:
Using syntax:
Per the lemmas in the last section, we can jump right to the result:
Using syntax:
The radix-4 FFT’s merge step works as follows:
| range | Term 0 | Term 1 | Term 2 | Term 3 |
|---|---|---|---|---|
| +1 | +1 | +1 | +1 | |
| +1 | -i | -1 | +i | |
| +1 | -1 | +1 | -1 | |
| +1 | +i | -1 | -i |
And for each stage, the twiddles are:
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:
The twiddles we look up in our inner loop are just
.
For the first iteration, big_n
,
so
is always 0 – therefore, we use
,
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 :)
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.
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 , 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 18 vs. 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 . Then:
| range | Term 0 | Term 1 | Term 2 | Term 3 | Term 4 | Term 5 | Term 6 | Term 7 |
|---|---|---|---|---|---|---|---|---|
| +1 | +1 | +1 | +1 | +1 | +1 | +1 | +1 | |
| +1 | -i | -1 | +i | |||||
| +1 | -i | -1 | +i | +1 | -i | -1 | +i | |
| +1 | +i | -1 | -i | |||||
| +1 | -1 | +1 | -1 | +1 | -1 | +1 | -1 | |
| +1 | -i | -1 | +i | |||||
| +1 | +i | -1 | -i | +1 | +i | -1 | -i | |
| +1 | +i | -1 | -i |
And the twiddles are .
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.
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.
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.
All source code is available here.
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! 😩)