【问题标题】:Is it possible to speed up a quicksort with par in Haskell?是否可以在 Haskell 中使用 par 加速快速排序?
【发布时间】:2013-11-14 04:50:21
【问题描述】:

我得到了这个看似微不足道的并行快速排序实现,代码如下:

import System.Random
import Control.Parallel
import Data.List

quicksort :: Ord a => [a] -> [a]
quicksort xs = pQuicksort 16 xs -- 16 is the number of sparks used to sort

-- pQuicksort, parallelQuicksort  
-- As long as n > 0 evaluates the lower and upper part of the list in parallel,
-- when we have recursed deep enough, n==0, this turns into a serial quicksort.
pQuicksort :: Ord a => Int -> [a] -> [a]
pQuicksort _ [] = []
pQuicksort 0 (x:xs) =
  let (lower, upper) = partition (< x) xs
  in pQuicksort 0 lower ++ [x] ++ pQuicksort 0 upper
pQuicksort n (x:xs) =
  let (lower, upper) = partition (< x) xs
      l = pQuicksort (n `div` 2) lower
      u = [x] ++ pQuicksort (n `div` 2) upper
  in (par u l) ++ u

main :: IO ()
main = do
  gen <- getStdGen
  let randints = (take 5000000) $ randoms gen :: [Int]
  putStrLn . show . sum $ (quicksort randints)

我用编译

ghc --make -threaded -O2 quicksort.hs

并运行

./quicksort +RTS -N16 -RTS

无论我做什么,我都无法让它比在一个 cpu 上运行的简单顺序实现运行得更快。

  1. 能否解释为什么它在多个 CPU 上运行比在一个 CPU 上慢得多?
  2. 是否有可能通过一些技巧使这种规模(至少亚线性)与 CPU 的数量一致?

编辑:@tempestadept 暗示快速排序是问题所在。为了检查这一点,我以与上面示例相同的精神实现了一个简单的合并排序。它具有相同的行为,您添加的功能越多,执行速度就越慢。

import System.Random
import Control.Parallel

splitList :: [a] -> ([a], [a])
splitList = helper True [] []
  where helper _ left right [] = (left, right)
        helper True  left right (x:xs) = helper False (x:left) right xs
        helper False left right (x:xs) = helper True  left (x:right) xs

merge :: (Ord a) => [a] -> [a] -> [a]
merge xs [] = xs
merge [] ys = ys
merge (x:xs) (y:ys) = case x<y of
  True  -> x : merge xs (y:ys)
  False -> y : merge (x:xs) ys

mergeSort :: (Ord a) => [a] -> [a]
mergeSort xs = pMergeSort 16 xs -- we use 16 sparks

-- pMergeSort, parallel merge sort. Takes an extra argument
-- telling how many sparks to create. In our simple test it is
-- set to 16
pMergeSort :: (Ord a) => Int -> [a] -> [a]
pMergeSort _ [] = []
pMergeSort _ [a] = [a]
pMergeSort 0 xs =
  let (left, right) = splitList xs
  in  merge (pMergeSort 0 left) (pMergeSort 0 right)
pMergeSort n xs =
  let (left, right) = splitList xs
      l = pMergeSort (n `div` 2) left
      r = pMergeSort (n `div` 2) right
  in  (r `par` l) `pseq` (merge l r)

ris :: Int -> IO [Int]
ris n = do
  gen <- getStdGen
  return . (take n) $ randoms gen

main = do
  r <- ris 100000
  putStrLn . show . sum $ mergeSort r

