25

私は Haskell に非常に慣れていないので、不純な (変更可能な) データ構造を使用することでパフォーマンスがどのように向上するかについて質問があります。私が聞いたいくつかの異なることをつなぎ合わせようとしているので、私の用語が完全に正しくない場合、またはいくつかの小さなエラーがある場合はご容赦ください.

これを具体的にするために、クイックソート アルゴリズム (Haskell wiki から引用) を検討してください。

quicksort :: Ord a => [a] -> [a]
quicksort []     = []
quicksort (p:xs) = (quicksort lesser) ++ [p] ++ (quicksort greater)
    where
        lesser  = filter (< p) xs
        greater = filter (>= p) xs

これは「真のクイックソート」ではありません。「真の」クイックソートアルゴリズムは実装されていますが、これは実装されていません。これは非常にメモリ効率が悪いです。

一方、Haskell でベクトルを使用してインプレース クイックソートを実装することは可能です。このスタックオーバーフローの回答に例が示されています。

2 番目のアルゴリズムは最初のアルゴリズムよりもどれくらい高速ですか? Big O 表記はここでは役に立ちません。なぜなら、パフォーマンスの向上は、より効率的にメモリを使用することによるものであり、より優れたアルゴリズムを持たないからです (そうですか?)。いくつかのテスト ケースを自分で作成するのは疲れましたが、実行するのは困難でした。

理想的な答えは、インプレース Haskell アルゴリズムを理論的に高速化する理由と、いくつかのテスト データ セットでの実行時間の比較の例を提供することです。

4

2 に答える 2

22

一方、Haskell でベクトルを使用してインプレース クイックソートを実装することは可能です。

2 番目のアルゴリズムは最初のアルゴリズムよりもどれくらい高速ですか?

もちろん、それは実装に依存します。以下に見られるように、リストが短すぎない場合、変更可能なベクトルまたは配列での適切なインプレース ソートは、リストからの変換とリストへの変換の時間が含まれていても、リストのソートよりもはるかに高速です (そして、その変換が補われます)。時間の大部分)。

ただし、リスト アルゴリズムはインクリメンタルな出力を生成しますが、配列/ベクトル アルゴリズムは完了する前に結果を生成しないため、リストの並べ替えが依然として望ましい場合があります。

リンクされた可変配列/ベクトルアルゴリズムが何を間違っていたのか正確にはわかりません。しかし、彼らはかなり間違ったことをしました。

