【问题标题】:parallel "Folding" in HaskellHaskell中的并行“折叠”
【发布时间】:2013-10-01 13:47:53
【问题描述】:

我有一个函数类型如下:

union :: a -> a -> a

a 具有可加性 属性。所以我们可以把union看成是(+)的一个版本

假设我们有[a],并且想要执行并行"folding",对于非并行折叠我们只能:

foldl1' union [a]

但是如何并行执行呢? 我可以在Num 值和(+) 函数上演示问题。

例如,我们有一个列表[1,2,3,4,5,6](+) 并行我们应该拆分

[1,2,3] (+) [4,5,6]
[1,2] (+) [3] (+) [4,5] (+) [6]
([1] (+) [2]) (+) ([3] (+) [4]) (+) ([5] (+) [6])

然后我们要并行执行的每个(+)操作,并结合起来回答

[3] (+) [7] (+) [11] = 21

请注意,由于a 可加性,我们拆分列表或以任何顺序执行操作。

有没有办法使用任何标准库来做到这一点?

【问题讨论】:

标签: haskell parallel-processing


【解决方案1】:

您需要将您的union 推广到任何关联二元运算符 ⊕ 使得 (a ⊕ b) ⊕ c == a ⊕ (b ⊕ c)。如果同时你有一个相对于⊕中性的单位元素,你就有一个幺半群。

关联性的重要方面是,您可以将列表中的连续元素块任意分组,并以任意顺序 ⊕ 它们,因为 a ⊕ (b ⊕ (c ⊕ d)) == (a ⊕ b) ⊕ ( c ⊕ d) - 每个括号可以并行计算;那么您需要“减少”所有括号的“总和”,并且您已经对 map-reduce 进行了排序。

为了使这种并行化有意义,您需要分块操作比 ⊕ 更快 - 否则,顺序执行 ⊕ 比分块更好。一种这样的情况是当你有一个随机访问“列表”——比如一个数组。 Data.Array.Repa 有很多并行化的折叠函数。

如果您正在考虑自己练习实现一个,您需要选择一个好的复杂函数⊕这样才能显示出好处。

例如:

import Control.Parallel
import Data.List

pfold :: (Num a, Enum a) => (a -> a -> a) -> [a] -> a
pfold _ [x] = x
pfold mappend xs  = (ys `par` zs) `pseq` (ys `mappend` zs) where
  len = length xs
  (ys', zs') = splitAt (len `div` 2) xs
  ys = pfold mappend ys'
  zs = pfold mappend zs'

