Intel TBB 开发指南 2 Parallelizing Simple Loops

原文

Initializing and Terminating the Library

TBB 2.2 及更高版本会自动初始化任务调度程序。 你可以使用类 task_scheduler_init 显式初始化任务调度程序,这对于执行以下任何操作都很有用:

  • 控制何时构造和销毁任务调度程序。
  • 指定任务调度程序使用的线程数。
  • 指定工作线程的堆栈大小。

parallel_for

假设你想将函数 Foo 应用于数组的每个元素,并且并发处理每个元素是安全的。 这是执行此操作的顺序代码:

void SerialApplyFoo( float a[], size_t n ) {
    for( size_t i=0; i!=n; ++i )
        Foo(a[i]);
}

这里的迭代空间是 size_t 类型,从 0n-1。 模板函数 tbb::parallel_for 将这个迭代空间分成块,并在单独的线程上运行每个块。 并行化这个循环的第一步是将循环体转换成对块进行操作的形式。 表单是一个 STL 风格的函数对象,称为主体对象,operator() 在其中处理一个块。 以下代码声明了 body 对象。 TBB 所需的额外代码以粗体显示。

#include "tbb/tbb.h"
using namespace tbb;

class ApplyFoo {
  float *const my_a;
public:
  void operator()( const blocked_range<size_t>& r ) const {
    float *a = my_a;
    for( size_t i=r.begin(); i!=r.end(); ++i ) 
       Foo(a[i]);
  }

  ApplyFoo( float a[] ) : my_a(a) {}
};

示例中的 using 指令使你能够使用库标识符,而无需在每个标识符之前写出名称空间前缀 tbb。其余示例假定存在此类 using 指令。
注意operator() 的参数。 blocked_range<T> 是库提供的模板类。它描述了类型 T 上的一维迭代空间。类 parallel_for 也适用于其他类型的迭代空间。该库为二维空间提供了 blocks_range2d。你可以按照高级主题:其他类型的迭代空间中的说明定义自己的空间。
ApplyFoo 的实例需要成员字段来记住在原始循环之外定义但在其中使用的所有局部变量。通常,body 对象的构造函数会初始化这些字段,尽管 parallel_for 并不关心 body 对象是如何创建的。模板函数parallel_for 要求body 对象有一个复制构造函数,它被调用来为每个工作线程创建一个单独的副本(或多个副本)。它还调用析构函数来销毁这些副本。在大多数情况下,隐式生成的复制构造函数和析构函数正常工作。如果不是,则几乎总是情况(在 C++ 中通常如此)你必须将两者定义为一致。
因为 body 对象可能会被复制,所以它的 operator() 不应该修改 body。否则,修改可能会或可能不会对调用 parallel_for 的线程可见,具体取决于 operator() 是作用于原始还是副本。作为这种细微差别的提醒,parallel_for 要求将主体对象的 operator() 声明为 const
示例 operator()my_a 加载到局部变量 a 中。虽然不是必需的,但在示例中这样做有两个原因:

  • 风格。它使循环体看起来更像原来的。
  • 表现。有时将经常访问的值放入局部变量有助于编译器更好地优化循环,因为局部变量通常更容易被编译器跟踪。

将循环体编写为主体对象后,调用模板函数 parallel_for,如下所示:

#include "tbb/tbb.h"
 
void ParallelApplyFoo( float a[], size_t n ) {
    parallel_for(blocked_range<size_t>(0,n), ApplyFoo(a));
}

这里构造的blocked_range代表了从0n-1的整个迭代空间,parallel_for为每个处理器划分了子空间。 构造函数的一般形式是blocked_range<T>(begin,end,grainsize)T 指定值类型。 参数 beginend 将迭代空间 STL 样式指定为半开区间 [begin,end)。 参数粒度在控制分块部分中进行了解释。 该示例使用默认粒度 1,因为默认情况下,parallel_for 应用了一种适用于默认粒度的启发式方法。

Lambda Expressions

C++11 lambda 表达式使 TBB parallel_for 更易于使用。 lambda 表达式让编译器可以完成创建函数对象的繁琐工作。
下面是上一节的示例,用 lambda 表达式重写。 以粗体显示的 lambda 表达式替换了上一节示例中函数对象 ApplyFoo 的声明和构造。

#include "tbb/tbb.h"
using namespace tbb;
 
void ParallelApplyFoo( float* a, size_t n ) {
   parallel_for( blocked_range<size_t>(0,n), 
     [=](const blocked_range<size_t>& r) {
       for(size_t i=r.begin(); i!=r.end(); ++i)
         Foo(a[i]);
     }
  );
}

[=] 引入了 lambda 表达式。 该表达式创建了一个与 ApplyFoo 非常相似的函数对象。 当像 an 这样的局部变量在 lambda 表达式之外声明,但在其中使用时,它们被“捕获”为函数对象内的字段。 [=] 指定按值捕获。 改写 [&] 将通过引用捕获值。 [=] 之后是生成的函数对象的 operator() 的参数列表和定义。 编译器文档详细介绍了 lambda 表达式和其他已实现的 C++11 功能。 值得阅读比这里更完整的 lambda 表达式描述,因为 lambda 表达式是一般使用模板库的强大功能。

