怠惰
これは「コンパイラの最適化」ではありませんが、言語仕様によって保証されているため、いつでもそれを期待できます。基本的に、これは、結果で「何かを行う」まで作業が実行されないことを意味します。(意図的に怠惰をオフにするためにいくつかのことの1つを行わない限り)。
これは明らかに、それ自体が全体のトピックであり、SOにはすでにそれに関する多くの質問と回答があります。
私の限られた経験では、コードを怠惰にしたり厳格にしたりすると、これから説明する他のどの要素よりも(時間と空間で)パフォーマンスが大幅に低下します...
厳格な分析
怠惰はそれが必要でない限り仕事を避けることです。コンパイラは、特定の結果が「常に」必要であると判断できる場合、計算を保存して後で実行する必要はありません。より効率的であるため、直接実行するだけです。これは、いわゆる「厳密性分析」です。
落とし穴は、明らかに、コンパイラが何かを厳密にすることができるときを常に検出できるわけではないということです。コンパイラに少しのヒントを与える必要がある場合があります。(厳密な分析が、コア出力をたどること以外に、あなたが思っていることを行ったかどうかを判断する簡単な方法は知りません。)
インライン化
関数を呼び出し、コンパイラが呼び出している関数を判別できる場合、コンパイラはその関数を「インライン化」しようとする可能性があります。つまり、関数呼び出しを関数自体のコピーに置き換えようとします。通常、関数呼び出しのオーバーヘッドはかなり小さいですが、インライン化によって他の方法では実現できなかった他の最適化が行われることがよくあるため、インライン化は大きなメリットになります。
関数がインライン化されるのは、「十分に小さい」場合(またはインライン化を要求するプラグマを追加した場合)のみです。また、関数がインライン化できるのは、コンパイラーが呼び出している関数を判別できる場合のみです。コンパイラが判断できない主な方法が2つあります。
呼び出す関数が別の場所から渡された場合。たとえば、filter
関数がコンパイルされるとき、ユーザーが指定した引数であるため、フィルター述語をインライン化することはできません。
呼び出す関数がクラスメソッドであり、コンパイラーがどの型が関係しているかがわからない場合。たとえば、sum
関数がコンパイルされると、コンパイラは+
関数をインライン化できません。sum
これは、それぞれが異なる+
関数を持ついくつかの異なる数値型で動作するためです。
後者の場合、{-# SPECIALIZE #-}
プラグマを使用して、特定の型にハードコードされた関数のバージョンを生成できます。たとえば、タイプ用{-# SPECIALIZE sum :: [Int] -> Int #-}
にsum
ハードコードされたバージョンをコンパイルします。Int
つまり+
、このバージョンでインライン化できます。
ただし、新しい特殊sum
関数は、コンパイラーがで作業していることを認識できる場合にのみ呼び出されることに注意してくださいInt
。それ以外の場合は、元の多態性sum
が呼び出されます。この場合も、実際の関数呼び出しのオーバーヘッドはかなり小さくなっています。インライン化によって実現できる追加の最適化は、有益です。
共通部分式の除去
コードの特定のブロックが同じ値を2回計算する場合、コンパイラはそれを同じ計算の単一のインスタンスに置き換えます。たとえば、
(sum xs + 1) / (sum xs + 2)
次に、コンパイラはこれを最適化します
let s = sum xs in (s+1)/(s+2)
コンパイラが常にこれを行うと期待するかもしれません。ただし、明らかに一部の状況では、これによりパフォーマンスが低下する可能性があり、パフォーマンスが低下する可能性があるため、GHCが常にこれを行うとは限りません。率直に言って、私はこの背後にある詳細を本当に理解していません。しかし、肝心なのは、この変換が重要である場合、手動で行うことは難しくありません。(そして、それが重要でないなら、なぜあなたはそれについて心配していますか?)
ケース式
以下を検討してください。
foo (0:_ ) = "zero"
foo (1:_ ) = "one"
foo (_:xs) = foo xs
foo ( []) = "end"
最初の3つの方程式はすべて、リストが空でないかどうかを確認します(特に)。しかし、同じことを3回チェックするのは無駄です。幸い、コンパイラーがこれをいくつかのネストされたケース式に最適化するのは非常に簡単です。この場合、次のようなもの
foo xs =
case xs of
y:ys ->
case y of
0 -> "zero"
1 -> "one"
_ -> foo ys
[] -> "end"
これはかなり直感的ではありませんが、より効率的です。コンパイラーはこの変換を簡単に行うことができるため、心配する必要はありません。できるだけ直感的な方法でパターンマッチングを記述してください。コンパイラーは、これを可能な限り高速にするためにこれを再配列および再配置するのに非常に優れています。
融合
リスト処理の標準Haskellイディオムは、1つのリストを取得して新しいリストを生成する関数をチェーン化することです。正規の例は
map g . map f
残念ながら、遅延は不必要な作業のスキップを保証しますが、中間リストのすべての割り当てと割り当て解除はsapパフォーマンスです。「融合」または「森林破壊」は、コンパイラがこれらの中間ステップを排除しようとする場所です。
問題は、これらの関数のほとんどが再帰的であることです。再帰がなければ、すべての関数を1つの大きなコードブロックに圧縮し、その上で単純化子を実行し、中間リストのない本当に最適なコードを生成することは、インライン化の基本的な演習になります。しかし、再帰のため、それは機能しません。
{-# RULE #-}
これを修正するためにプラグマを使用できます。例えば、
{-# RULES "map/map" forall f g xs. map f (map g xs) = map (f.g) xs #-}
これで、GHCがにmap
適用されるのを見るたびにmap
、リストを1回のパスに圧縮して中間リストを削除します。
問題は、これはのmap
後にのみ機能することmap
です。他にも多くの可能性があります-がmap
続きfilter
、がfilter
続きますmap
。それぞれのソリューションを手動でコーディングするのではなく、いわゆる「ストリームフュージョン」が発明されました。これはより複雑なトリックであり、ここでは説明しません。
長い点と短い点:これらはすべて、プログラマーが作成した特別な最適化トリックです。GHC自体は融合について何も知りません。リストライブラリとその他のコンテナライブラリにすべて含まれています。したがって、どの最適化が行われるかは、コンテナーライブラリの作成方法(より現実的には、使用するライブラリを選択する)によって異なります。
たとえば、Haskell '98の配列を使用する場合、いかなる種類の融合も期待しないでください。しかし、vector
ライブラリには広範な融合機能があることを理解しています。それはすべてライブラリに関するものです。コンパイラはRULES
プラグマを提供するだけです。(ちなみに、これは非常に強力です。ライブラリの作成者は、これを使用してクライアントコードを書き換えることができます。)
メタ:
すべてのもの、そしてすべてのもののバランス...