バタフライ図も行列も見たくない人のための高速フーリエ変換
高速フーリエ変換(FFT)を実装しようと思って、まず理論を知らねばと調べたところ、まったくチンプンカンプンだったのですが、とりあえず、なんとか証明を追うことができました。というわけで、バタフライ演算の図を見てもなんもわからん人が FFT を実装するための資料として、この記事を書き残したいと思います。
参考にした資料は、富永氏による GNU Scientific Library に関するドキュメントの翻訳の「FFT アルゴリズム」の「基数2の時間空間法」です。周波数空間については追っていません(どっちでやっても計算量は同じなので)。
なぜ離散フーリエ変換(DFT)を高速化できるのか?
この記事内の式で使用する文字を定義しつつ、 FFT の概要を紹介していきます。
とりあえず、 DFT の式を出しておきましょう(フーリエ変換自体を解説できるほどの理解はないので、ごめんなさい)。
記号 | 意味 |
---|---|
入力データ(実数列) | |
入力データ数 | |
出力のインデックス(周波数ビン) | |
入力のインデックス(時刻) | |
入力データの 番目(0オリジン) | |
虚数単位 |
周波数ビン に対するフーリエ係数 を求めるには、データ数だけループを回す必要があります。これをすべての周波数ビンについて求めるならば、二重ループが必要になります。つまり、計算量は であり、とても重い処理だということです。 FFT では、これを頑張って まで下げます。これでなんとか大量のデータを実用的に解析することができるというわけです。
次の説明に行く前に、指数関数の部分を文字で置き換えましょう。いわゆる回転因子というやつです。
指数法則が効くので、例えば となります。
回転因子の意味ですが、複素平面上の単位円を何個の点に分割するか、ということです。例えば、 ならば、次の図のように分割され、 0 乗のとき 1 で、それから 1 乗されるごとに時計回りに移動していきます。 1 周まわり終わると、また同じところをぐるぐる回るだけなので、 だったりします(もっと一般化すると )。
今導入した を使って DFT の式を書き直すと、次のようになります。
さて、新たな文字の導入も終わったので、計算量を減らすトリックの紹介です。 DFT の計算は、 を偶数と奇数に分けて考えると、ループ回数を半分にできるという性質があります*1。というわけで、式変形で、偶数と奇数に分けてみましょう。ただし、 が 2 の倍数でないと添え字が合わなくなってしまいます(これを繰り返すので、データ数は 2 のべき乗である必要があります)。第1項が偶数のときの計算、第2項が奇数のときの計算になっています。
ここからさらに式変形をしていきます。 は指数法則で と分解できます。そして、 のほうを変形していきます。
といった感じで ということが判明したので、 です。
さらに のほうも、指数法則で変形していきます。
これで、もう一度 DFT の式を書き直します。
これを、最初の DFT の式 と見比べてみてください。長さが半分になった DFT を 2 個足し合わせた形 になっています! 各項の DFT の式もさらに半分に分けることができるので、どんどん分割していけます。例として、入力データを 8 個与えた場合を考えてみましょう。
いい感じに分割できました。この分割する作業を 回行い、すべての周波数ビン に対して計算するので、計算量が になります。
分割とインデックスのビットとの関係
今の分割を図に表すと、次のような感じになっています。
説明の都合上、一番下を0段目と呼ぶことにしました。では、0段目の並び順には、どのような規則があるのでしょうか? それは、インデックスを2進法で表すとわかります。
10進 | 0 | 4 | 2 | 6 | 1 | 5 | 3 | 7 |
---|---|---|---|---|---|---|---|---|
2進 | 000 | 100 | 010 | 110 | 001 | 101 | 011 | 111 |
これはちょうど、0~7を2進法で表したときのビットを、左右反転させた並びになっています。
0段目から1段目を求める
ここからは、分割した結果このようにな並びになったのではなく、最初からこの並び順になっているものとして、話を進めます(つまり事前にこのように並べ替えます)。この並び順から、 を求めるには、分割結果の式から、次のようにすればよいことがわかります。
求める結果は、すべての周波数ビン についてですので、そこに注目しながら考えていきます。
まずは、0段目のデータを使って1段目を求めることを考えます。いったん元の順番のことは忘れて、 とは、周波数ビン について計算するときに、 段目の左から 番目(0オリジン)のデータを表すものとします。図通りに式を書くならこんな感じになりそうですね。
( は、 にまったく関係なく同じ値を返すので と表しています。)
ここで、 について観察すると、 なので、 を2進法で表して としたとき(以降、2進数を [ ] で表します)、 より、 ということがわかります。つまり、すべての について求めるとしても、 しか関係ありません。
このことから、すべての について を求めた結果は、次のように格納することができます。ここでは、 を利用しています。
→ ↓ |
0 = [00] | 1 = [01] | 2 = [10] | 3 = [11] |
---|---|---|---|---|
0 | ||||
1 |
必要な容量は、表の大きさから 8 個、すなわち入力データ数と同じです。そして、2段目を計算するとき、0段目のデータははもう必要ありません。というわけで、0段目のデータが入っていた領域を、1段目の計算結果を入れておく場所として再利用しようということを考えます。
ある について、 それぞれについて求めるとき、表内の式に表れるインデックスを2進法で表すと、次の法則があります。
この式から次のことが言えます:
にアクセスするのは を求めるときだけ。
ここで、作業用のメモリを としましょう。1段目を計算する前は です。 を求めた後、もう にアクセスすることはないということは、これと同じ領域 に結果を保存しておけばよいということになります。といっても、式で書いていると、メモリの状況をうまく表現できないので、手続き的に書き表してみます。一時変数 を用いて
といった感じの手順で、うまくメモリを上書きできます。図で表すとこんな感じでしょうか。
1段目から2段目を求める
次の段も同じようにできそうなので、やっていきましょう。図から式を抽出すると、こんな感じです。
2段目は2個しかないので、 です。データ8個だと簡単になりすぎてしまって、一般化まで持っていくのが大変ですが、データ数を増やすと図がつらくなるので許してください。
1段目と同様に、 は、回転因子の周期によって、次の変形ができます。
さらにもう一歩、指数法則で分解しておきます。
というわけで、これと、 を利用すると、 は、次のようになります。
さらに、 で場合分けすると、 より、次のようになります。
では、すべての について計算してみましょう。
→ ↓ |
0 | 1 |
---|---|---|
0 = [00] | ||
1 = [01] | ||
2 = [10] | ||
3 = [11] |
1段目の結果はひとつの配列(作業用メモリ)に保存されているのでした。したがって、この表から、同じメモリへのアクセスする計算が2つずつあることがわかります。まとめると、こういうことです。
にアクセスするのは、 を求めるときだけ。
メモリのインデックスの関係から
なので、 のデータを使って計算し、その結果をまた に保存すればやりくりできそうです。ちょうど、 について、場合分けしてあり、 のそれぞれについて一緒に計算できるよう準備をしておいたので、あとは結果を のように保存すればよいわけです。手続きで考えると、次のようになります。
m-1段目からm段目を求める
さて、大体一般化する準備はできたと思います。まず計算の図から明らかっぽいのは、次の式です。
ここで、 は、1段進むごとに半分になるので、 です。
そして、回転因子の周期を使った変形ができるのでした。
さらに、 を外に出します。
このようにすることで、 次のような場合分けができるようになります。
ここまでの変形をまとめると、次のようになります。
このとき、 は について、下位 ビットしか利用しないということがわかります。ということは、 では、下位 ビットしか利用しないですね。というわけで、ビットごとに考えるとこうなります。
さらに、 の場合分けによって、関数を分けます。
また、 のほうもビットで考えると、 のとき、 とします。
それでは、またメモリアクセスについて考えます。 の式から、 を用いて、次のことが言えます。
にアクセスするのは、 を求めるときだけ。
2段目までの議論から、天下り式に の結果を保存する場所 を設定します*2。上位ビットが 、下位ビットが になります。
この結果から、どの においても、 の大きさは ビット、すなわち であればよいということがわかります。
では、 をメモリアクセスとして書き直してみます。
この作業用メモリのインデックスは、ちょうど に対応するインデックスと同じところです。したがって、2段目までと同じように、2つ取り出し、計算して、格納し直す方法が使えそうです。そうすると、もう見慣れた手続きですが、このようになりますね(あまりに横に長いので、一時変数を分けました)。
というわけで、1段前の結果を使って、次の段を計算することを一般化することができました。 まで達すると、 のインデックスは、 すなわち となり、すべての周波数ビンについての DFT 結果が に入っている状態になるので、これで処理は完了です。
どうやってプログラムとして実現するか
1からn段目まで順番に計算していくわけですが、今までの話だと、まだプログラムとしてどう書くかが見えていない気がするので、もう少し話をします。
重要なポイントは それぞれの計算を一緒に行ってしまうことで、メモリをうまくやりくりできることでした。逆に言うと、それ以外の数値 は、すべてのパターンについて計算を行う必要があります。したがって、 a と b の二重ループを行います。
は同時に計算を行うので、1段の計算において、 a は までのビットを使って の範囲で動きます。 b は ですね。あとは、 b をビットシフトしたりすれば、インデックスが合います。
そもそも最初に、ビット反転順に並べ替えなきゃいけないじゃん、というのもあります。入力と出力の配列を別々にするならば、簡単なビット演算でビットを反転して、そのインデックスに詰め替えることで実現できます。入力と出力に同じ配列を使う場合は、計算自体と同じようなループを回して値を交換していけばできるのですが、もうちょっと計算量的にマシな方法があるらしいのです。しかし、解説できるほど理解していません……。
解説は以上です。ちゃんとした疑似コードは、最初に挙げた参考資料をご覧ください。「ビットシフトしたり」が掛け算で実現されているくらいで、あまり驚かずに読めるようになっているのではないでしょうか。ただし、文字 b の扱い方は結構違うので、注意してください。この記事では最初からビット反転してあるものとして扱ってしまいましたが、参考資料では、最初の並び順で導出したのち、反転するとメモリの格納の都合上便利といった風に解説されています。