【问题讨论】:

  • 请注意,这实际上是一个快速排序的实现:stackoverflow.com/questions/7717691/…
  • 至少我无法让它在使用pseq 时表现得更好,即使在使用sum 清除任何可能的thunk 时也是如此。也许涉及到一个完全不同的问题。 — 正如我现在已通过回答删除,这里再次作为评论: 1. 将该函数命名为 quicksort 可能会造成混淆,因为您不希望这样的函数接受额外的并行参数; 2. 使用类型签名,仅always 用于顶级函数,当它们的工作方式可能与名称所暗示的略有不同时更是如此; 3.尽可能使用partition等库函数。 — 好问题,顺便说一句。
  • 我没有足够的时间来发布完整的答案,但我想有两个可能的问题:(1)您应该使用l `par` u `pseq` (u ++ l)。 (2) 当您并行运行子计算时,直到需要时才真正评估它们。因此,您应该将每个子列表强制为 NF(或至少其完整结构),例如 forceList l `par` forceList u `pseq` (u ++ l) 其中forceList 是(您自己的)强制评估列表的函数。另外,为了进行适当的基准测试,我建议使用criterion
  • 如果您想要一种快速简便的方法来查看 Spark 的运行情况,可以使用 -rtsopts 标志进行编译,然后在运行程序时添加 -sstderr 标志。跨度>
  • 实际上,只要我使用的线程数不超过内核数,mergesort 实现在我的机器上以几乎恒定的速度执行。我开始认为我们遇到的主要问题与内存/缓存有关。列表在这方面并不是很好。如果 所有 核心大部分时间都在等待获取内存页面,那么并行性几乎不会获得什么。在快速排序中,这显然比在归并排序中更为关键。

标签: haskell parallel-processing profiling quicksort


【解决方案1】:

已经提到了几个问题:

  • 使用列表不会提供您想要的性能。即使 this sample implementation 使用向量也比使用列表快 50 倍,因为它会进行就地元素交换。出于这个原因,我的回答将包括使用数组库massiv 来实现,而不是列表。
  • 我倾向于发现 Haskell 调度器对于 CPU 密集型任务来说远非完美,因此,正如@Edward Kmett 在他的回答中指出的那样,我们需要一个工作窃取调度器,我为上述库方便地实现了它:scheduler
-- A helper function that partitions a region of a mutable array.
unstablePartitionRegionM ::
     forall r e m. (Mutable r Ix1 e, PrimMonad m)
  => MArray (PrimState m) r Ix1 e
  -> (e -> Bool)
  -> Ix1 -- ^ Start index of the region
  -> Ix1 -- ^ End index of the region
  -> m Ix1
