[The code from this article is available as a Gist. I’m going to go on putting the code for these articles up as individual Gists as long as we’re doing experimentation – eventually, there will be a proper repo and a Hackage package for the full “bells and whistles” code.]
The decomposition of the Fourier matrix described in the previous article allows us to represent the DFT of a vector of length by two DFTs of length . If is a power of two, we can repeat this decomposition until we reach DFTs of length 1, which are just an identity transform. We can then use the decomposition
to build the final result up from these decompositions.
At each step in this approach, we treat the even and odd indexed elements of our input vectors separately. If we think of the indexes as binary numbers, at the first step we decompose based on the lowest order bit in the index, at the next step we decompose based on the next lowest bit and so on.
Bit reversal permutation
What overall permutation does performing this decomposition recursively give us? We can work this out by thinking about the permutation matrices from . We know that and the total permutation matrix involved in a recursive decomposition will be
where denotes a block-diagonal matrix composed of blocks of a matrix . For , since we have:
At each step of the Fourier matrix decomposition, we’re splitting between even and odd entries in our input vector, i.e. we’re splitting on the least significant bit of the index of entries in the input vector. Composing those individual even/odd permutations together, we get an overall permutation that puts our input vector into bit reversed order1:
Let’s denote the overall bit-reversal permutation for a vector of length as .
The full algorithm
We can now write as
and denotes the block-diagonal matrix composed of copies of along the diagonal. Each of the multiplications by these block-diagonal matrices involves only a small number of floating point operations, and once we rearrange our input vector in bit-reversed order, we can compose the matrix multiplications to get the final FFT result (this approach, rearranging the input vector then using this hierarchical composition of vector elements to produce the result, is called decimation-in-time in the FFT literature, because we’re progressively splitting the input, which is a vector indexed by time, into even- and odd-indexed entries).
To implement this algorithm in Haskell, we first deal with bit reversal. To begin with, we’ll use the simple approach of producing an index vector with the input vector indices in bit reversed order then using the
backpermute function from
Data.Vector to reorder the input data according to this indexing scheme. Recall that we’re still only dealing with vectors whose lengths are powers of two, so the number of bits we have to deal with in our indices is just : we use
Data.Bits to swap the bit orders around.
The main Cooley-Tukey algorithm takes bit-reversal reordered input vector and iteratively applies the inverse of the unpermuted Danielson-Lanczos decomposition to it, starting with matrix blocks, then and so on up to the final matrix. This iteration is compactly represented by the
recomb function below, which uses the
dl (“Danielson-Lanczos”) helper function to apply one step of the matrix multiplication (which takes far fewer than floating point operations because of the special structure of the block-diagonal matrices we are dealing with).
At each step, we slice the partially processed input vector into sub-vectors of length corresponding to the size of the matrix blocks at this step, then compute the matrix product of the block-diagonal / matrix and the appropriate segment of the data vector.
fft, ifft :: Vector (Complex Double) -> Vector (Complex Double) fft = fft' 1 1 ifft v = fft' (-1) (1.0 / (fromIntegral $ length v)) v fft' :: Int -> Double -> Vector (Complex Double) -> Vector (Complex Double) fft' sign scale h = if n <= 2 then dft' sign scale h else map ((scale :+ 0) *) $ recomb $ backpermute h (bitrev n) where n = length h recomb = foldr (.) id $ map dl $ iterateN (log2 n) (`div` 2) n dl m v = let w = omega m m2 = m `div` 2 ds = map ((w ^^) . (sign *)) $ enumFromN 0 m2 doone v = let v0 = slice 0 m2 v v1 = zipWith (*) ds $ slice m2 m2 v in (zipWith (+) v0 v1) ++ (zipWith (-) v0 v1) in concat $ P.map doone $ slicevecs m v slicevecs m v = P.map (\i -> slice (i * m) m v) [0..n `div` m - 1]
We can use the same tests as we used for the
dft function in the article before last. In GHCi:
> :load FFT-v1.hs [1 of 1] Compiling Main ( FFT-v1.hs, interpreted ) Ok, modules loaded: Main. > fft $ fromList [1,0,0,0,0,0,0,0] fromList [1.0 :+ 0.0,1.0 :+ 0.0,1.0 :+ 0.0,1.0 :+ 0.0, 1.0 :+ 0.0,1.0 :+ 0.0,1.0 :+ 0.0,1.0 :+ 0.0] > defuzz $ fft $ generate 8 (\i -> sin (2 * pi * fromIntegral i / 8))} fromList [0.0 :+ 0.0,0.0 :+ 4.0,0.0 :+ 0.0,0.0 :+ 0.0, 0.0 :+ 0.0,0.0:+ 0.0,0.0 :+ 0.0,0.0 :+ (-4.0)]
The results are the same as for the direct DFT implementation, and further tests reveal that the behaviour of the FFT code is identical to that of the DFT code (apart from small numerical differences due to different order of computation in the two algorithms).
For an input vector of length , the simple DFT implementation multiplies our input vector by the Fourier matrix, an operation requiring multiplication operations and a comparable number of addition operations – we thus expect the time behaviour of the simple DFT to scale as . In the case of the FFT, after permuting the input vector into bit-reversed order, we perform “Danielson-Lanczos” steps, each of which involves multiplication by a block-diagonal matrix, each of whose blocks is composed of diagonal matrices in the / pattern of . These matrix multiplications involve multiplications each, plus additions. We thus expect the time behaviour of the FFT algorithm to scale as .
The difference between and is huge. For , we expect the FFT to be about 100 times faster than the simple DFT matrix multiplication. The ratio is even more in favour of the FFT for larger . It’s pretty fair to say that the invention of the FFT is what has enabled most of the spectral and filtering computations that are ubiquitous in digital signal processing today.
So far, the presentation we’ve followed is pretty much what you can find in more or less any signal processing textbook – everyone explains this powers-of-two algorithm because it’s nice and simple. We’re going to be a lot more ambitious than that though! After looking at a simple application of this code in the next article, we’re going to venture into deeper waters, as we begin exploring what’s needed to lift the “powers of two” restriction on input vector lengths. Conceptually, this turns out to be a relatively straightforward generalisation of the powers-of-two algorithm, but the details are quite delicate and quite interesting.