怠惰
これは「コンパイラの最適化」ではありませんが、言語仕様によって保証されているものなので、いつでもそれが行われることを期待できます。基本的に、これは、結果を「何かする」まで作業が実行されないことを意味します。(意図的に怠惰をオフにするためにいくつかのことの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
残念ながら、怠惰は不必要な作業をスキップすることを保証しますが、中間リストのすべての割り当てと割り当て解除はパフォーマンスを低下させます。「融合」または「森林破壊」は、コンパイラーがこれらの中間ステップを排除しようとする場所です。
問題は、これらの関数のほとんどが再帰的であるということです。再帰がなければ、すべての関数を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
プラグマを提供するだけです。(ちなみに、これは非常に強力です。ライブラリの作成者は、これを使用してクライアントコードを書き直すことができます!)
メタ:
すべてのバランス、そしてすべて...