main = print $ pfold (+) [ foldl' (*) 1 [1..x] | x <- [1..5000] ]
  -- need a more complicated computation than (+) of numbers
  -- so we produce a list of products of many numbers

这里我特意使用了一个关联操作,它只在本地被称为mappend,以表明它可以用于比幺半群更弱的概念——只有关联性对并行性很重要;因为并行只对非空列表有意义,所以不需要mempty

ghc -O2 -threaded a.hs
a +RTS -N1 -s

总运行时间为 8.78 秒,而

a +RTS -N2 -s

在我的双核笔记本电脑上总运行时间为 5.89 秒。显然,在这台机器上尝试超过 -N2 是没有意义的。

【讨论】:

    【解决方案2】:

    您所描述的本质上是一个幺半群。在 GHCI 中:

    Prelude> :m + Data.Monoid
    Prelude Data.Monoid> :info Monoid
    class Monoid a where
      mempty :: a
      mappend :: a -> a -> a
      mconcat :: [a] -> a
    

    如你所见,一个幺半群具有三个相关的功能:

    1. mempty 函数有点像幺半群的恒等函数。例如,Num 可以表现为一个幺半群,需要两个操作:求和和乘积。对于总和mempty 定义为0。对于产品mempty 定义为1

      mempty `mappend` a = a
      a `mappend` mempty = a
      
    2. mappend 函数类似于您的 union 函数。例如,Nums mappend 的总和定义为 (+)Nums 的乘积将 mappend 定义为 (*)

    3. mconcat 函数类似于折叠。然而,由于幺半群的特性,我们是从左侧折叠、从右侧折叠还是从任意位置折叠都无关紧要。这是因为mappend 应该是关联的:

      (a `mappend` b) `mappend` c =  a `mappend` (b `mappend` c)
      

    但是请注意,Haskell 不执行幺半群定律。因此,如果您将一个类型设为 Monoid 类型类的实例,那么您有责任确保它满足幺半群定律。

    在您的情况下,很难从其类型签名中理解 union 的行为方式:a -&gt; a -&gt; a。当然,您不能使类型变量成为类型类的实例。这是不允许的。你需要更具体。 union 究竟做了什么?

    举个例子说明如何使一个类型成为 monoid 类型类的实例:

    newtype Sum a = Sum { getSum :: a }
    
    instance Num a => Monoid (Sum a) where
        mempty = 0
        mappend = (+)
    

    就是这样。我们不需要定义mconcat 函数,因为它有一个依赖于memptymappend 的默认实现。因此,当我们定义memptymappend 时,我们会免费获得mconcat

    现在你可以使用Sum,如下:

    getSum . mconcat $ map Sum [1..6]
    

    这是正在发生的事情:

    1. 您将Sum 构造函数映射到[1..6] 以生成[Sum 1, Sum 2, Sum 3, Sum 4, Sum 5, Sum 6]
    2. 您将结果列表提供给mconcat,后者将其折叠为Sum 21
    3. 您使用getSumSum 21 中提取Num

    但是请注意mconcat 的默认实现是foldr mappend mempty(即它是一个正确的折叠)。对于大多数情况,默认实现就足够了。但是,在您的情况下,您可能希望覆盖默认实现:

    foldParallel :: Monoid a => [a] -> a
    foldParallel []  = mempty
    foldParallel [a] = a
    foldParallel xs  = foldParallel left `mappend` foldParallel right
        where size = length xs
              index = (size + size `mod` 2) `div` 2
              (left, right) = splitAt index xs
    

    现在我们可以创建一个Monoid 的新实例,如下所示:

    data Something a = Something { getSomething :: a }
    
    instance Monoid (Something a) where
        mempty  = unionEmpty
        mappend = union
        mconcat = foldParallel
    

    我们使用如下:

    getSomething . mconcat $ map Something [1..6]
    

    请注意,我将mempty 定义为unionEmpty。我不知道union 函数作用于什么类型的数据。因此我不知道应该将mempty 定义为什么。因此,我只是称它为unionEmpty。按照您认为合适的方式定义它。

    【讨论】:

    • 我不清楚 foldParallel 的并行性。使用结合律只是促成因素。您还需要确保拆分比 mappend 更快。
    • 确实如此。拆分列表的额外开销必须通过并行执行折叠节省的时间来补偿。否则,在正常折叠上使用foldParallel 将没有任何意义。 foldParallel 函数本质上没有什么可比的。然而,因为它将列表分成两个并递归处理每个子列表,Haskell 可以进行优化并在不同的核心上处理每个子列表。因此,它启用了并行性。它不保证它。
    • AFAIK GHC 永远不会“在不同的核心上进行优化和处理每个子列表”。并行性始终是明确的。
    • 并行项/图缩减当然是可能的,但在实践中会发生多少?如果某些 Haskell 编译器在没有提示的情况下执行此操作,那将是添加到您的答案中的一个很好的示例。
    【解决方案3】:

    我知道在 OP 之后已经有很长一段时间了,但我只是碰巧遇到了这件事,并认为我的经历可能会有所帮助。

    如果我们考虑这个问题,我们可以看到:

    • 折叠本质上是一个函数,它接受一个项目列表,并将它们转换为单个项目,该项目可能与列表中的项目类型相同,但不一定是:所以它的类型是@987654321 @。

    • 并行折叠将其输入列表拆分为块,分别折叠每个块(并行),然后将结果组合以得出最终结果。为此,我们需要:

      • 块大小。这可以参考输入列表的大小来计算,但这有一个明显的缺点:为了确定列表的大小,我们必须对其进行处理,这失去了惰性的好处。所以最好让所有的块大小都一样;这可以是硬编码的,但在通用函数中,最好将其公开为参数,以便可以对其进行更改和调整以满足调用应用程序的需求。

      • 知道如何组合结果的函数。它的类型为(b -&gt; b -&gt; b)

    因此,一个合适的通用并行折叠函数是:

    import Control.Parallel
    
    foldParallel :: Int -> ([a] -> b) -> (b -> b -> b) -> [a] -> b
    foldParallel _ fold _ [] = fold []
    foldParallel chunkSize fold combine xs = par lf $ combine lf rf
      where
        (left, right) = splitAt chunkSize xs
        lf = fold left
        rf = foldParallel chunkSize fold combine right
    

    并行处理是显式完成的,使用 par 函数并行启动对其第一个操作数的评估,并返回第二个操作数。

    花了一段时间——对于像我这样一个古老的命令式编程恐龙——我才明白where 块中的定义实际上并没有评估任何东西,而只是设置了一些可以评估;因此名为lf 的折叠可以在par 的两个操作数中引用,但只计算一次。

    par 的不同之处在于,如果函数只返回 combine lf rf,则在评估时需要评估 lf,然后是 rf,然后是 combine lf rf。但是par lf $ combine lf rf 表示lf 在需要其值时已经全部或部分评估(并行)。而且因为rf 本身就是一个平行折叠,所以每个后续块的折叠也是如此。

    【讨论】:

      猜你喜欢
      • 2015-08-30
      • 2019-02-02
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多