速度と最適化を研究するとき、非常に間違った結果を得るのは非常に簡単です。特に、コンパイラのバージョンとベンチマーク設定の最適化モードについて言及しなければ、1つのバリアントが別のバリアントよりも速いとは言えません。それでも、最新のプロセッサは非常に洗練されており、あらゆる種類のキャッシュは言うまでもなく、ニューラルネットワークベースの分岐予測子を備えています。そのため、注意深く設定しても、ベンチマーク結果はぼやけます。
言われていること...
ベンチマークは私たちの友人です。
criterion
高度なベンチマークツールを提供するパッケージです。私はすぐに次のようなベンチマークを作成しました:
module Main where
import Criterion
import Criterion.Main
-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"
-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse
-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init
butLast2 :: [a] -> a
butLast2 (x : _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"
setupEnv = do
let xs = [1 .. 10^7] :: [Int]
return xs
benches xs =
[ bench "slow?" $ nf myButLast xs
, bench "decent?" $ nf myButLast' xs
, bench "fast?" $ nf myButLast'' xs
, bench "match2" $ nf butLast2 xs
]
main = defaultMain
[ env setupEnv $ \ xs -> bgroup "main" $ let bs = benches xs in bs ++ reverse bs ]
ご覧のとおり、2つの要素を明示的に一度に一致させるバリアントを追加しましたが、それ以外はまったく同じコードです。また、キャッシングによるバイアスを意識するために、ベンチマークを逆に実行します。だから、実行して見てみましょう!
% ghc --version
The Glorious Glasgow Haskell Compilation System, version 8.6.5
% ghc -O2 -package criterion A.hs && ./A
benchmarking main/slow?
time 54.83 ms (54.75 ms .. 54.90 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 54.86 ms (54.82 ms .. 54.93 ms)
std dev 94.77 μs (54.95 μs .. 146.6 μs)
benchmarking main/decent?
time 794.3 ms (32.56 ms .. 1.293 s)
0.907 R² (0.689 R² .. 1.000 R²)
mean 617.2 ms (422.7 ms .. 744.8 ms)
std dev 201.3 ms (105.5 ms .. 283.3 ms)
variance introduced by outliers: 73% (severely inflated)
benchmarking main/fast?
time 84.60 ms (84.37 ms .. 84.95 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 84.46 ms (84.25 ms .. 84.77 ms)
std dev 435.1 μs (239.0 μs .. 681.4 μs)
benchmarking main/match2
time 54.87 ms (54.81 ms .. 54.95 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 54.85 ms (54.81 ms .. 54.92 ms)
std dev 104.9 μs (57.03 μs .. 178.7 μs)
benchmarking main/match2
time 50.60 ms (47.17 ms .. 53.01 ms)
0.993 R² (0.981 R² .. 0.999 R²)
mean 60.74 ms (56.57 ms .. 67.03 ms)
std dev 9.362 ms (6.074 ms .. 10.95 ms)
variance introduced by outliers: 56% (severely inflated)
benchmarking main/fast?
time 69.38 ms (56.64 ms .. 78.73 ms)
0.948 R² (0.835 R² .. 0.994 R²)
mean 108.2 ms (92.40 ms .. 129.5 ms)
std dev 30.75 ms (19.08 ms .. 37.64 ms)
variance introduced by outliers: 76% (severely inflated)
benchmarking main/decent?
time 770.8 ms (345.9 ms .. 1.004 s)
0.967 R² (0.894 R² .. 1.000 R²)
mean 593.4 ms (422.8 ms .. 691.4 ms)
std dev 167.0 ms (50.32 ms .. 226.1 ms)
variance introduced by outliers: 72% (severely inflated)
benchmarking main/slow?
time 54.87 ms (54.77 ms .. 55.00 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 54.95 ms (54.88 ms .. 55.10 ms)
std dev 185.3 μs (54.54 μs .. 251.8 μs)
私たちの「遅い」バージョンはまったく遅くないようです!そして、パターンマッチングの複雑さは何も追加しません。(match2
私がキャッシングの影響によると、2つの連続した実行の間に見られるわずかなスピードアップ。)
より「科学的な」データを取得する方法があり-ddump-simpl
ます。コンパイラーがコードを認識する方法を確認できます。
中間構造の検査は私たちの友人です。
「コア」はGHCの内部言語です。すべてのHaskellソースファイルは、ランタイムシステムが実行する最終的な機能グラフに変換される前に、コアに単純化されます。この中間段階を見るmyButLast
とbutLast2
、同等であることがわかります。名前を変更する段階で、すべての素敵な識別子がランダムに変形されているので、それを見るのには時間がかかります。
% for i in `seq 1 4`; do echo; cat A$i.hs; ghc -O2 -ddump-simpl A$i.hs > A$i.simpl; done
module A1 where
-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"
module A2 where
-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse
module A3 where
-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init
module A4 where
butLast2 :: [a] -> a
butLast2 (x : _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"
% ./EditDistance.hs *.simpl
(("A1.simpl","A2.simpl"),3866)
(("A1.simpl","A3.simpl"),3794)
(("A2.simpl","A3.simpl"),663)
(("A1.simpl","A4.simpl"),607)
(("A2.simpl","A4.simpl"),4188)
(("A3.simpl","A4.simpl"),4113)
と思われるA1
とA4
最も類似しています。徹底的に調べると、実際のコード構造A1
とA4
が同じであることがわかります。ことA2
とA3
の両方が、2つの関数の組成物として定義されているので、似ていることも合理的です。
core
出力を詳細に調べる場合は、やなどのフラグを指定することも意味が-dsuppress-module-prefixes
あり-dsuppress-uniques
ます。彼らはそれをとても読みやすくします。
私たちの敵の短いリストも。
では、ベンチマークと最適化で何がうまくいかないのでしょうか?
ghci
は、インタラクティブなプレイと迅速な反復のために設計されており、Haskellソースを最終的な実行可能ファイルではなく特定のフレーバーバイトコードにコンパイルし、より高速なリロードを優先して高価な最適化を避けます。
- プロファイリングは、複雑なプログラムの個々のビットやピースのパフォーマンスを調べるのに最適なツールのようですが、コンパイラの最適化を大幅に損なう可能性があり、結果は桁違いになります。
- 安全策は、独自のベンチマークランナーを使用して、すべての小さなコードを個別の実行可能ファイルとしてプロファイルすることです。
- ガベージコレクションは調整可能です。ちょうど今日、新しい主要な機能がリリースされました。ガベージコレクションの遅延は、簡単に予測できない方法でパフォーマンスに影響します。
- 先に述べたように、コンパイラーのバージョンが異なると、パフォーマンスが異なるさまざまなコードがビルドされるため、約束をする前に、コードのユーザーがビルドに使用する可能性のあるバージョンを把握し、それをベンチマークする必要があります。
これは悲しそうに見えるかもしれません。しかし、ほとんどの場合、それは実際にはHaskellプログラマに関係するべきことではありません。実話:最近Haskellを学び始めたばかりの友人がいます。彼らは数値積分のためのプログラムを書いていて、カメは遅かった。そこで私たちは一緒に座って、図表などを使ってアルゴリズムのカテゴリ別の説明を書きました。彼らが抽象的な記述に合わせてコードを書き直したとき、それは魔法のように、チーターが速く、メモリもスリムになりました。すぐにπを計算しました。この話の教訓?完璧な抽象的な構造、そしてあなたのコードはそれ自身を最適化します。
init
リストの複数回の「アンパック」を回避するように最適化されています。