非再帰FFT

再帰FFTを自分なりに解釈してみた。


FFT

長さ2^nの配列A2^n-1多項式\displaystyle f(x) = \sum_{0 \le k < 2^n} A_k x^kと対応付けられる。\zeta12^n乗根として、配列\left(f(\zeta^0), f(\zeta^1), f(\zeta^2), \ldots, f(\zeta^{2^n-1})\right)を、すなわちx=\zeta^0, \zeta^1, \zeta^2, \ldots, \zeta^{2^n - 1}におけるfの評価値を求めたい。

fの偶数次、奇数次の係数を取ってくることで作られる2つの2^{n-1}-1多項式
\begin{align*}
f_0 (x) &= \sum_{0 \le k < 2^{n-1}} A_{2k} x^k\\
f_1 (x) &= \sum_{0 \le k < 2^{n-1}} A_{2k+1} x^k
\end{align*}
を用いることで、f(x)
\begin{align*}
f(x) = f_0(x^2) + x f_1(x^2)
\end{align*}
と分けられる。f_0, f_1に対してFFTをすることで得られるx=(\zeta^2)^0, (\zeta^2)^1, (\zeta^2)^2, \ldots, (\zeta^2)^{2^{n-1}-1}におけるf_0,f_1の評価値を用いることで、各f(\zeta^i)は以下の式から計算できる。
\begin{align*}
f(\zeta^i) &=
\begin{cases}
f_0\left((\zeta^2)^i\right) + \zeta^i f_1\left((\zeta^2)^i\right) & (0 \le i < 2^{n - 1})\\
f_0\left((\zeta^2)^{i - 2^{n-1}}\right) - \zeta^{i - 2^{n-1}} f_1\left((\zeta^2)^{i - 2^{n - 1}}\right) & (2^{n - 1} \le i < 2^n)
\end{cases}
\end{align*}


行列

もう少し機械的に扱いたいので、上の操作が何をやっているかを行列の言葉で表してみる。行列F_n
\begin{align*}
F_n=(\zeta^{ij})_{(i,j)}=
\begin{pmatrix}
\zeta^0 & \zeta^0 & \zeta^0 & \cdots & \zeta^0 \\
\zeta^0 & \zeta^1 & \zeta^2 & & \zeta^{2^n-1} \\
\zeta^0 & \zeta^2 & \zeta^4 & & \zeta^{2(2^n - 1)}\\
\vdots & & & \ddots & \vdots \\
\zeta^0 & \zeta^{2^n-1} & \zeta^{2(2^n-1)} & \cdots & \zeta^{(2^n-1)(2^n-1)}
\end{pmatrix}
\end{align*}
と定めてAを行ベクトルと見なすと、FFTAA F_nに変換する操作と言うことができる。

上で説明したFFT

  1. Aをインデックスの偶奇で2つに分けてそれぞれFFTを行う
  2. それらの結果を組み合わせて答えを求める
という操作をしていた。例えばn=3の場合、これは次のような行列の分解に対応する。
\begin{align*}
F_3 &=
\begin{pmatrix}
\zeta^{0} & \zeta^{0} & \zeta^{0} & \zeta^{0} & \zeta^{0} & \zeta^{0} & \zeta^{0} & \zeta^{0} \\
\zeta^{0} & \zeta^{1} & \zeta^{2} & \zeta^{3} & \zeta^{4} & \zeta^{5} & \zeta^{6} & \zeta^{7} \\
\zeta^{0} & \zeta^{2} & \zeta^{4} & \zeta^{6} & \zeta^{8} & \zeta^{10} & \zeta^{12} & \zeta^{14} \\
\zeta^{0} & \zeta^{3} & \zeta^{6} & \zeta^{9} & \zeta^{12} & \zeta^{15} & \zeta^{18} & \zeta^{21} \\
\zeta^{0} & \zeta^{4} & \zeta^{8} & \zeta^{12} & \zeta^{16} & \zeta^{20} & \zeta^{24} & \zeta^{28} \\
\zeta^{0} & \zeta^{5} & \zeta^{10} & \zeta^{15} & \zeta^{20} & \zeta^{25} & \zeta^{30} & \zeta^{35} \\
\zeta^{0} & \zeta^{6} & \zeta^{12} & \zeta^{18} & \zeta^{24} & \zeta^{30} & \zeta^{36} & \zeta^{42} \\
\zeta^{0} & \zeta^{7} & \zeta^{14} & \zeta^{21} & \zeta^{28} & \zeta^{35} & \zeta^{42} & \zeta^{49}
\end{pmatrix} \\
&=
\left(
\begin{pmatrix}
(\zeta^2)^{0} & & (\zeta^2)^{0} & & (\zeta^2)^{0} & & (\zeta^2)^{0} & \\
& & & & & & & \\
(\zeta^2)^{0} & & (\zeta^2)^{1} & & (\zeta^2)^{2} & & (\zeta^2)^{3} & \\
& & & & & & & \\
(\zeta^2)^{0} & & (\zeta^2)^{2} & & (\zeta^2)^{4} & & (\zeta^2)^{6} & \\
& & & & & & & \\
(\zeta^2)^{0} & & (\zeta^2)^{3} & & (\zeta^2)^{6} & & (\zeta^2)^{9} & \\
& & & & & & &
\end{pmatrix}
+
\begin{pmatrix}
& & & & & & & \\
& (\zeta^2)^{0} & & (\zeta^2)^{0} & & (\zeta^2)^{0} & & (\zeta^2)^{0} \\
& & & & & & & \\
& (\zeta^2)^{0} & & (\zeta^2)^{1} & & (\zeta^2)^{2} & & (\zeta^2)^{3} \\
& & & & & & & \\
& (\zeta^2)^{0} & & (\zeta^2)^{2} & & (\zeta^2)^{4} & & (\zeta^2)^{6} \\
& & & & & & & \\
& (\zeta^2)^{0} & & (\zeta^2)^{3} & & (\zeta^2)^{6} & & (\zeta^2)^{9}
\end{pmatrix}
\right) \\
& \qquad \begin{pmatrix}
1&&&& 1&&& \\
\zeta^0&&&& -\zeta^0&&& \\
&1&&& &1&& \\
&\zeta^1&&& &-\zeta^1&& \\
&&1&& &&1& \\
&&\zeta^2&& &&-\zeta^2& \\
&&&1& &&&1 \\
&&&\zeta^3& &&&-\zeta^3 \\
\end{pmatrix}
\end{align*}
このまま色々弄っていくのはつらいので、もうちょっと分かりやすくこれらの行列を表してみたい。


