12

ベクトル ライブラリとステート モナドを使用して Haskell で最長共通サブシーケンス アルゴリズムを作成しています(Miller O(NP) アルゴリズムの非常に命令的で変更可能な性質をカプセル化するため)。私はすでにそれを必要とするいくつかのプロジェクトのために C でそれを書いており、C に匹敵する優れたパフォーマンスを備えた命令型グリッドウォーク アルゴリズムを記述する方法を探求する方法として、Haskell でそれを書いています。同じ入力に対してCバージョンよりも約4倍遅いです(そして、適切な最適化フラグでコンパイルされました-システムクロック時間とCriterionHaskell と C のバージョン間の相対時間測定値、および同じデータ型 (大きい入力と小さい入力の両方) を検証するメソッド)。パフォーマンスの問題がどこにあるのかを突き止めようとしており、フィードバックをいただければ幸いです。特に、ここで頻繁に使用しているベクター ライブラリで、よく知られたパフォーマンスの問題が発生している可能性があります。

私のコードには、最も頻繁に呼び出され、ほとんどの作業を行う gridWalk という関数が 1 つあります。パフォーマンスの低下が見られる可能性がありますが、それが何であるかはわかりません。完全な Haskell コードはこちらです。以下のコードのスニペット:

import Data.Vector.Unboxed.Mutable as MU
import Data.Vector.Unboxed as U hiding (mapM_)
import Control.Monad.ST as ST
import Control.Monad.Primitive (PrimState)
import Control.Monad (when) 
import Data.STRef (newSTRef, modifySTRef, readSTRef)
import Data.Int


type MVI1 s  = MVector (PrimState (ST s)) Int

cmp :: U.Vector Int32 -> U.Vector Int32 -> Int -> Int -> Int
cmp a b i j = go 0 i j
               where
                 n = U.length a
                 m = U.length b
                 go !len !i !j| (i<n) && (j<m) && ((unsafeIndex a i) == (unsafeIndex b j)) = go (len+1) (i+1) (j+1)
                                    | otherwise = len