为了进一步紧凑,TBB 有一种 parallel_for 形式,专门用于在连续整数范围内并行循环。 表达式 parallel_for(first,last,step,f) 就像写 for(auto i=first; i<last; i+=step)f(i) 不同之处在于,如果资源允许,每个 f(i) 都可以并行计算。 step 参数是可选的。 这是以紧凑形式重写的先前示例:

#include "tbb/tbb.h"
using namespace tbb;
 
#pragma warning(disable: 588)
 
void ParallelApplyFoo(float a[], size_t n) {
    parallel_for(size_t(0), n, [=](size_t i) {Foo(a[i]);});
}

紧凑形式仅支持整数的一维迭代空间和下一节详述的自动分块功能。

Automatic Chunking

并行循环结构会为其调度的每个工作块带来开销成本。 自 2.2 版起, TBB 根据负载平衡需求自动选择块大小。[1] 启发式尝试限制开销,同时仍为负载平衡提供充足的机会。

警告
通常一个循环需要至少一百万个时钟周期才能使用parallel_for。 例如,在 2 GHz 处理器上花费至少 500 微秒的循环可能会受益于 parallel_for

对于大多数用途,建议使用默认的自动分块。 然而,与大多数启发式方法一样,在某些情况下,更精确地控制块大小可能会产生更好的性能。

Controlling Chunking

分块由分区器和粒度控制。要获得对分块的最大控制,你可以同时指定两者。

  • 指定 simple_partitioner() 作为 parallel_for 的第三个参数。 这样做会关闭自动分块。
  • 在构建范围时指定粒度。 构造函数的线程参数形式是blocked_range<T>(begin,end,grainsize)。 粒度的默认值为 1。它以每个块的循环迭代为单位。

如果块太小,开销可能会超过性能优势。
以下代码是来自 parallel_for 的最后一个示例,修改为使用显式粒度 G。添加以粗体显示。

#include "tbb/tbb.h"
 
void ParallelApplyFoo( float a[], size_t n ) {
    parallel_for(blocked_range<size_t>(0,n,G), ApplyFoo(a), 
                 simple_partitioner());
}

粒度设置了并行化的最小阈值。示例中的 parallel_for 对可能具有不同大小的块调用 ApplyFoo::operator()。令 chunksize 为块中的迭代次数。使用 simple_partitioner 保证 ⌈G/2⌉≤chunksize≤ G。
还有一个中间级别的控制,你可以在其中指定范围的粒度,但使用 auto_partitioneraffinity_partitionerauto_partitioner 是默认分区程序。两个分区器都实现了自动分块中描述的自动粒度启发式。一个affinity_partitioner 隐含了一个额外的提示,正如稍后在带宽和缓存亲和性一节中解释的那样。尽管这些分区器可能会导致块的迭代次数超过 G 次,但它们永远不会生成少于 ⌈G/2⌉ 次迭代的块。指定具有显式粒度的范围有时可能有助于防止这些分区器在其启发式失败时生成浪费的小块。
由于grainsize对并行循环的影响,即使你依赖auto_partitioneraffinity_partitioner自动选择grainsize,也值得阅读以下材料。

包装开销与颗粒大小
Case ACase A Case B Case B

上图通过将有用的工作显示为代表开销的棕色边框内的灰色区域来说明粒度的影响。 案例 A 和案例 B 具有相同的总灰色区域。 案例 A 显示了粒度太小如何导致开销比例相对较高。 案例 B 显示了大粒度如何以降低潜在并行性为代价降低这一比例。 作为有用功的一部分的开销取决于颗粒大小,而不是颗粒数量。 在设置粒度时,请考虑这种关系,而不是迭代总数或处理器数量。
根据经验,operator() 的粒度迭代应该至少需要 100,000 个时钟周期才能执行。 例如,如果单次迭代需要 100 个时钟,那么粒度至少需要 1000 次迭代。 如有疑问,请进行以下实验:

  1. 将粒度参数设置为高于必要值。 粒度以循环迭代为单位指定。 如果你不知道迭代可能需要多少个时钟周期,请从粒度 = 100,000 开始。 基本原理是每次迭代通常需要每次迭代至少一个时钟。 在大多数情况下,第 3 步会引导你获得更小的值。
  2. 运行你的算法。
  3. 迭代地将粒度参数减半,并查看算法随着值的减小而减慢或加速的程度。

将粒度设置得太高的一个缺点是它会降低并行度。 例如,如果粒度为 1000 并且循环有 2000 次迭代,parallel_for 将循环仅分布在两个处理器上,即使有更多处理器可用。 但是,如果你不确定,请在过高而不是过低的方面犯错,因为太低的值会损害串行性能,如果在调用中还有其他更高的并行可用,则反过来又会损害并行性能 树。

提示
你不必太精确地设置粒度。

下图显示了执行时间与粒度的典型“浴缸曲线”,基于浮点 a[i]=b[i]*c 计算超过一百万个索引。 每次迭代几乎没有工作。 这些时间是在具有八个硬件线程的四插槽机器上收集的。

