“reduce”函数可以在函数式编程中并行化吗?

时间:2016-02-06 22:09:09

标签: parallel-processing functional-programming simd

功能编程中,map函数的一个好处是可以实现它以并行执行。

因此,在4核硬件上,此代码和map的并行实现将允许4个值同时处理

let numbers = [0,1,2,3]
let increasedNumbers = numbers.map { $0 + 1 }

很好,现在让我们谈谈reduce功能。

  

返回重复调用与累计相关的结果   值初始化为初始和自身的每个元素,即   return combine(combine(... combine(combine(initial,self [0]),   self [1]),... self [count-2]),self [count-1])。

我的问题:是否可以实现reduce函数以便并行执行? 或者,根据定义,它可以仅按顺序执行

示例:

let sum = numbers.reduce(0) { $0 + $1 }

2 个答案:

答案 0 :(得分:4)

最常见的减少之一是所有元素的总和。

((a+b) + c) + d == (a + b) + (c+d)  # associative
a+b == b+a                          # commutative

该等式适用于整数,因此您可以将操作的顺序从一个长依赖链更改为多个较短的依赖链,从而允许多线程和SIMD并行。

对于数学实数,but not for floating point numbers也是如此。在许多情况下,catastrophic cancellation不是预期的,因此最终结果将足够接近,值得获得巨大的性能提升。对于C / C ++编译器,这是-ffast-math选项启用的优化之一。 (-fassociative-math的这一部分只有-ffast-math选项,没有关于缺乏无穷大和NaN的假设。)

如果一个宽负载无法获得多个有用的值,那么很难获得更多的SIMD加速。英特尔的AVX2增加了"聚集"负载,但开销很高。使用Haswell,使用标量代码通常会更快,但后来的微体系结构确实有更快的收集。因此,SIMD减少对数组或连续存储的其他数据更有效。

现代SIMD硬件的工作原理是将2个连续双精度浮点数加载到向量寄存器中(例如,使用16B向量,如' s ) 。有一个packed-FP-add指令,它添加了两个向量的相应元素。所谓的"垂直"向量运算(两个向量中的相应元素之间发生相同的操作)比水平"操作(在一个向量中相互添加两个double)。

所以在asm级别,你有一个循环,它将所有偶数元素加到向量累加器的一半,所有奇数元素加到另一半。然后在最后的一个水平操作组合它们。因此,即使没有多线程,使用SIMD也需要关联操作(或者至少足够接近关联,就像通常的浮点一样)。如果您的输入中有一个近似模式,例如+1.001,-0.999,那么将一个大正数加到一个大负数的取消错误可能会比每次取消单独发生时更糟糕。

使用更宽的向量或更窄的元素,向量累加器将容纳更多元素,从而增加了SIMD的优势。

现代硬件具有流水线执行单元,每个时钟可以维持一个(或有时两个)FP向量加法,但每个硬件的结果都没有准备好5个周期。使硬件的吞吐能力饱和需要在循环中使用多个累加器,因此有5或10个独立的循环承载依赖链。 (具体而言,英特尔Skylake的矢量-FP乘法,加法或FMA(融合乘法 - 加法)具有4c延迟和每0.5c吞吐量一个.4c / 0.5c = 8 FP在飞行中同时增加以使Skylake和#39饱和; s FP数学单元。每个操作可以是一个32B向量的八个单精度浮点数,四个双精度浮点数,一个16B向量或一个标量。(在飞行中保持多个操作也可以加速标量,但是如果有任何数据级并行可用,您可以对其进行矢量化以及使用多个累加器。)请参阅http://agner.org/optimize/了解x86指令时序,流水线描述和asm优化内容。但请注意这里的一切都适用于带有NEON,PPC Altivec和其他SIMD架构的ARM 。它们都有向量寄存器和类似的向量指令。

具体例子是here's how gcc 5.3 auto-vectorizes a FP sum reduction。它只使用一个累加器,因此它错过了Skylake的8倍吞吐量。 clang更聪明一点,uses two accumulators, but not as many as the loop unroll factor获得Skylake最大吞吐量的1/4。请注意,如果从编译选项中取出-ffast-math,则FP循环使用addss(添加标量单)而不是addps(添加打包单)。整数循环仍然自动矢量化,因为整数数学是关联的。

实际上,内存带宽在大多数情况下都是限制因素。 Haswell和后来的Intel CPU可以从L1缓存每个周期维持两个32B负载。 In theory, they could sustain that from L2 cache。共享L3缓存是另一个故事:它比主内存快得多,但其带宽由所有内核共享。这使得L1或L2的缓存阻塞(又名loop tiling)成为非常重要的优化,当它可以廉价地完成时,使用超过256k的数据。而不是产生然后减少10MiB数据,产生128k块并减少它们,而它们仍然在L2缓存中而不是生产者必须将它们推送到主存储器并且减速器必须将它们带回来。当工作时在更高级别的语言中,您最好的选择可能是希望实现为您执行此操作。不过,这就是理想情况下你想要在CPU实际上做的事情。

请注意,所有SIMD加速内容都适用于在连续内存块上运行的单个线程。 你(或你的函数式语言的编译器!)可以而且应该使用这两种技术,让多个线程各自使执行单元在他们正在运行的核心上饱和。

很抱歉这个答案中没有功能编程。您可能已经猜到我因为SIMD标记而看到了这个问题。 :P

我不会尝试从其他操作中加以概括。 IDK功能编程人员通过减少来获得什么样的东西,但是添加或比较(找到最小/最大,计数匹配)是用作SIMD优化示例的那些。

答案 1 :(得分:1)

有一些用于函数编程语言的编译器,它们并行化nilreduce函数。这是来自Futhark编程语言的示例,该语言编译为并行CUDA和OpenCL源代码:

map

尽管尚未完成,但可能会编写将Haskell的子集转换为Futhark的编译器。 Futhark语言不允许递归函数,但可以在该语言的将来版本中实现。