3

私は Haskell で遊ぶことから少し長い休憩を取っていましたが、それに戻り始めています。私は間違いなくまだ言語を学んでいます。Haskell を書いているときに常に緊張したり不快に感じたりする原因の 1 つは、慣用的でパフォーマンスの高いアルゴリズムを作成する方法をしっかりと把握していないことです。「時期尚早の最適化はすべての悪の根源である」ことは理解していますが、同様に遅いコードは最終的に処理する必要があり、非常に高レベルな言語は非常に遅いという先入観を取り除くことはできません。

そういうわけで、私はテストケースをいじり始めました。dy/dt = -y; y(0) = 1私が取り組んでいたものの 1 つは、かなり自明な IVPに適用された、古典的な 4 次ルンゲクッタ法のナイーブで単純な実装でしたy = e^-t。Haskell と C の両方で完全に単純な実装を作成しました (これについては、すぐに投稿します)。Haskell バージョンは信じられないほど簡潔で、見たときに内部がぼやけていましたが、C バージョン (実際には解析するのはまったくひどいものではありませんでした)は 2 倍以上高速でした。

2 つの異なる言語のパフォーマンスを比較するのは 100% 公平ではないことは承知しています。そして、私たち全員が死ぬ日まで、C は、特に手動で最適化された C コードのパフォーマンスの王としての王冠を保持する可能性が最も高いでしょう。Haskell の実装を C の実装と同じくらい速く実行しようとしているわけではありません。しかし、自分が何をしているのかをもっと認識していれば、この特定の Haskell 実装からもう少しスピードを求めることができると確信しています。

Haskell バージョンは-02OS X 10.8.4 上の GHC 7.6.3 でコンパイルされ、C バージョンは Clang でコンパイルされ、フラグは付けませんでした。で追跡した場合、Haskell バージョンは平均で約 0.016 秒time、C バージョンは約 0.006 秒でした。

これらのタイミングは、stdout への出力を含むバイナリの実行時間全体を考慮に入れており、明らかにオーバーヘッドの一部を占めていますが-prof -auto-all GC で再コンパイルして実行し+RTS -p、GC を調べることによって、GHC バイナリのプロファイリングを行いました。との統計+RTS -s。私が見たもののすべてを本当に理解していたわけではありませんでしたが、GC が制御不能ではないように見えましたが、おそらく少しは抑制できたようです (5%、~93% のユーザーでの生産性、~85総経過時間の割合) であり、生産的な時間のほとんどが function に費やされました。関数iterateRKを書いたときに遅いことはわかっていましたが、それをクリーンアップする方法がすぐにはわかりませんでした。リストの使用でおそらくペナルティが発生していることに気づきました。consing と、結果を stdout にダンプする際の怠惰。

私は正確に何を間違っていますか?クリーンアップに使用できることを悲劇的に認識していないライブラリ関数またはモナドの魔法は何iterateRKですか? GHCプロファイリングのロックスターになる方法を学ぶための良いリソースは何ですか?

RK.hs

rk4 :: (Double -> Double -> Double) -> Double -> Double -> Double -> Double
rk4 y' h t y = y + (h/6) * (k1 + 2*k2 + 2*k3 + k4)
  where k1 = y' t y
        k2 = y' (t + h/2) (y + ((h/2) * k1))
        k3 = y' (t + h/2) (y + ((h/2) * k2))
        k4 = y' (t + h) (y + (h * k3))

iterateRK y' h t0 y0 = y0:(iterateRK y' h t1 y1)
  where t1 = t0 + h
        y1 = rk4 y' h t0 y0

main = do
  let y' t y = -y
  let h = 1e-3
  let y0 = 1.0
  let t0 = 0
  let results = iterateRK y' h t0 y0
  (putStrLn . show) (take 1000 results)

RK.c

#include<stdio.h>

#define ITERATIONS 1000

double rk4(double f(double t, double x), double h, double tn, double yn)
{
  double k1, k2, k3, k4;

  k1 = f(tn, yn);
  k2 = f((tn + h/2), yn + (h/2 * k1));
  k3 = f((tn + h/2), yn + (h/2 * k2));
  k4 = f(tn + h, yn + h * k3);

  return yn + (h/6) * (k1 + 2*k2 + 2*k3 + k4);
}

double expDot(double t, double x)
{
  return -x;
}