変換を扱いやすくする

まず以下のように記法を定めておく。

  • k bit整数を考えるとき左方をLSBとする。例えば6を表す5 bit整数は01100となる。
  • k bit整数iのbit 0i_0、bit [1, k)i_{[1, k)}などと書くことにする。
  • 適宜bitの連結をそのままつなげることで表すことにする。例えばk bit整数iに対し0iと書いたらbit 00、bit [1, k+1)iであるk+1 bit整数を表すとする。
  • iのbit reversalを\overline{i}と書くことにする。例えば\overline{01100} = 00110となる。
  • 12^n乗根を1^{0.\overbrace{\text{$00 \cdots 0$}}^{n - 1}1}と書くことにする。例えば1を表す3 bit整数i=100を用いて18乗根は1^{0.\overline{i}}と書ける。

(操作1)、(操作2)を表す行列を次のように書くことにする。

(操作1)について

配列Aのサイズは2^nなので各インデックスはn bit整数と見なせる。また配列Aはペア(i, A_i)の集合と見なせる。「偶奇で分けてFFTを行う」というのは「bit 0の値ごとにグループ分けし、各グループ内でbit [1, n)をインデックスに用いてFFTを行う」ことであることから、その行列をと書くことにする。

(操作2)について

このパートはさらに2つの変換に分解できる。
  1. n-1 bit整数iに対し、A_{0i}A_{1i}A_{0i} + 1^{0.0\overline{i}}A_{1i}A_{0i} - 1^{0.0\overline{i}}A_{1i}に写す。
  2. インデックスを1個分左巡回シフトする。つまりAをペア(i, A_i)の集合と見た時、各(i_0 i_{[1,n)}, A_i)(i_{[1,n)} i_0, A_i)に写した集合に変える。
(操作2-1)を表す行列を、(操作2-2)を表す行列をと書くことにする。

補足

クロネッカー積を用いてF_{n-1} \otimes I_2とも書ける。ただしはある行列X, T_{n-1}を用いてT_{n-1} \otimes Xと書けるという主張ではなく、インデックスのbit 0Xの箇所が担当しているという気持ちで書いている。

変換を変形していく

これらを用いると、F_n