-- function to find previous y on diagonal k for furthest point 
findYP :: MVI1 s -> Int -> Int -> ST s (Int,Int)
findYP fp k offset = do
              let k0 = k+offset-1
                  k1 = k+offset+1
              y0 <- MU.unsafeRead fp k0 >>= \x -> return $ 1+x
              y1 <- MU.unsafeRead fp k1
              if y0 > y1 then return (k0,y0)
              else return (k1,y1)
{-#INLINE findYP #-}

gridWalk :: Vector Int32 -> Vector Int32 -> MVI1 s -> Int -> (Vector Int32 -> Vector Int32 -> Int -> Int -> Int) -> ST s ()
gridWalk a b fp !k cmp = {-#SCC gridWalk #-} do
   let !offset = 1+U.length a
   (!kp,!yp) <- {-#SCC findYP #-} findYP fp k offset                          
   let xp = yp-k
       len = {-#SCC cmp #-} cmp a b xp yp
       x = xp+len
       y = yp+len

   {-#SCC "updateFP" #-} MU.unsafeWrite fp (k+offset) y  
   return ()
{-#INLINE gridWalk #-}

-- The function below executes ct times, and updates furthest point as they are found during furthest point search
findSnakes :: Vector Int32 -> Vector Int32 -> MVI1 s ->  Int -> Int -> (Vector Int32 -> Vector Int32 -> Int -> Int -> Int) -> (Int -> Int -> Int) -> ST s ()
findSnakes a b fp !k !ct cmp op = {-#SCC findSnakes #-} U.forM_ (U.fromList [0..ct-1]) (\x -> gridWalk a b fp (op k x) cmp)
{-#INLINE findSnakes #-}

コスト センターの注釈をいくつか追加し、テスト用に特定の LCS 入力でプロファイリングを実行しました。ここに私が得るものがあります:

  total time  =        2.39 secs   (2394 ticks @ 1000 us, 1 processor)
  total alloc = 4,612,756,880 bytes  (excludes profiling overheads)

COST CENTRE MODULE    %time %alloc

gridWalk    Main       67.5   52.7
findSnakes  Main       23.2   27.8
cmp         Main        4.2    0.0
findYP      Main        3.5   19.4
updateFP    Main        1.6    0.0


                                                         individual     inherited
COST CENTRE    MODULE                  no.     entries  %time %alloc   %time %alloc

MAIN           MAIN                     64           0    0.0    0.0   100.0  100.0
 main          Main                    129           0    0.0    0.0     0.0    0.0
 CAF           Main                    127           0    0.0    0.0   100.0  100.0
  findSnakes   Main                    141           0    0.0    0.0     0.0    0.0
  main         Main                    128           1    0.0    0.0   100.0  100.0
   findSnakes  Main                    138           0    0.0    0.0     0.0    0.0
    gridWalk   Main                    139           0    0.0    0.0     0.0    0.0
     cmp       Main                    140           0    0.0    0.0     0.0    0.0
   while       Main                    132        4001    0.1    0.0   100.0  100.0
    findSnakes Main                    133       12000   23.2   27.8    99.9   99.9
     gridWalk  Main                    134    16004000   67.5   52.7    76.7   72.2
      cmp      Main                    137    16004000    4.2    0.0     4.2    0.0
      updateFP Main                    136    16004000    1.6    0.0     1.6    0.0
      findYP   Main                    135    16004000    3.5   19.4     3.5   19.4
   newVI1      Main                    130           1    0.0    0.0     0.0    0.0
   newVI1.\   Main                    131        8004    0.0    0.0     0.0    0.0
 CAF           GHC.Conc.Signal         112           0    0.0    0.0     0.0    0.0
 CAF           GHC.IO.Encoding         104           0    0.0    0.0     0.0    0.0
 CAF           GHC.IO.Encoding.Iconv   102           0    0.0    0.0     0.0    0.0
 CAF           GHC.IO.Handle.FD         95           0    0.0    0.0     0.0    0.0

プロファイリング出力を正しく解釈している場合 (そして、プロファイリングによる歪みがあまりないと仮定すると)、gridWalkほとんどの時間がかかりますが、主な機能cmpfindYPで重労働を行うものはgridWalk、プロファイリング レポートにほとんど時間がかからないようです。したがって、おそらくボトルネックは、関数が呼び出しに使用するforM_ラッパーにありますか? ヒープ プロファイルも非常にきれいに見えます。findSnakesgridWalkヒープ プロファイル

核心を読んで、何も飛び出さない。内部ループのいくつかの値がボックス化されている可能性があると思いましたが、コアでそれらを見つけられません。パフォーマンスの問題が、私が見逃した単純なものによるものであることを願っています。

アップデート

@DanielFischer の提案に従って、C バージョンの 4 倍から 2.5 倍のパフォーマンスを向上させるin関数forM_Data.Vector.Unboxedを置き換えました。Haskell と C のバージョンは、試してみたい場合にここに投稿されています。Control.MonadfindSnakes

私はまだコアを掘り下げて、ボトルネックがどこにあるかを確認しています。gridWalk最も頻繁に呼び出される関数であり、それが適切に機能するためには、ループを条件チェックとインライン化されたコードの適切な反復内部ループにlcsh減らす必要があります。アセンブリでは、これはループには当てはまらないと思いますが、コアの変換とアセンブリで名前がマングルされた GHC 関数の場所を特定することについてあまり知識がないので、問題が発生するまで辛抱強くプラグインするだけの問題だと思います。私はそれを理解します。その間、パフォーマンスの修正に関する指針があれば、それらは高く評価されます。whileM_findSnakeswhileM_

私が考えることができる別の可能性は、関数呼び出し中のヒープ チェックのオーバーヘッドです。プロファイリング レポートに見られるように、gridWalk16004000 回呼び出されます。ヒープ チェックに 6 サイクルを想定すると (それよりも少ないと推測されますが、それでも想定しておきましょう)、3.33 GHz ボックスでは 96024000 サイクルで約 0.02 秒です。

また、いくつかのパフォーマンス数値:

Haskell code (GHC 7.6.1 x86_64): 修正前は ~0.25 秒でしforM_た。

 time ./T
1

real    0m0.150s
user    0m0.145s
sys     0m0.003s

C code (gcc 4.7.2 x86_64):

time ./test
1

real    0m0.065s
user    0m0.063s
sys     0m0.000s

更新 2:

更新されたコードはこちらです。を使用STUArrayしても数値は変わりません。パフォーマンスは で約 1.5 倍でMac OS X (x86_64,ghc7.6.1)あり、@DanielFischer が Linux で報告したものとかなり似ています。

Haskell コード:

$ time ./Diff
1

real    0m0.087s
user    0m0.084s
sys 0m0.003s

C コード:

$ time ./test
1

real    0m0.056s
user    0m0.053s
sys 0m0.002s

をちらりと見るとcmm、呼び出しは末尾再帰であり、 によってループになっていますllvm。しかし、新しい反復ごとに、ヒープチェックも呼び出す新しい値が割り当てられるように見えるため、パフォーマンスの違いを説明できる可能性があります。ヒープ チェックと割り当てのオーバーヘッドを回避して、反復間で値が割り当てられないように末尾再帰を記述する方法を考える必要があります。

4

1 に答える 1

10

あなたはで大打撃を受けます

U.forM_ (U.fromList [0..ct-1])

findSnakes。私はそれが起こるはずがないと確信しています(チケット?)が、呼び出されるVectorたびに新しいトラバースを割り当てます。findSnakes使用する場合

Control.Monad.forM_ [0 .. ct-1]

代わりに、実行時間は約半分になり、割り当てはここで約 500 分の 1 に減少します。(GHC は適切に最適化されC.M.forM_ [0 :: Int .. limit]、リストは削除され、基本的にはループが残ります。)ループを自分で作成することで、わずかに改善することができます。

パフォーマンスをあまり損なうことなく、無償の割り当て/コードサイズの肥大化を引き起こすいくつかのことは次のとおりです。

  • の未使用Boolの引数lcsh
  • andへのcmp引数; これらが最上位の とは異なる比較で呼び出されない場合、その引数は不要なコードの重複につながります。findSnakesgridWalkcmp
  • の一般的なタイプwhile。used 型に特殊化すると、ST s Bool -> ST s () -> ST s ()割り当てが (大幅に) 削減され、実行時間も (わずかですが、ここでは顕著に) 削減されます。

プロファイリングに関する一般的な言葉: プロファイリング用にプログラムをコンパイルすると、多くの最適化が阻害されます。特に のようなライブラリvectorbytestringまたはtext融合を多用するライブラリの場合、プロファイリングはしばしば誤解を招く結果をもたらします。

たとえば、元のコードはここで生成されます

    total time  =        3.42 secs   (3415 ticks @ 1000 us, 1 processor)
    total alloc = 4,612,756,880 bytes  (excludes profiling overheads)

COST CENTRE MODULE    %time %alloc  ticks     bytes

gridWalk    Main       63.7   52.7   2176 2432608000
findSnakes  Main       20.0   27.8    682 1281440080
cmp         Main        9.2    0.0    313        16
findYP      Main        4.2   19.4    144 896224000
updateFP    Main        2.7    0.0     91         0

leninのバインディングに強打を追加するだけgridWalkでは、非プロファイリング バージョンでは何も変更されませんが、プロファイリング バージョンでは変更されません。

    total time  =        2.98 secs   (2985 ticks @ 1000 us, 1 processor)
    total alloc = 3,204,404,880 bytes  (excludes profiling overheads)

COST CENTRE MODULE    %time %alloc  ticks     bytes

gridWalk    Main       63.0   32.0   1881 1024256000
findSnakes  Main       22.2   40.0    663 1281440080
cmp         Main        7.2    0.0    214        16
findYP      Main        4.7   28.0    140 896224000
updateFP    Main        2.7    0.0     82         0

それは多くの違いを生みます。上記の変更を含むバージョン(および の強打lengridWalkの場合、プロファイリング バージョンは次のように述べています。

total alloc = 1,923,412,776 bytes  (excludes profiling overheads)

しかし、非プロファイリング バージョン

     1,814,424 bytes allocated in the heap
        10,808 bytes copied during GC
        49,064 bytes maximum residency (2 sample(s))
        25,912 bytes maximum slop
             1 MB total memory in use (0 MB lost due to fragmentation)

                                  Tot time (elapsed)  Avg pause  Max pause
Gen  0         2 colls,     0 par    0.00s    0.00s     0.0000s    0.0000s
Gen  1         2 colls,     0 par    0.00s    0.00s     0.0001s    0.0001s

INIT    time    0.00s  (  0.00s elapsed)
MUT     time    0.12s  (  0.12s elapsed)
GC      time    0.00s  (  0.00s elapsed)
EXIT    time    0.00s  (  0.00s elapsed)
Total   time    0.12s  (  0.12s elapsed)

プロファイリング バージョンよりも 1000 倍少なく割り当てられたと言います。

および友人のコードの場合vector、プロファイリングよりもボトルネックを特定するのに信頼性が高くなります (残念ながら、はるかに時間がかかり困難です)。


更新に関して、C は私のボックスで少し遅く実行されます (gcc-4.7.2、-O3)

$ time ./miltest1

real    0m0.074s
user    0m0.073s
sys     0m0.001s

しかしHaskellはほぼ同じ

$ time ./hsmiller
1

real    0m0.151s
user    0m0.149s
sys     0m0.001s

LLVM バックエンド経由でコンパイルすると、少し速くなります。

$ time ./hsmiller1

real    0m0.131s
user    0m0.129s
sys     0m0.001s

forM_を手動ループに置き換えると、

findSnakes a b fp !k !ct op = go 0
  where
    go x
        | x < ct    = gridWalk a b fp (op k x) >> go (x+1)
        | otherwise = return ()

少し速くなり、

$ time ./hsmiller
1

real    0m0.124s
user    0m0.121s
sys     0m0.002s

それぞれ LLVM 経由:

$ time ./hsmiller
1

real    0m0.108s
user    0m0.107s
sys     0m0.000s

全体として、生成されたコアは問題ないように見えますが、1 つの小さな問題がありました。

Main.$wa
  :: forall s.
     GHC.Prim.Int#
     -> GHC.Types.Int
     -> GHC.Prim.State# s
     -> (# GHC.Prim.State# s, Main.MVI1 s #)

やや回りくどい実装です。newVI1これは、2 番目の引数を strict にすることで修正されます。

newVI1 n !x = do

これは頻繁に呼び出されるわけではないため、パフォーマンスへの影響はもちろん無視できます。

肉は の核でlcshあり、それはそれほど悪くはありません。その中の唯一のボックス化されたものは、Intから読み取られる / に書き込まれるSTRefであり、これは避けられません。コアに多くのコードの重複が含まれているのはあまり喜ばしいことではありませんが、私の経験では、それが実際のパフォーマンスの問題になることはめったになく、重複したコードのすべてがコード生成に耐えられるわけではありません。

そして、それがうまく機能するためには、ループを条件チェックとインライン化されたコードの適切な反復内部ループにlcsh減らす必要があります。whileM_findSnakes

INLINEプラグマをに追加すると内部ループが発生しますwhileM_が、そのループは適切ではありません。この場合、アウトオブラインよりもはるかに遅くなりますwhileM_(コード サイズだけが原因かどうかはわかりませんが、しかし、そうかもしれません)。

于 2013-06-06T09:58:47.393 に答える