在软件上可以轻易地计算上百万点FFT,因为PC或服务器的内存足够大,但是在硬件电路(ASIC或FPGA)中,RAM非常稀有且昂贵,直接做如此大规模的FFT是不现实的。我们可以把大规模的FFT分解为两个维度上的小规模FFT,从而解决RAM不足的问题,这个算法叫做Cooley-Tukey算法。


1. 数学原理

对长度为$N$的原始信号(复信号)$x[n]$直接做离散傅里叶变换,结果如下

$$ X[f] = \sum_{n = 0}^{N-1} x[n] e^{-j\frac {2\pi}N fn} $$

将原始信号的数据序列长度分解为$N = N_1 \cdot N_2$,按列优先填充的顺序,将数据序列重排为$N_1 \times N_2$的矩阵。我们要计算的离散傅里叶变换序列,则是按行优先的顺序,排列为同样纬度$N_1 \times N_2$的矩阵。(为什么要这样定义?见后文物理直觉章节)

$$ \begin{matrix} \begin{bmatrix} x[0] & \dots & x[N - 1] \end{bmatrix} & \Rightarrow & \begin{bmatrix} x[0] & \dots & x[(N_2 - 1)N_1] \\ \vdots & & \vdots \\ x[N_1 - 1] & \dots & x[N_2 N_1 - 1] \\ \end{bmatrix} & \Rightarrow & \begin{bmatrix} X[0] & \dots & X[N_2 - 1] \\ \vdots & & \vdots \\ X[(N_1 - 1)N_2] & \dots & X[N_2 N_1 - 1] \\ \end{bmatrix} \\ time\ domain(N) & & time\ domain(N_1 \times N_2) & & frequency\ domain(N_1 \times N_2) \end{matrix} $$

信号数据序列的索引$n$可以表示为$n = p + q N_1$ ,傅里叶变换频谱的索引$f$可以表示为$f = rN_2 + s$,其中整数$p, r \in [0, N_1)$和$q, s \in [0, N_2)$分别表示矩阵的行和列。将展开后的索引代入到离散傅里叶变换中,

$$ X[rN_2 + s] = \sum_{p = 0}^{N_1 - 1} \sum_{q = 0}^{N_2 - 1} x[p + qN_1] e^{-j \frac {2\pi}{N_1 N_2} (pr N_2 + qs N_1 + ps + qr N_1 N_2)} $$

将指数中的常数和索引分开,适配求和次序,并化简,可以得到

$$ X[rN_2 + s] = \sum_{p = 0}^{N_1 - 1} \left\{ \left\{ \sum_{q = 0}^{N_2 - 1} x[p + qN_1] e^{-j \frac {2\pi}{N_2} qs} \right\} \cdot e^{-j \frac {2\pi}{N_1 N_2} ps} \right\} e^{-j \frac {2\pi}{N_1} pr} $$

其中,最内层的求和是对矩阵的行做DFT,定义原始矩阵按行做DFT之后的矩阵为$F$,$F[p, s]$表示其第$p$行第$s$列的值

$$ F[p, s] = \sum_{q = 0}^{N_2 - 1} x[p + qN_1] e^{-j \frac {2\pi}{N_2} qs} $$

定义相位旋转因子底数$W_N$为

$$ W_N = e^{-j \frac {2\pi}{N_1 N_2}} $$

那么原始DFT可化简为

$$ X[rN_2 + s] = \sum_{p = 0}^{N_1 - 1} ( F[p, s] \cdot W_N^{ps} ) e^{-j \frac {2\pi}{N_1} pr} $$

定义矩阵$F$的每个元素乘以相位旋转因子之后的矩阵为$G$,$G[s, p]$表示其第$p$行第$s$列的值

$$ G[p, s] = F[p, s] \cdot W_N^{ps} $$

代入到原始DFT中,可得

$$ X[rN_2 + s] = \sum_{p = 0}^{N_1 - 1} G[p, s] \cdot e^{-j \frac {2\pi}{N_1} pr} $$

而这其实是对矩阵$G$的每一列做DFT。将最终的矩阵$X$按行优先填充的方式还原为长度为$N = N_1 \times N_2$的序列,就得到了原始信号数据序列的DFT频谱。

$$ \begin{matrix} \begin{bmatrix} X[0] & \dots & X[N_2 - 1] \\ \vdots & & \vdots \\ X[(N_1 - 1)N_2] & \dots & X[N_2 N_1 - 1] \\ \end{bmatrix} & \Rightarrow & \begin{bmatrix} X[0] & \dots & X[N - 1] \end{bmatrix} \\ frequency\ domain(N_1 \times N_2) & & frequency\ domain(N) \end{matrix} $$


2. 物理直觉

TODO


3. 算法总结

  1. 将长度为N(N = R x C)的数据序列分解为R段长度为C的字序列,按照行优先的顺序排列成R x C的矩阵。
  2. 按照列优先的顺序对矩阵的每一列做R点FFT。
  3. 矩阵的每个元素(索引为r, c)乘以相位旋转因子${W_N}^{rc}$。
  4. 按照行优先的顺序对矩阵的每一行做C点FFT。
  5. 矩阵转置。
  6. 将矩阵的每一行拼接起来,还原出长度为N点频谱。
  7. 若上述过程中出现的FFT点数仍然过大,则递归进行二维分解,直至完成当前的FFT计算。

4. 代码示例

以完成2048点FFT为例,将其排列为为32 x 64的矩阵。

import numpy as np

def decompose_fft_demonstration():
    fs = 2048.0
    N = 2048
    R, C = 32, 64
    
    # signal
    t = np.arange(N) / fs
    signal = 1.0 * np.exp(1j * 2 * np.pi * 50 * t) + \
             0.5 * np.exp(1j * 2 * np.pi * 120 * t) + \
             0.1 * (np.random.randn(N) + 1j * np.random.randn(N))

    # ==========================================
    # just FFT
    # ==========================================
    fft_ref = np.fft.fft(signal)

    # ==========================================
    # Cooley-Tukey
    # ==========================================
    
    matrix = signal.reshape(R, C)

    matrix_step1 = np.fft.fft(matrix, axis=0)

    k_r = np.arange(R).reshape(-1, 1)
    c   = np.arange(C).reshape(1, -1)
    twiddle = np.exp(-1j * 2 * np.pi * k_r * c / N)
    matrix_step2 = matrix_step1 * twiddle

    matrix_step3 = np.fft.fft(matrix_step2, axis=1)

    fft_decomposed = matrix_step3.T.reshape(-1)

    # ==========================================
    # compare
    # ==========================================
    error = np.linalg.norm(fft_ref - fft_decomposed)
    print(f"Total error: {error:.10e}")
    
    is_close = np.allclose(fft_ref, fft_decomposed)
    print(f"close? {'yes' if is_close else 'no'}")
    
    return fft_ref, fft_decomposed

fft_ref, fft_dec = decompose_fft_demonstration()

可以对比2个频谱fft_reffft_decomposed,可以发现它们是一致的。