由于您要求进行详尽的解释,而上一个答案仅涵盖基础知识,因此我冒昧地添加更详尽的解释。
std::reduce 旨在执行MapReduce programming model 的第二个主要步骤。基本思想是平台(在本例中为 C++ 实现)提供这两个原始操作 map 和 reduce,程序员为执行“实际工作”的这两个操作提供回调操作。
基本上,map 操作的回调将输入值映射到中间值。 reduce 的回调将两个中间值合并为一个中间值。最后剩下的中间值成为整个 MapReduce 的输出。它本身似乎是一个非常严格的模型,但它仍然具有广泛的应用范围。
当然,平台必须做更多的事情,例如“洗牌”(将输入,通常以组的形式,分配给不同的处理单元)和调度,但这对应用程序程序员来说并不重要。
顺便说一句,在 C++ 标准库中,“映射”操作称为transform。它在 C++17 中也获得了并行性支持,但我稍后会介绍并行性。
这是一个虚构的例子:假设我们有一个将整数转换为字符串表示的函数。现在,给定一个整数列表,我们想要包含最大比例的辅音与人声的文本表示。例如
(如果您不相信这个结果,请自己尝试。)
我们可以在这里使用 MapReduce,通过使用我们的 int-to-text 函数作为 map 的回调 (rsp.std::transform),以及一个计算辅音和人声数量然后选择左手或右手的函数- 相应的手参数。这里有一些低效率的地方,特别是“比率”应该被缓存,但这些都是细节。
现在您的问题可能而且可能应该是:我为什么要关心?毕竟,到目前为止,您并没有从使用例如std::transform 和 std::reduce 以这种方式,您也可以使用 std::accumulate 代替后者。当然,如果输入值足够多,答案就是执行策略——前两个标准函数模板具有允许隐式并行性的重载。但这仍然引出了一个问题,为什么要使用 MapReduce 而不是线程池或std::async,以及一堆手写循环?首先,特别是对于大型向量或其他容器上的“纯”计算,没有 I/O,编写两个 MapReduce 回调通常更方便,因为您不必处理输入数据的所有细节分散到不同的线程,然后合并。
其次,MapReduce 鼓励以一种可以非常有效地并行化的方式来构建计算的规则。当然,在支持命令式范式的编程语言(例如 C++)中,您仍然可以通过锁定一堆互斥锁或任何干扰其他线程的方式来搞砸事情。但是 MapReduce 范式鼓励编写独立的映射回调。作为一个简单的经验法则,如果这些任务完全共享数据,那么它应该是只读的,以便它的副本可以同时存储在多个 CPU 缓存中。编写良好的任务,结合底层算法的高效平台实现,可以扩展到数百甚至数千个 CPU 内核,正如已经普遍使用的 MapReduce 平台(如 Apache Hadoop,但仅将其视为必要的)示例而不是无偿广告)。
但问题主要是关于std::reduce——我们仍然可以执行这个高度可扩展的映射,然后在中间结果上运行std::accumulate,对吗?这就是我们得到 François Andrieux 之前所写的内容的地方。 accumulate 执行数学家所说的左折叠。如果您将操作及其操作数视为二叉树,那么这将是一棵左倾树,例如添加 1、2、3 和 4:
+
/ \
+ 4
/ \
+ 3
/ \
1 2
如您所见,每个操作的结果是下一个操作的参数之一。这意味着存在数据依赖的线性链,这是所有并行性的祸根。要添加一百万个数字,您需要一百万次连续操作,这将阻塞单个内核,并且您无法使用额外的内核。他们大部分时间都无事可做,通信开销将大大超过计算成本。 (实际上比这更糟糕,因为现代 CPU 可以在每个时钟执行多个简单的计算,但是当存在如此多的数据依赖关系时这不起作用,因此大多数 ALUs 或 FPU 都未使用。)
通过解除必须使用左折叠的限制,std::reduce 允许平台更有效地利用计算资源。 即使你使用单线程重载,平台也可以,例如,使用 SIMD 在远少于一百万次的操作中添加一百万个整数,数据依赖的数量将大大减少。一个写得很好的整数加法 reduce 的 10 倍加速不会让我感到惊讶。当然,这种特殊情况可能会在 as-if 规则下进行优化,因为 C++ 实现“知道”整数加法(几乎,见下文)是关联的。
但如前所述,reduce 比这更进一步,它支持执行策略,即在大多数情况下是多核并行。理论上,可以使用平衡的二叉树操作。 (请记住,如果深度小于 2,或者左子树的深度与右子树的深度最多相差 1,则树是平衡的。)这样的树只有对数深度。如果我们有一百万个整数,最小的树深度是 20,所以——理论上——给定足够的内核并且没有通信开销,我们甚至可以比优化的单线程计算实现 50,000 倍的加速。当然,在实践中,这是一厢情愿的想法,但我们仍然可以期待大幅加速。
说了这么多,让我快速添加一个免责声明/提醒,即性能与效率不同。使用 64 个内核每个 100 毫秒意味着比使用一个内核 1,000 毫秒更高的性能,但 CPU 效率要低得多。另一种说法是,在最小化经过时间的意义上,性能是效率,但还有其他衡量效率的方法——使用的总 CPU 时间、使用的 RAM、使用的能量等等。并行 MapReduce 的主要动机是提供更高的性能。是否会减少 CPU 时间或能源消耗尚不清楚,它很可能会增加峰值 RAM 使用量。
最重要的是,这里有一些警告。如前所述,reduce 是不确定的,如果操作不是关联的或不可交换的。幸运的是,加法和乘法等最重要的算术运算是结合和交换的,对吧?例如,我们都知道整数和浮点加法具有这两个属性。当然,我在开玩笑。在 C++ 中,有符号整数加法和浮点加法都不是关联的。对于浮点数,这是由于中间结果的舍入可能存在差异。例如,如果我们选择一个具有两位有效数字的简单十进制浮点格式,并考虑总和 10 + 0.4 + 0.4,这很容易可视化。如果这是通过正常的 C++ 语法规则作为左折叠来完成的,我们得到 (10 + 0.4) + 0.4 = 10 + 0.4 = 10,因为每次结果都向下舍入到 10。但是如果我们将其作为 10 + (0.4 + 0.4),第一个中间结果是 0.8,然后 10 + 0.8 向上舍入到 11。此外,由于操作树的深度较大,舍入误差会大大放大,因此进行左折叠实际上是一个在准确性方面可以做的最糟糕的事情之一。有几种方法可以处理这种行为,从对输入进行排序和分组到使用增加的中间精度,但是当涉及到 reduce 时,可能根本无法获得 100% 的运行一致性。
另一个可能更令人惊讶的观察结果是,有符号整数加法在 C++ 中不具有关联性。这样做的原因是溢出的可能性,说白了:(-1) + 1 + INT_MAX。根据正常的语法规则,或者accumulate,结果是INT_MAX。但是如果你使用reduce,这可能会被重写为(-1) + (1 + INT_MAX),它包含一个整数溢出,因此未定义的行为。事实上,因为reduce可以任意改变操作数的顺序,即使输入为{ INT_MAX, -1, 1 }也是如此。
我在这里的建议是确保对reduce 的回调不会产生溢出。这可以通过限制允许的输入范围来完成(例如,如果添加 1000 个ints,请确保它们都不大于INT_MAX / 1000 或小于INT_MIN / 1000,四舍五入),或者,等效地,通过使用更大的整数类型,或者,作为绝对的最后手段(因为这既昂贵又难以正确处理),在reduce 回调中进行额外检查。然而,在大多数实际情况下,reduce 在整数溢出方面的安全性仅略低于accumulate。