可変ベクトル コードの場合、ボックス化されたベクトルを使用しているようで、ポリモーフィックであり、どちらもパフォーマンスに大きな影響を与える可能性がありますが、関数が{-# INLINABLE #-}.

コードについてはIOUArray、まあ、楽しそうに見えますが、遅いです。を使用しIORef、明確な厳密さはありませんreadArraywriteArrayそれなら、それがかかるひどい時間はそれほど驚くべきことではありません.

を使用して(モノモーフィック)Cコードをより直接的に翻訳STUArrayし、ラッパーを使用してリストで動作させる¹、

{-# LANGUAGE BangPatterns #-}
module STUQuickSort (stuquick) where

import Data.Array.Base (unsafeRead, unsafeWrite)
import Data.Array.ST
import Control.Monad.ST

stuquick :: [Int] -> [Int]
stuquick [] = []
stuquick xs = runST (do
    let !len = length xs
    arr <- newListArray (0,len-1) xs
    myqsort arr 0 (len-1)
    -- Can't use getElems for large arrays, that overflows the stack, wth?
    let pick acc i
            | i < 0     = return acc
            | otherwise = do
                !v <- unsafeRead arr i
                pick (v:acc) (i-1)
    pick [] (len-1))

myqsort :: STUArray s Int Int -> Int -> Int -> ST s ()
myqsort a lo hi
   | lo < hi   = do
       let lscan p h i
               | i < h = do
                   v <- unsafeRead a i
                   if p < v then return i else lscan p h (i+1)
               | otherwise = return i
           rscan p l i
               | l < i = do
                   v <- unsafeRead a i
                   if v < p then return i else rscan p l (i-1)
               | otherwise = return i
           swap i j = do
               v <- unsafeRead a i
               unsafeRead a j >>= unsafeWrite a i
               unsafeWrite a j v
           sloop p l h
               | l < h = do
                   l1 <- lscan p h l
                   h1 <- rscan p l1 h
                   if (l1 < h1) then (swap l1 h1 >> sloop p l1 h1) else return l1
               | otherwise = return l
       piv <- unsafeRead a hi
       i <- sloop piv lo hi
       swap i hi
       myqsort a lo (i-1)
       myqsort a (i+1) hi
   | otherwise = return ()

ボックス化されていないベクトルの適切な並べ替え (クイック並べ替えではなくイントロ並べ替え) のラッパー、

module VSort where

import Data.Vector.Algorithms.Intro
import qualified Data.Vector.Unboxed as U
import Control.Monad.ST

vsort :: [Int] -> [Int]
vsort xs = runST (do
    v <- U.unsafeThaw $ U.fromList xs
    sort v
    s <- U.unsafeFreeze v
    return $ U.toList s)

私は予想に沿って何倍も得ます(注:deepseqこれらのタイミングでは、ソートアルゴリズムを呼び出す前にランダムリストが編集されています。それがないSTUArrayと、サンクの長いリストを最初に評価するため、への変換ははるかに遅くなります長さを決定します. vectorfromListパッケージの変換はこの問題に悩まされません.を への変換に移すと, 他のソート [およびvectorの場合は変換] アルゴリズムは少し時間がかかります. -algorithms ' イントロソートとクイックソートが少し大きくなります。):deepseqSTUArraySTUArray

list size: 200000                    -O2     -fllvm  -fllvm-O2
────────             ────────   ────────   ────────   ────────
Data.List.sort      0.663501s  0.665482s  0.652461s  0.792005s
Naive.quicksort     0.587091s  0.577796s  0.585754s  0.667573s
STUArray.quicksort   1.58023s  0.142626s  1.597479s  0.156411s
VSort.vsort         0.820639s  0.139967s  0.888566s  0.143918s

最適化されていない時間は、STUArray. 高速にするためにインライン化する必要がありますunsafeReadunsafeWriteインライン化されていない場合は、呼び出しごとに辞書検索が行われます。したがって、大規模なデータセットの場合、最適化されていない方法は省略します。

list size: 3000000         -O2   -fllvm-O2
────────              ────────    ────────
Data.List.sort      16.728576s  16.442377s
Naive.quicksort     14.297534s  12.253071s
STUArray.quicksort   2.307203s   2.200807s
VSort.vsort          2.069749s   1.921943s

可変ボックス化されていない配列のインプレース ソートは、正しく行われた場合、リストベースのソートよりもはるかに高速であることがわかります。ボックス化されていない変更可能なベクターの並べ替えと並べ替えの違いは、STUArrayアルゴリズムの違いによるものなのか、それともベクターの方が実際に高速なのかはわかりません。ベクトルがSTUArrays よりも速い² ことを観察したことがないので、前者を信じる傾向があります。 クイックソートとイントロソートの違いは、ベクトルパッケージが提供STUArrayするリストとの間の変換が優れていることと、アルゴリズムが異なることです。


Louis Wassermanの提案で、あまり大きくないデータセットを使用して、vector-algorithms パッケージの他の並べ替えアルゴリズムを使用して簡単なベンチマークを実行しました結果は驚くべきことではなく、優れた汎用アルゴリズムのヒープソート、イントロソート、およびマージソートはすべてうまく機能し、ボックス化されていない可変配列のクイックソートに近い時間です (ただし、もちろん、クイックソートはほとんどソートされた入力で二次動作に低下しますが、これらはO(n*log n) の最悪のケースが保証されています)。特殊用途のソートアルゴリズムAmericanFlag入力が目的にうまく適合しないため、基数ソートはうまく機能しません(基数ソートは、データに必要な数よりも多くのパスを実行するため、範囲が広い大きな入力でうまく機能します)。挿入ソートは、その 2 次動作のために、断然最悪です。

AmericanFlag:
list size: 300000         -O2  -fllvm-O2
────────             ────────   ────────
Data.List.sort      1.083845s  1.084699s
Naive.quicksort     0.981276s   1.05532s
STUArray.quicksort  0.218407s  0.215564s
VSort.vsort         2.566838s  2.618817s

Heap:
list size: 300000         -O2  -fllvm-O2
────────             ────────   ────────
Data.List.sort      1.084252s   1.07894s
Naive.quicksort     0.915984s  0.887354s
STUArray.quicksort  0.219786s  0.225748s
VSort.vsort         0.213507s   0.20152s

Insertion:
list size: 300000         -O2   -fllvm-O2
────────             ────────    ────────
Data.List.sort      1.168837s   1.066058s
Naive.quicksort     1.081806s   0.879439s
STUArray.quicksort  0.241958s   0.209631s
VSort.vsort         36.21295s  27.564993s

Intro:
list size: 300000         -O2  -fllvm-O2
────────             ────────   ────────
Data.List.sort       1.09189s  1.112415s
Naive.quicksort     0.891161s  0.989799s
STUArray.quicksort  0.236596s  0.227348s
VSort.vsort         0.221742s   0.20815s

Merge:
list size: 300000         -O2  -fllvm-O2
────────             ────────   ────────
Data.List.sort      1.087929s  1.074926s
Naive.quicksort     0.875477s  1.019984s
STUArray.quicksort  0.215551s  0.221301s
VSort.vsort         0.236661s  0.230287s

Radix:
list size: 300000         -O2  -fllvm-O2
────────             ────────   ────────
Data.List.sort      1.085658s  1.085726s
Naive.quicksort     1.002067s  0.900985s
STUArray.quicksort  0.217371s  0.228973s
VSort.vsort         1.958216s  1.970619s

結論: 特に理由がない限り、 vector-algorithmsの優れた汎用ソート アルゴリズムの 1 つを使用し、必要に応じてリストとの間で変換するラッパーを使用することが、大きなリストをソートするための推奨される方法です。(これらのアルゴリズムは、ボックス化されたベクトルでもうまく機能します。私の測定では、ボックス化されていないものよりも約 50% 遅くなります。) 短いリストの場合、変換のオーバーヘッドが非常に大きくなるため、コストがかかりません。


さて、@applicative の提案で、vector-algorithmsのイントロソート、ボックス化されていないベクトルのクイックソート、s の改良された (恥知らずに の実装を盗むunstablePartition) クイックソートのソート時間を見てみましょうSTUArray

改良されたSTUArrayクイックソート:

{-# LANGUAGE BangPatterns #-}
module NQuick (stuqsort) where


import Data.Array.Base (unsafeRead, unsafeWrite, getNumElements)
import Data.Array.ST
import Control.Monad.ST
import Control.Monad (when)

stuqsort :: STUArray s Int Int -> ST s ()
stuqsort arr = do
    n <- getNumElements arr
    when (n > 1) (myqsort arr 0 (n-1))

myqsort :: STUArray s Int Int -> Int -> Int -> ST s ()
myqsort a lo hi = do
    p <- unsafeRead a hi
    j <- unstablePartition (< p) lo hi a
    h <- unsafeRead a j
    unsafeWrite a j p
    unsafeWrite a hi h
    when (j > lo+1) (myqsort a lo (j-1))
    when (j+1 < hi) (myqsort a (j+1) hi)

unstablePartition :: (Int -> Bool) -> Int -> Int -> STUArray s Int Int -> ST s Int
{-# INLINE unstablePartition #-}
unstablePartition f !lf !rg !v = from_left lf rg
  where
    from_left i j
      | i == j    = return i
      | otherwise = do
                      x <- unsafeRead v i
                      if f x
                        then from_left (i+1) j
                        else from_right i (j-1)

    from_right i j
      | i == j    = return i
      | otherwise = do
                      x <- unsafeRead v j
                      if f x
                        then do
                               y <- unsafeRead v i
                               unsafeWrite v i x
                               unsafeWrite v j y
                               from_left (i+1) j
                        else from_right i (j-1)

ベクトルのクイックソート:

module VectorQuick (vquicksort) where

import qualified Data.Vector.Unboxed.Mutable as UM
import qualified Data.Vector.Generic.Mutable as GM
import Control.Monad.ST
import Control.Monad (when)

vquicksort :: UM.STVector s Int -> ST s ()
vquicksort uv = do
    let li = UM.length uv - 1
        ui = UM.unsafeSlice 0 li uv
    p <- UM.unsafeRead uv li
    j <- GM.unstablePartition (< p) ui
    h <- UM.unsafeRead uv j
    UM.unsafeWrite uv j p
    UM.unsafeWrite uv li h
    when (j > 1) (vquicksort (UM.unsafeSlice 0 j uv))
    when (j + 1 < li) (vquicksort (UM.unsafeSlice (j+1) (li-j) uv))

タイミングコード:

{-# LANGUAGE BangPatterns #-}
module Main (main) where

import System.Environment (getArgs)
import System.CPUTime
import System.Random
import Text.Printf

import Data.Array.Unboxed
import Data.Array.ST hiding (unsafeThaw)
import Data.Array.Unsafe (unsafeThaw)
import Data.Array.Base (unsafeAt, unsafeNewArray_, unsafeWrite)
import Control.Monad.ST
import Control.Monad

import qualified Data.Vector.Unboxed as U
import qualified Data.Vector.Unboxed.Mutable as UM

import NQuick
import VectorQuick
import qualified Data.Vector.Algorithms.Intro as I

nextR :: StdGen -> (Int, StdGen)
nextR = randomR (minBound, maxBound)

buildArray :: StdGen -> Int -> UArray Int Int
buildArray sg size = runSTUArray (do
    arr <- unsafeNewArray_ (0, size-1)
    let fill i g
            | i < size  = do
                let (r, g') = nextR g
                unsafeWrite arr i r
                fill (i+1) g'
            | otherwise = return arr
    fill 0 sg)

buildVector :: StdGen -> Int -> U.Vector Int
buildVector sg size = U.fromList $ take size (randoms sg)

time :: IO a -> IO ()
time action = do
    t0 <- getCPUTime
    action
    t1 <- getCPUTime
    let tm :: Double
        tm = fromInteger (t1 - t0) * 1e-9
    printf "%.3f ms\n" tm

stu :: UArray Int Int -> Int -> IO ()
stu ua sz = do
    let !sa = runSTUArray (do
                st <- unsafeThaw ua
                stuqsort st
                return st)
    forM_ [0, sz `quot` 2, sz-1] (print . (sa `unsafeAt`))

intro :: U.Vector Int -> Int -> IO ()
intro uv sz = do
    let !sv = runST (do
            st <- U.unsafeThaw uv
            I.sort st
            U.unsafeFreeze st)
    forM_ [0, sz `quot` 2, sz-1] (print . U.unsafeIndex sv)

vquick :: U.Vector Int -> Int -> IO ()
vquick uv sz = do
    let !sv = runST (do
            st <- U.unsafeThaw uv
            vquicksort st
            U.unsafeFreeze st)
    forM_ [0, sz `quot` 2, sz-1] (print . U.unsafeIndex sv)

main :: IO ()
main = do
    args <- getArgs
    let !num = case args of
                 (a:_) -> read a
                 _ -> 1000000
    !sg <- getStdGen
    let !ar = buildArray sg num
        !vc = buildVector sg num
        !v2 = buildVector sg (foo num)
        algos = [ ("Intro", intro v2), ("STUArray", stu ar), ("Vquick", vquick vc) ]
    printf "Created data to be sorted, last elements %d %d %d\n" (ar ! (num-1)) (vc U.! (num-1)) (v2 U.! (num-1))
    forM_ algos $ \(name, act) -> do
        putStrLn name
        time (act num)

-- For the prevention of sharing
foo :: Int -> Int
foo n
    | n < 0 = -n
    | n > 0 = n
    | otherwise = 3

結果(回のみ):

$ ./timeSorts 3000000
Intro
587.911 ms
STUArray
402.939 ms
Vquick
414.936 ms
$ ./timeSorts 1000000
Intro
193.970 ms
STUArray
131.980 ms
Vquick
134.979 ms

およびボックス化されていないベクトルでの実質的に同一のクイックソートは、STUArray予想どおり、実質的に同じ時間がかかります。(古いクイックソートの実装は、イントロソートよりも約 15% 遅くなりました。上記の時間と比較すると、リストから/への変換に約 70-75% が費やされました。)

ランダムな入力では、クイックソートはイントロソートよりもはるかに優れたパフォーマンスを発揮しますが、ほぼソートされた入力では、イントロソートではパフォーマンスが低下しますが、パフォーマンスは低下しません。


¹ s でコードをポリモーフィックにするSTUArrayのはせいぜい面倒です。IOUArray{-# INLINABLE #-}

² 同じアルゴリズムを使用して比較した場合、どちらも測定精度の範囲内で常に同等に高速でした (それほど頻繁ではありません)。

于 2012-07-14T16:14:45.413 に答える
18

テストに勝るものはありませんよね?そして、結果は驚くべきことではありません。範囲内のランダムな整数のリストの場合[0 .. 1000000]

list size: 200000         ghc              -O2     -fllvm  -fllvm-O2
────────                   ────────   ────────   ────────   ────────
Data.List.sort            0.878969s  0.883219s  0.878106s  0.888758s
Naïve.quicksort           0.711305s  0.870647s  0.845508s  0.919925s
UArray_IO.quicksort       9.317783s  1.919583s  9.390687s  1.945072s
Vector_Mutable.quicksort   1.48142s  0.823004s  1.526661s  0.806837s

ここに、Data.List.sortそれが何であるか、Naïve.quicksortあなたが引用したアルゴリズムがあり、UArray_IO.quicksortあなたVector_Mutable.quicksortがリンクした質問から取られています:klapauciusDan Burtonの答え は、パフォーマンスに関して非常に最適ではないことが判明しまし。両方ともリストを受け入れるようにラップされています(これが完全に正しいかどうかはわかりません):

quicksort :: [Int] -> [Int]
quicksort l = unsafePerformIO $ do
  let bounds = (0, length l)
  arr <- newListArray bounds l :: IO (IOUArray Int Int)
  uncurry (qsort arr) bounds
  getElems arr

quicksort :: Ord a => [a] -> [a]
quicksort = toList . iqsort . fromList

それぞれ。

ご覧のとおり、ナイーブアルゴリズムはData.Vector、ランダムに生成された整数のリストを並べ替える速度の点で、可変ソリューションにそれほど遅れをとっていません。IOUArray実際には、はるかに劣っています。テストは、Ubuntu11.10x86-64を実行しているInteli5ラップトップで実行されました。


結局のところ、ɢᴏᴏᴅの可変実装がここで比較したすべての実装よりもはるかに進んでいることを考えると、以下はあまり意味がありません。

これは、優れたリストベースのプログラムが常に可変的に実装された同等のプログラムに追いつくことができるという意味ではありませんが、GHCは確かにパフォーマンスを近づけるのに素晴らしい仕事をします。また、もちろんデータにも依存します。これらは、ランダムに生成された並べ替えリストに、上記の0から1000000ではなく、0から1000の間の値が含まれる場合です。つまり、多くの重複があります。

list size: 200000         ghc               -O2      -fllvm  -fllvm-O2
────────                    ────────   ────────    ────────   ────────
Data.List.sort             0.864176s  0.882574s   0.850807s  0.857957s
Naïve.quicksort            1.475362s  1.526076s   1.475557s  1.456759s
UArray_IO.quicksort       24.405938s  5.255001s  23.561911s  5.207535s
Vector_Mutable.quicksort   3.449168s  1.125788s   3.202925s  1.117741s

事前にソートされたアレイについては言うまでもありません。

非常に興味深いのは(スタック容量を増やすためにrtsoptsを必要とする非常に大きなサイズでのみ明らかになる)、両方の可変実装が次のように大幅に遅くなること-fllvm -O2です。

list size: 3⋅10⁶        ghc      -O1   -fllvm-O1         -O2   -fllvm-O2
────────                    ────────    ────────    ────────    ────────
Data.List.sort            23.897897s  24.138117s  23.708218s  23.631968s
Naïve.quicksort           17.068644s  19.547817s  17.640389s  18.113622s
UArray_IO.quicksort       35.634132s  38.348955s  37.177606s  49.190503s
Vector_Mutable.quicksort  17.286982s  17.251068s  17.361247s  36.840698s

不変の実装がllvmでうまくいくことは私には論理的に思えます(あるレベルですべてを不変に実行しませんか?)が、これが高度な最適化での可変バージョンへの減速としてのみ明らかになる理由はわかりません大きなデータサイズ。


テストプログラム:

$ cat QSortPerform.hs
module Main where

import qualified Data.List(sort)
import qualified Naïve
import qualified UArray_IO
import qualified Vector_Mutable

import Control.Monad
import System.Random
import System.Environment

sortAlgos :: [ (String, [Int]->[Int]) ]
sortAlgos = [ ("Data.List.sort", Data.List.sort)
            , ("Naïve.quicksort", Naïve.quicksort)
            , ("UArray_IO.quicksort", UArray_IO.quicksort)
            , ("Vector_Mutable.quicksort", Vector_Mutable.quicksort) ]

main = do
   args <- getArgs
   when (length args /= 2) $ error "Need 2 arguments"

   let simSize = read $ args!!1
   randArray <- fmap (take simSize . randomRs(0,1000000)) getStdGen

   let sorted = case filter ((== args!!0) . fst) sortAlgos of
        [(_, algo)] -> algo randArray
        _ -> error $ "Argument must be one of " 
                        ++ show (map fst sortAlgos)

   putStr "First element:  "; print $ sorted!!0
   putStr "Middle element: "; print $ sorted!!(simSize`div`2)
   putStr "Last element:   "; print $ sorted!!(simSize-1)

これは、コマンドラインでアルゴリズム名と配列サイズを取ります。ランタイム比較は、このプログラムで行われました。

$ cat PerformCompare.hs
module Main where

import System.Process
import System.Exit
import System.Environment
import Data.Time.Clock
import Data.List
import Control.Monad
import Text.PrettyPrint.Boxes

compiler = "ghc"
testProgram = "./QSortPerform"
flagOpts = [[], ["-O2"], ["-fllvm"], ["-fllvm","-O2"]]
algos = ["Data.List.sort","Naïve.quicksort","UArray_IO.quicksort","Vector_Mutable.quicksort"]


main = do
   args <- getArgs
   let testSize = case args of
         [numb] -> read numb
         _      -> 200000

   results <- forM flagOpts $ \flags -> do

      compilerExitC <- verboseSystem
              compiler $ testProgram : "-fforce-recomp" : flags
      when (compilerExitC /= ExitSuccess) .
         error $ "Compiler error \"" ++ show compilerExitC ++"\""

      algoCompare <- forM algos $ \algo -> do
         startTime <- getCurrentTime
         exitC <- verboseSystem testProgram [algo, show testSize]
         endTime <- getCurrentTime
         when (exitC /= ExitSuccess) .
            error $ "Program error \"" ++ show exitC ++"\""
         return . text . show $ diffUTCTime endTime startTime

      return . vcat right $ text(concat flags)
                          : text("────────")
                          : algoCompare

   let table = hsep 2 bottom
         $ vcat left (map text $ ("list size: "++show testSize)
                               : "────────"
                               : algos                          )
         : results

   printBox table



verboseSystem :: String -> [String] -> IO ExitCode
verboseSystem cmd args = do
   putStrLn . unwords $ cmd : args
   rawSystem cmd args
于 2012-07-14T11:03:11.953 に答える