と表せる。`スワップ'みたいなことを繰り返してを前方に持ってくることを考えてみる。はインデックスのbit列を左巡回シフトする変換であることを踏まえると、F_nは以下のように変形できる。

例えばn=4の場合、これを再帰的に用いると次のように変形できる。

ここで、はbit reversalを表す変換になっている。これをと表すことにすると、

と表せる。のような変換がバタフライ演算と呼ばれる。

bit reversalを右に

以上からbit reversal + バタフライ演算を行うことで非再帰FFTが実装できるが、ここでbit reversalを後ろに持っていくことを考えてみる。がインデックスのbit列を逆順にする変換であることを踏まえると、F_4は以下のように変形できる。

ただし、\overline{T_i}T_iとは逆順にインデックスのbit列を扱うようにしたものとする。さっきは変換のときに1^{0.0\overline{i}}を掛けていたが、今度は逆順に扱うようになったので1^{0.0i}が掛けられるようになる。例えばは、A_{1010}A_{1110}A_{1010} + 1^{0.010}A_{1110}A_{1010} - 1^{0.010}A_{1110}に写す。

一番最後に置かれたbit reversalは評価値を正しい順番に並び替えているだけなので、その操作を無視しても畳み込み定理は依然成り立つ。したがって畳み込みの実装において無視してしまって良い。ただし原子根を逆元に取り替えるだけでは逆変換とはならなくなる。逆変換をするには素直に巻き戻しをすれば良い。つまり

の表す変換をすれば良い。

またこのとき、変換後にインデックスiに来る値はfx=1^{0.i}における評価値になる。これを踏まえての意味を考えてみる。k bit整数 i に対し、2^{n-k}-1多項式f_i
\begin{align*}
f_i(x) = \sum_{j: n - k \text{ bit整数}} A_{ij}x^j
\end{align*}
と定める*1。例としてインデックス1011における値の変遷に注目してみると、f_{1011}(1^{0.}) \mapsto f_{101}(1^{0.1}) \mapsto f_{10}(1^{0.11}) \mapsto f_{1}(1^{0.011}) \mapsto f(1^{0.1011})と変換されていき、fの添字が1の肩に移っていく感じになっている。

逆にこのように変換を進めていこうという気持ちになることで、この非再帰FFTを直接導ける。例えばfの添字のbit長を2から1にするステップにおいてf_{10}(1^{0.11})f_{11}(1^{0.11})からf_{1}(1^{0.011})f_{1}(1^{0.111})を求めるには、今f_{1}(x) = f_{10}(x^2) + x f_{11}(x^2)が成り立つので、
\begin{align*}
f_{1}(1^{0.011}) &= f_{10}(1^{0.11}) + 1^{0.011} f_{11}(1^{0.11}) \\
f_{1}(1^{0.111}) &= f_{10}(1^{0.11}) - 1^{0.011} f_{11}(1^{0.11})
\end{align*}
のようにすれば良い。

ACL

ACLのbutterflyもこのような変換を行っている。高速化のために可能であれば2 bit分ずつ処理するようになっているが、ここでは1 bitバージョンに注目する。

int p = 1 << (h - len - 1);
mint rot = 1;
for (int s = 0; s < (1 << len); s++) {
    int offset = s << (h - len);
    for (int i = 0; i < p; i++) {
        auto l = a[i + offset];
        auto r = a[i + offset + p] * rot;
        a[i + offset] = l + r;
        a[i + offset + p] = l - r;
    }
    if (s + 1 != (1 << len))
        rot *= info.rate2[bsf(~(unsigned int)(s))];
}

h-lenが現在の添字のbit長、rotが1^{0.0s}になっている。

例えば\verb|h| = 8, \verb|len| = 3のとき、s000, 100, 010, 110, 001, 101, 011, 111の順に回る。そのループ内において各4 bit整数iに対しl = f_{i0}(1^{0.s})r = f_{i1}(1^{0.s})f_i(1^{0.0s})f_i(1^{0.1s})に写している。

rot *= info.rate2[bsf(~(unsigned int)(s))];

のパートが複雑に見えるが、これは単に1^{0.0s}から1^{0.0(s+1)}を計算している。
例えばs = 110のとき、1^{0.0s}から1^{0.0(s+1)}を、つまり1^{0.0110}から1^{0.0001}を得るには、1^{0.0110}に対して1^{0.011}の逆元と1^{0.0001}の積を掛けてやればよい。繰り上がりが止まるbitの位置がbsf(~(unsigned int)(s))によってbit 2と分かる。info.rate2[2]には1^{0.011}の逆元と1^{0.0001}の積が前計算されて入っており、それが参照される。

2 bitバージョンについては、1 bitバージョン2回と考えるほかに、f_{00}(1^{0.s})f_{10}(1^{0.s})f_{01}(1^{0.s})f_{11}(1^{0.s})f(1^{0.00s})f(1^{0.10s})f(1^{0.01s})f(1^{0.11s})に写すようなことをしていると考えることもできる。

転置ver.

F_nは対称行列なので転置しても同じ変換を表す。例えば

の転置を取ることで

を得る。ここで、の転置行列とする。転置を取ることは重みをそのままに「入口」と「出口」を交換することと見なせる。A_{i0}A_{i1}A_{i0} + 1^{0.0\overline{i}}A_{i1}A_{i0} - 1^{0.0\overline{i}}A_{i1}に写す変換なので、これは以下のような回路を表す。

よってこれの転置となる回路は

となり、A_{i0}A_{i1}A_{i0} + A_{i1}1^{0.0\overline{i}}(A_{i0} - A_{i1})に写す変換になっている。

これもbit reversalが最後に来ているので無視できる。こっちの方針で実装すると以下のようになる。

int p = 1 << (len - 1);
const mint root_len = info.root[len];
for (int s = 0; s < (1 << (h - len)); s++) {
    mint rot = 1;
    int offset = s << len;
    for (int i = 0; i < p; i++) {
        auto l = a[i + offset];
        auto r = a[i + offset + p];
        a[i + offset] = l + r;
        a[i + offset + p] =
            (unsigned long long)(mint::mod() + l.val() - r.val()) *
            rot.val();
        rot *= root_len;
    }
}

空間的局所性を高めるために2重ループの内側で下方bitを回すようにしている。そのため二重ループ内でrotを毎回更新している。手元でACLの1 bitバージョンとこれを比較したが、長さ2^{20}のときに前者は28066383 \,\mathrm{ns}、後者は37628898 \,\mathrm{ns}になって、ACLの方が速い。

*1:ijはbitの連結の意味