《Today’s TBB 2nd Edition》
先引入两个概念:归约(reduce)和前缀和(scan)。reduce通过聚合操作将多个输入值合并为一个输出值。通常和map结合使用,map :将输入数据拆分为独立片段,每个片段生成中间键值对,reducution:对相同 key 的 value 集合进行聚合;而scan模式不仅计算最终聚合结果,还为每个元素生成前缀结果(这个过程类似reduction),scan的实现分块并行。对比图如下。
Item | Reduce | Scan |
---|---|---|
输出结果 | 单一聚合值(如总和) | 每个元素的前缀结果(如累积和) |
数据依赖 | 无依赖(仅需相同 key 的聚合) | 强依赖(需传递前序计算结果) |
并行难度 | 高(易分块) | 中(需处理块间依赖) |
内存占用 | 低(仅需最终结果) | 高(需存储中间结果) |
tbb::parallel_reduce
是 一个函数模板,它依赖于关联性(associative)来使用并行任务执行执行归约操作。其中一些函数签名如下,包括了lambda-friendly签名以及class-friendly签名。
template<typename Range, typename Value, typename RealBody, typename Reduction>
__TBB_requires(tbb_range<Range> && parallel_reduce_function<RealBody, Range, Value> &&
parallel_reduce_combine<Reduction, Value>)
Value parallel_reduce( const Range& range, const Value& identity, const RealBody& real_body, const Reduction& reduction,
const simple_partitioner& partitioner, task_group_context& context );
template<typename Range, typename Body>
__TBB_requires(tbb_range<Range> && parallel_reduce_body<Body, Range>)
void parallel_reduce( const Range& range, Body& body, const simple_partitioner& partitioner, task_group_context& context ) {
start_reduce<Range,Body,const simple_partitioner>::run( range, body, partitioner, context );
}
tbb::parallel_reduce
核心思想是把数据范围划分为chunk(chunk本意就是“大块”。一如TBB中的chunk,数据划分的基本单位;或者像是内存管理中的chunk管理堆内存),根据硬件特征或者负载均衡策略,划分为合适的块。标识值(identity value)作为一个body的初始值。每个任务计算部分结果,最后调用归约函数合并结果。
文中一个简单的例子,从一个有16个元素的数组中找最大值。图示如下。
int simpleParallelMax(const std::vector<int>& v) {
int max_value = tbb::parallel_reduce(
/* the range = */ tbb::blocked_range<int>(0, v.size()),
/* identity = */ std::numeric_limits<int>::min(),
/* func = */
[&](const tbb::blocked_range<int>& r, int init) -> int {
for (int i = r.begin(); i != r.end(); ++i) {
init = std::max(init, v[i]);
}
return init;
},
/* reduction = */
[](int x, int y) -> int {
return std::max(x,y);
}
);
return max_value;
}
非常直观且容易理解的例子,分为了三个步骤:
tbb::block_range
定义数据范围,经由策略划分为块;tbb::parallel_for
而言,无需显式构造 tbb::blocked_range
对象,是单维度顺序遍历的并行化。而tbb::parallel_reduce
需要显式传递 tbb::blocked_range
对象,我们需要去控制范围的分割。 下面是一个略微复杂的例子,通过数值积分的方式去计算 π \pi π,就是计算出每个矩形的高度,单位圆四分之一的面积乘以 4 4 4即为圆的面积 π \pi π,代码如下。
double serialPI(int num_intervals) {
double dx = 1.0 / num_intervals;
double sum = 0.0;
for (int i = 0; i < num_intervals; ++i) {
double x = (i+0.5)*dx;
double h = std::sqrt(1-x*x);
sum += h*dx;
}
double pi = 4 * sum;
return pi;
}
使用tbb:parallel_reduce
实现并行化。步骤包括了
double parallelPI(int num_intervals) {
double dx = 1.0 / num_intervals;
double sum = tbb::parallel_reduce(
/* range = */ tbb::blocked_range<int>(0, num_intervals),
/* identity = */ 0.0,
/* func */
[=](const tbb::blocked_range<int>& r, double init) -> double {
for (int i = r.begin(); i != r.end(); ++i) {
double x = (i + 0.5)*dx;
double h = std::sqrt(1 - x*x);
init += h*dx;
}
return init;
},
/* reduction */
[](double x, double y) -> double {
return x + y;
}
);
double pi = 4 * sum;
return pi;
}
本篇主要专注tbb:parallel_reduce
算法,遵循一种分治的设计思想,拆解大任务为独立的子任务并行处理,最终通过归约操作合并最终结果。通过tbb::blocked_range
动态划分任务粒度,自动平衡负载,使得线程的利用率最大化。tbb::blocked_range
默认按照独立内存块划分任务,利用了CPU缓存局部性减少数据访问延迟。我们无需去加锁,其一TBB的任务调度性采用的是无锁机制,其二,连续的内存划分独立任务,每个子任务仅依赖自身数据,避免共享数据的锁需求,其三,归约操作的原子性合并或者无锁任务合并,最后,lambda函数或者仿函数生成副本,又避免多线程修改同一变量。总之,分治模式结合动态负载均衡,以及缓存友好和无锁合并,实现了tbb:parallel_reduce
简洁性和高效性,成为一种归约行为下的优秀算法工具。