挂钟时间与粒度
grainsize
尺度是对数的。 左侧向下的斜率表示粒度为 1 时,大部分开销是并行调度开销,而不是有用的工作。 粒度的增加会导致并行开销成比例地减少。 然后曲线变平,因为对于足够大的粒度,并行开销变得微不足道。 在右侧的末尾,曲线向上,因为块太大,以至于块比可用的硬件线程少。 请注意,粒度在 100-100,000 范围内的效果非常好。

提示
并行化循环嵌套的一般经验法则是尽可能并行化最外层的嵌套。 原因是外循环的每次迭代可能比内循环的迭代提供更大的工作粒度。

Bandwidth and Cache Affinity

对于足够简单的函数 Foo,这些示例在编写为并行循环时可能不会显示出良好的加速效果。 原因可能是处理器和内存之间的系统带宽不足。 在这种情况下,你可能需要重新考虑你的算法以更好地利用缓存。 重构以更好地利用缓存通常有利于并行程序和串行程序。
在某些情况下,重组的替代方法是affinity_partitioner。 它不仅会自动选择粒度,还会针对缓存亲和性进行优化,并尝试在线程之间均匀分配数据。 在以下情况下,使用affinity_partitioner 可以显着提高性能:

  • 每次数据访问时,计算都会执行一些操作。
  • 循环所处理的数据适合缓存。
  • 循环或类似的循环对相同的数据重新执行。
  • 有两个以上的硬件线程可用(尤其是当线程数不是 2 的幂时)。 如果只有两个线程可用,TBB 中的默认调度通常会提供足够的缓存关联。

以下代码展示了如何使用affinity_partitioner

#include "tbb/tbb.h"
 
void ParallelApplyFoo( float a[], size_t n ) {
    static affinity_partitioner ap;
    parallel_for(blocked_range<size_t>(0,n), ApplyFoo(a), ap);
}
 
void TimeStepFoo( float a[], size_t n, int steps ) {    
    for( int t=0; t<steps; ++t )
        ParallelApplyFoo( a, n );
}

在这个例子中,affinity_partitioner 对象 ap 存在于循环迭代之间。 它记住循环的迭代在哪里运行,以便每次迭代都可以交给之前执行它的同一个线程。 示例代码通过将affinity_partitioner 声明为本地静态对象来获得分区器的正确生命周期。 另一种方法是在 TimeStepFoo 中的迭代循环之外的范围内声明它,并将其传递给 parallel_for 的调用链。
如果数据不适合系统的缓存,则可能没有什么好处。 下图显示了这些情况。

由数据集和缓存的相对大小决定的亲和性的好处
affinity

下图显示了并行加速如何随数据集的大小而变化。 该示例的计算是 A[i]+=B[i] 对于范围 [0,N) 中的 i。 选择它是为了戏剧效果。 你不太可能在代码中看到如此多的变化。 该图显示在极端情况下没有太大改善。 对于小 N,并行调度开销占主导地位,导致很少的加速。 对于大 N,数据集太大而无法在循环调用之间的缓存中携带。 中间的峰值是亲和力的最佳点。 因此,当计算与内存访问的比率较低时,affinity_partitioner 应该被视为一种工具,而不是万灵药。

从基于数组大小的亲和性的改进
affinity

Partitioner Summary

并行循环模板parallel_forparallel_reduce 接受一个可选的partitioner 参数,它指定执行循环的策略。 下表总结了分区器及其与 blocks_range 结合使用时的效果。

PartitionerDescriptionWhen Used with blocked_range(i,j,g)
simple_partitionerChunksize bounded by grain size.g/2 ≤ chunksize ≤ g
auto_partitioner (default)[4]Automatic chunk size.g/2 ≤ chunksize
affinity_partitionerAutomatic chunk size, cache affinity and uniform distribution of iterations.
static_partitionerDeterministic chunk size, cache affinity and uniform distribution of iterations without load balancing.max(g/3, problem_size/num_of_resources) ≤ chunksize

当没有指定分区器时使用 auto_partitioner。通常,应使用 auto_partitioneraffinity_partitioner,因为它们会根据可用的执行资源定制块的数量。 affinity_partitionerstatic_partitioner 可以利用 Range 能力以给定的比例进行拆分(参见“高级主题:其他类型的迭代空间”),以在计算资源之间以几乎相等的块分布迭代。
simple_partitioner 在以下情况下很有用:

  • operator() 的子范围大小不得超过限制。这可能是有利的,例如,如果你的 operator() 需要一个与范围大小成比例的临时数组。在子范围大小有限的情况下,你可以为数组使用自动变量,而不必使用动态内存分配。
  • 大的子范围可能会低效地使用缓存。例如,假设子范围的处理涉及对相同存储位置的重复扫描。将子范围保持在限制以下可能会使重复引用的内存位置适合缓存。有关此场景的示例,请参阅 examples/parallel_reduce/primes/primes.cpp 中的 parallel_reduce 的使用。
  • 你想为特定的机器调优。
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值