Haskellの「fib = 1:1:zipWith (+) fib (tail fib)」はとても遅い
ラボの昼休みに光成さん、中谷さんとご飯を食べながら話した内容を一応ざっくりとまとめておく。
発端はたしか最近Haskellを勉強の光成さんが、Haskellのかっこいいsieveは実はとても遅い(俺は Haskell の sieve についてとんでもない思い違いをしていたようだ...)という話を見て、同様にかっこいいけど遅い下記のフィボナッチ数列の定義の速度を調べてみたら2.5乗くらいのオーダーになっていたという話だったかと思う。
fib = 1:1:zipWith (+) fib (tail fib)
僕も確認するために、コマンドライン引数でNを与えられるフィボナッチ数列のN番目を求めるコードを書いた。
import System fib = 1:1:zipWith (+) fib (tail fib) main = do args <- getArgs print $ (0 *) $ (fib !!) $ read $ args !! 0
(0 *)は大量の数字の列をコンソールに表示するところで時間がかかると測りたいものが測れないからつけてある。人間には常に0になるのがわかるけどもHaskell処理系にはわからないので、きちんとfibを計算してから0を掛けて結果の0を表示してくれる。これをghc -O3でコンパイルする。そのまま走らせると「Stack space overflow」というエラーで死んでしまうので+RTS -K200Mをつけて走らせる。
$ time ./a.out 100000 +RTS -K200M 0 real 0m1.080s user 0m1.045s sys 0m0.029s $ time ./a.out 200000 +RTS -K200M 0 real 0m6.420s user 0m6.313s sys 0m0.072s $ time ./a.out 300000 +RTS -K200M 0 real 0m18.477s user 0m18.264s sys 0m0.145s
これはおおよそO(N ** 2.6)のオーダーである。ちなみにfib(1000000)は5分経っても終わらなかったので中断した。
>> log(6.42 / 1.08) / log(2) 2.5715419849588343 >> log(18.477 / 6.42) / log(1.5) 2.6071505945442417
この時点で「次の定義は線形時間でフィボナッチ数列のリストを生成する」っていうWikipediaの記述は事実に反するということがわかった。それで終りにしてもよかったのだけどもまだお弁当が残っていたので「いつからこの間違った記述があるんだろう」という話題に。
それは履歴を見ればわかりますよ、と見つけてきたのがこれ。2006年7月5日の更新。そしてこの更新のコメントで英語版からの翻訳であることがわかる。当時の英語版を見ると確かにlinearって書いてある。そして2010年の2月に修正されている。"linear time"から"efficient implementation"へ。efficientって何に比べてだよ…。
ちなみにPythonで素朴にループするコードを書いてみる。
import sys def fib(n): if n == 0 or n == 1: return 1 x = y = 1 while n > 1: x, y = y, x + y n -= 1 return y print 0 * fib(int(sys.argv[1]))
これの実行結果は下のようになる。驚いたことにN = 100000でHaskellよりわずかに速く、N = 300000だと2倍以上速い。むしろコンパイルしてあるくせにインタプリタのPythonに負けるくらいHaskellのコードが遅い。
$ time python fib.py 100000 0 real 0m0.968s user 0m0.937s sys 0m0.013s $ time python fib.py 200000 0 real 0m3.687s user 0m3.658s sys 0m0.016s $ time python fib.py 300000 0 real 0m8.205s user 0m8.169s sys 0m0.020s $ time python fib.py 1000000 0 real 1m31.549s user 1m29.910s sys 0m0.148s
これはおよそO(N ** 2)のオーダーだ
>> log(3.687 / 0.968) / log(2) 1.9293684638192232 >> log(8.205 / 3.687) / log(1.5) 1.9728716214877173 >> log(91.549 / 8.205) / log(10 / 3.0) 2.0034760044903011
なぜO(N)ではなく2乗のオーダーになるかと言うと、フィボナッチ数がO(1.618 ** N)のオーダーで増えるからその数の桁数はO(N)のオーダーで増え、1回の足し算にかかる時間がO(N)のオーダーで増えるから。だから順番に足していく方式では2乗のオーダーまではしかたない。だけどHaskellの2.6乗の0.6はどこから来たんだ。
Haskellでefficientなコードを書いてみた。泥臭く自前で末尾再帰している。
import System fib 0 = 1 fib 1 = 1 fib n = fib' n 1 1 fib' 1 _ y = y fib' n x y = fib' (n - 1) y (x + y) main = do args <- getArgs print $ (0 *) $ fib $ read $ args !! 0
これならPythonの素朴なコードに比べて10倍速いし、ちゃんと2乗のオーダーだ。
$ time ./a.out 100000 0 real 0m0.088s user 0m0.075s sys 0m0.006s etude$ time ./a.out 200000 0 real 0m0.346s user 0m0.329s sys 0m0.011s etude$ time ./a.out 300000 0 real 0m0.814s user 0m0.789s sys 0m0.019s
>> log(0.346 / 0.088) / log(2) 1.9751966089994275 >> log(0.814 / 0.346) / log(1.5) 2.1099758618849926
まとめ
- Haskellの有名なフィボナッチ数列の定義「fib = 1:1:zipWith (+) fib (tail fib)」はWikipediaに「線形時間」と書かれているが事実ではない
- 英語版には「An efficient implementation」と書かれているが、N = 100000でインタプリタ言語であるPythonより遅く、O(N ** 2.6)である。
- この定義より10倍以上速く、O(N ** 2)であるような実装も容易
というわけで「fib = 1:1:zipWith (+) fib (tail fib)」は「Haskellらしさをアピールできるトリッキーなコードであるが、速度は遅い」と認識するのが正しい。
まとめ追記
- fib = 1 : 1 : zipWith (+) fib (tail fib) が遅いかどうかは、使い方に依存する - www.kotha.netの裏
- fibを先頭から順に使って行った場合(例:sum (take 300000 fib))の方が、fib !! 300000よりずっと速いという話
- 僕の書いた早いバージョンのfibは正格性が(コード上には明記されていないが)コンパイラによって推論されているという話
- melancholic afternoon
- 巨大なリストから要素1つだけを取り出す時と2つ以上取り出すときではかかる時間や消費メモリがぜんぜん違うという話
- Haskellの神話 - あどけない話
- 正格評価するzipWith'を作ればよい、という話など