int main()
{
  double t0, y0, tn, yn, h, results[ITERATIONS];
  int i;

  h = 1e-3;
  y0 = 1.0;
  t0 = 0.0;
  yn = y0;

  for(i = 0; i < ITERATIONS; i++)
  {
    results[i] = yn;

    yn = rk4(expDot, h, tn, yn);
    tn += h;
  }

  for(i = 0; i < ITERATIONS; i++)
  {
    printf("%.10lf", results[i]);
    if(i != ITERATIONS - 1)
      printf(", ");
    else
      printf("\n");
  }

  return 0;
}
4

3 に答える 3

14

サイズを大きくしてプログラムを使用すると、スタック オーバーフローが発生します。

Stack space overflow: current size 8388608 bytes.
Use `+RTS -Ksize -RTS' to increase it.

これはおそらく怠惰が多すぎることが原因です。タイプ別に分類されたヒープ プロファイルを見ると、次のようになります。

タイプ別ヘオプロファイル

(注: leftaroundabout が指摘したようにプログラムを修正しました)

これはよく見えません。アルゴリズムに線形スペースを必要としないでください。Double 値を必要以上に長く保持しているようです。厳密にすることで問題が解決します。

{-# LANGUAGE BangPatterns #-}

iterateRK :: (Double -> Double -> Double) -> Double -> Double -> Double -> [Double]
iterateRK y' !h !t0 !y0 = y0:(iterateRK y' h t1 y1)
  where t1 = t0 + h
        y1 = rk4 y' h t0 y0

この変更により、新しいヒープ プロファイルは次のようになります。

新しいヒープ プロファイル

これははるかに良く見え、メモリ使用量ははるかに低くなっています。-sstderr` は、変更後にガベージ コレクターで合計時間の 2.5% しか費やしていないことも確認しています。

%GC     time       2.5%  (2.9% elapsed)

現在、haskell バージョンは C バージョンよりも約 40% 遅いです (ユーザー時間を使用):

$ time ./tesths; time ./testc     
2.47e-321
./tesths  0,73s user 0,01s system 86% cpu 0,853 total
2.470328e-321
./testc  0,51s user 0,01s system 95% cpu 0,549 total

反復回数を増やし、C の結果ストレージにヒープ割り当て配列を使用すると、差が再び小さくなります。

time ./tesths; time ./testc
2.47e-321
./tesths  18,25s user 0,04s system 96% cpu 19,025 total
2.470328e-321
./testc  16,98s user 0,14s system 98% cpu 17,458 total

これは約9%の差に過ぎません。


しかし、私たちはまだ改善することができます。stream-fusionパッケージを使用すると、デカップリングを維持しながらリストを完全に削除できます。その最適化を含む完全なコードは次のとおりです。

{-# LANGUAGE BangPatterns #-}
import qualified Data.List.Stream as S

rk4 :: (Double -> Double -> Double) -> Double -> Double -> Double -> Double
rk4 y' !h !t !y = y + (h/6) * (k1 + 2*k2 + 2*k3 + k4)
  where k1 = y' t y
        k2 = y' (t + h/2) (y + ((h/2) * k1))
        k3 = y' (t + h/2) (y + ((h/2) * k2))
        k4 = y' (t + h) (y + (h * k3))

iterateRK :: (Double -> Double -> Double) -> Double -> Double -> Double -> [Double]
iterateRK y' h = curry $ S.unfoldr $ \(!t0, !y0) -> Just (y0, (t0 + h, rk4 y' h t0 y0))

main :: IO ()
main = do
  let y' t y = -y
  let h = 1e-3
  let y0 = 1.0
  let t0 = 0
  let results = iterateRK y' h t0 y0
  print $ S.head $ (S.drop (pred 10000000) results)

私は一緒に来ました:

$ ghc -O2 ./test.hs -o tesths -fllvm

タイミングは次のとおりです。

$ time ./tesths; time ./testc                    
2.47e-321
./tesths  15,85s user 0,02s system 97% cpu 16,200 total
2.470328e-321
./testc  16,97s user 0,18s system 97% cpu 17,538 total

割り当てを行わないため、C よりも少し高速になりました。C プログラムに同様の変換を行うには、2 つのループを 1 つにマージし、適切な抽象化を解除する必要があります。それでも、haskell と同じくらい高速です。

$ time ./tesths; time ./testc
2.47e-321
./tesths  15,86s user 0,01s system 98% cpu 16,141 total
2.470328e-321
./testc  15,88s user 0,02s system 98% cpu 16,175 total
于 2013-09-02T18:51:27.640 に答える