unstablePartitionRegionM marr f start end = fromLeft start (end + 1)
  where
    fromLeft i j
      | i == j = pure i
      | otherwise = do
        x <- A.unsafeRead marr i
        if f x
          then fromLeft (i + 1) j
          else fromRight i (j - 1)
    fromRight i j
      | i == j = pure i
      | otherwise = do
        x <- A.unsafeRead marr j
        if f x
          then do
            A.unsafeWrite marr j =<< A.unsafeRead marr i
            A.unsafeWrite marr i x
            fromLeft (i + 1) j
          else fromRight i (j - 1)
{-# INLINE unstablePartitionRegionM #-}

这是实际的就地快速排序

quicksortMArray ::
     (Ord e, Mutable r Ix1 e, PrimMonad m)
  => Int
  -> (m () -> m ())
  -> A.MArray (PrimState m) r Ix1 e
  -> m ()
quicksortMArray numWorkers schedule marr =
  schedule $ qsort numWorkers 0 (unSz (msize marr) - 1)
  where
    qsort n !lo !hi =
      when (lo < hi) $ do
        p <- A.unsafeRead marr hi
        l <- unstablePartitionRegionM marr (< p) lo hi
        A.unsafeWrite marr hi =<< A.unsafeRead marr l
        A.unsafeWrite marr l p
        if n > 0
          then do
            let !n' = n - 1
            schedule $ qsort n' lo (l - 1)
            schedule $ qsort n' (l + 1) hi
          else do
            qsort n lo (l - 1)
            qsort n (l + 1) hi
{-# INLINE quicksortMArray #-}

现在,如果我们查看参数 numWorkersschedule,它们是非常不透明的。假设我们为第一个参数提供1,为第二个参数提供id,我们将简单地进行顺序快速排序,但是如果我们有一个可用的函数可以安排每个任务同时计算,那么我们将获得快速排序的并行实现。幸运的是我们massiv 提供了开箱即用的withMArray

withMArray ::
     (Mutable r ix e, MonadUnliftIO m)
  => Array r ix e
  -> (Int -> (m () -> m ()) -> MArray RealWorld r ix e -> m a)
  -> m (Array r ix e)

这是一个纯版本,它将复制一个数组,然后使用数组本身中指定的computation strategy 对其进行排序:

quicksortArray :: (Mutable r Ix1 e, Ord e) => Array r Ix1 e -> Array r Ix1 e
quicksortArray arr = unsafePerformIO $ withMArray arr quicksortMArray
{-# INLINE quicksortArray #-}

这里是最好的部分,基准。结果顺序:

  • vector-algorithms 开始排序
  • 使用来自this answer 的向量进行就地快速排序
  • C 中的实现,我从this question 获取的
  • 使用massiv 的顺序快速排序
  • 与上述相同,但在具有超线程的第三代 i7 四核处理器的计算机上并行处理
benchmarking QuickSort/Vector Algorithms
time                 101.3 ms   (93.75 ms .. 107.8 ms)
                     0.991 R²   (0.974 R² .. 1.000 R²)
mean                 97.13 ms   (95.17 ms .. 100.2 ms)
std dev              4.127 ms   (2.465 ms .. 5.663 ms)

benchmarking QuickSort/Vector  
time                 89.51 ms   (87.69 ms .. 91.92 ms)
                     0.999 R²   (0.997 R² .. 1.000 R²)
mean                 92.67 ms   (91.54 ms .. 94.50 ms)
std dev              2.438 ms   (1.468 ms .. 3.493 ms)

benchmarking QuickSort/C       
time                 88.14 ms   (86.71 ms .. 89.41 ms)
                     1.000 R²   (0.999 R² .. 1.000 R²)
mean                 90.11 ms   (89.17 ms .. 93.35 ms)
std dev              2.744 ms   (387.1 μs .. 4.686 ms)

benchmarking QuickSort/Array   
time                 76.07 ms   (75.77 ms .. 76.41 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 76.08 ms   (75.75 ms .. 76.28 ms)
std dev              453.7 μs   (247.8 μs .. 699.6 μs)

benchmarking QuickSort/Array Par
time                 25.25 ms   (24.84 ms .. 25.61 ms)
                     0.999 R²   (0.997 R² .. 1.000 R²)
mean                 25.13 ms   (24.80 ms .. 25.75 ms)
std dev              991.6 μs   (468.5 μs .. 1.782 ms)

基准对 1,000,000 个随机 Int64s 进行排序。如果您想查看完整代码,可以在这里找到:https://github.com/lehins/haskell-quicksort

总而言之,我们在四核处理器和 8 种功能上获得了 3 倍的速度提升,这对我来说听起来很不错。谢谢这个问题,现在我可以给massiv添加排序功能了;)

编辑

请注意,对于这个问题,使用列表而不是更合适的数据结构(例如可变数组)的公认答案在相同输入上要慢 x100 倍:

benchmarking List/random/List Par
time                 2.712 s    (2.566 s .. 3.050 s)
                     0.998 R²   (0.996 R² .. 1.000 R²)
mean                 2.696 s    (2.638 s .. 2.745 s)
std dev              59.09 ms   (40.83 ms .. 72.04 ms)
variance introduced by outliers: 19% (moderately inflated)

【讨论】:

  • 这当然是一些令人印象深刻的表现!太糟糕了,这是一种超级单一的不安全可变代码,已经非常害怕从Data.Vector.Unboxed 中榨取最多的东西。 scheduler 也可以用于更高级别代码的任务吗? (我赏金的目的不是将超级优化的解决方案视为一个简单解决方案,它实际上见证了 Haskell 被认为适合编写漂亮的、纯功能的并行化代码,但不一定能提供绝对的最佳性能但提供合理的性能,可轻松地扩展。)
  • 是的,您可以在数组上下文之外使用scheduler 库。但请记住,因为它使用forkIO 进行调度,所以它必须存在于IO 中,因为这样的纯功能并行代码是不可能的。快速排序也必须到位,所以没有突变,至少ST monad 这个问题不能让你满意;)
  • 就地分区真的是最好的方法吗?我认为您可以通过将数组划分为段来获得更多的并行性,让每个线程将一个段划分为自己的存储桶,然后并行合并存储桶。
【解决方案2】:

我不确定它对于惯用的快速排序有多好,但它对于真正的命令式快速排序可以(在某种程度上)适用,如 Roman 在 Sparking Imperatives 中所示。

不过,他从来没有得到很好的加速。这确实需要一个真正的work-stealing deque,它不会像 spark 队列那样溢出才能正确优化。

【讨论】:

  • 我在火花队列中撞墙了吗?我只使用标准杆 16 次将问题细分为我的 16 项能力。之后,算法是顺序的。也许我不了解有关 par、pseq 和 sparks 本质的基本知识?
【解决方案3】:

鉴于@lehins 的出色回答,我不确定这是否值得注意,但是...

为什么你的pQuickSort 不起作用

您的pQuickSort 存在两个大问题。第一个是您正在使用System.Random,它速度很慢并且与并行排序奇怪地交互(见下文)。第二个是你的par u l 引发计算来评估:

u = [x] ++ pQuicksort (n `div` 2) upper

到 WHNF,即u = x : UNEVALUATED_THUNK,所以你的火花没有做任何实际的工作。

使用简单的伪快速排序观察改进

事实上,当并行化一个简单的、非原位的、伪快速排序时,不难观察到性能改进。如前所述,一个重要的考虑因素是避免使用System.Random。使用快速 LCG,我们可以对实际排序时间进行基准测试,而不是一些奇怪的排序和随机数生成混合。以下伪快速排序:

import Data.List

qsort :: Ord a => [a] -> [a]
qsort (x:xs)
  = let (a,b) = partition (<=x) xs
    in qsort a ++ x:qsort b
qsort [] = []

randomList :: Int -> [Int]
randomList n = take n $ tail (iterate lcg 1)
  where lcg x = (a * x + c) `rem` m
        a = 1664525
        c = 1013904223
        m = 2^32

main :: IO ()
main = do
  let randints = randomList 5000000
  print . sum $ qsort randints

使用 GHC 8.6.4 和 -O2 编译时,运行时间约为 9.7 秒。以下“并行化”版本:

qsort :: Ord a => [a] -> [a]
qsort (x:xs)
  = let (a,b) = partition (<=x) xs
        a' = qsort a
        b' = qsort b
    in (b' `par` a') ++ x:b'
qsort [] = []

使用ghc -O2 -threaded 编译的一项功能在大约 11.0 秒内运行。添加+RTS -N4,运行7.1秒。

哒哒!改进。

(相比之下,System.Random 的版本在非并行版本上运行大约 13 秒,在一个能力上运行并行版本大约 12 秒(可能只是因为一些小的严格性改进),并且显着减慢每增加一个额外的能力;时间也是不稳定的,虽然我不太清楚为什么。)

拆分partition

这个版本的一个明显问题是,即使a' = qsort ab' = qsort b 并行运行,它们也被绑定到相同的顺序partition 调用。通过将其分为两个过滤器:

qsort :: Ord a => [a] -> [a]
qsort (x:xs)
  = let a = qsort $ filter (<=x) xs
        b = qsort $ filter (>x)  xs
    in b `par` a ++ x:b
qsort [] = []

我们使用-N4 将处理速度提高到大约 5.5 秒。公平地说,即使是 non-parallel 版本实际上也稍微快一点,用两个filters 代替partition 调用,至少在对Ints 进行排序时。与分区相比,过滤器可能还有一些额外的优化,使额外的比较值得。

减少火花数

现在,您在上面的pQuickSort 中尝试做的是将并行计算限制为最顶层的递归集。让我们使用下面的psort 来试验一下:

psort :: Ord a => Int -> [a] -> [a]
psort n (x:xs)
  = let a = psort (n-1) $ filter (<=x) xs
        b = psort (n-1) $ filter (>x)  xs
    in if n > 0 then b `par` a ++ x:b else a ++ x:b
psort _ [] = []

这将并行化递归的顶层n。我的种子为 1(即iterate lcg 1)的特定 LCG 示例最多递归 54 层,因此psort 55 应该提供与完全并行版本相同的性能,除了跟踪层的开销。当我运行它时,-N4 的时间约为 5.8 秒,因此开销非常小。

现在,看看我们减少层数会发生什么:

| Layers |  55 |  40 |  30 |  20 |  10 |   5 |   3 |    1 |
|--------+-----+-----+-----+-----+-----+-----+-----+------|
| time   | 5.5 | 5.6 | 5.7 | 5.4 | 7.0 | 8.9 | 9.8 | 10.2 |

请注意,在最低层,并行计算几乎没有什么好处。这主要是因为树的平均深度大概在 25​​ 层左右,所以只有 50 层的计算很少,其中很多都有奇怪的、不平衡的分区,而且它们当然也是小到并行化。另一方面,这些额外的par 调用似乎没有任何惩罚。

同时,至少有 20 层的收益越来越高,因此试图人为地将火花总数限制为 16 层(例如,前 4 层或 5 层)是一个很大的损失。

【讨论】:

  • 奖励这个答案的赏金,虽然这里的并行性增益并不令人敬畏,因为它实际上保持了可以称为函数式编程的东西。令人不安的结论是,即使在今天,要从现代处理器中获得真正好的性能,您仍然需要将大量工作负载放入丑陋的低级代码中。 (幸运的是,Haskell 很适合将低级代码包装到库中,但仍然很遗憾。)
【解决方案4】:

您不会得到任何明显的改进,因为您的伪快速排序涉及列表连接,它不能并行化并且需要二次时间(所有连接的总时间)。我建议您尝试使用合并排序,即链表上的 O(n log n)

另外,要使用大量线程运行程序,您应该使用-rtsopts 编译它。

【讨论】:

  • 我添加了一个合并排序实现。我已经注意使算法在拆分和合并期间只遍历列表一次,这应该是最佳的。但是,它显示与快速排序相同的症状。越慢,你投入的能力就越多。只有超过 24 种功能才需要 -rtsopts。
  • 列表串联二次如何及时?
  • 我的意思是,所有连接的总时间是二次的
  • 列表连接可以并行化。诀窍是让主线程设置连接,在结果中保存指向段之间边界的指针。这些指针可以连同适当的大小一起传递给新线程;然后每个线程强制其共享结果(使用drop)。
【解决方案5】:

par 只计算弱头范式的第一个参数。也就是说:如果第一个参数的类型是Maybe Int,那么par 将检查结果是Nothing 还是Just something 并停止。它根本不会评估something。同样对于列表,它只评估足以检查列表是[] 还是something:something_else。要并行评估整个列表:您不会将列表直接传递给par,而是创建一个依赖于列表的表达式,当您将其传递给par 时,需要整个列表。例如:

evalList :: [a] -> ()
evalList [] = ()
evalList (a:r) = a `pseq` evalList r

pMergeSort :: (Ord a) => Int -> [a] -> [a]
pMergeSort _ [] = []
pMergeSort _ [a] = [a]
pMergeSort 0 xs =
  let (left, right) = splitList xs
  in  merge (pMergeSort 0 left) (pMergeSort 0 right)
pMergeSort n xs =
  let (left, right) = splitList xs
      l = pMergeSort (n `div` 2) left
      r = pMergeSort (n `div` 2) right
  in  (evalList r `par` l) `pseq` (merge l r)

另一个注意事项:在 Haskell 中启动新线程的开销非常低,所以 pMergeSort 0 ... 的情况可能没有用。

【讨论】:

  • 0 的情况仍然有用,因为仍然存在一些开销,而且当 spark 队列溢出时运行时会丢失工作。请参阅 Kmett 的回答。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-11-03
  • 2022-11-02
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多