本节书摘来自异步社区出版社《C++ AMP:用Visual C++加速大规模并行计算》一书中的第1章,第1.2节,作者: 【美】Kate Gregory , Ade Miller,更多章节内容可以访问云栖社区“异步社区”公众号查看。
C++ AMP:用Visual C++加速大规模并行计算
减少应用程序串行部分耗时的一种方法是尽量降低其串行性,重新设计应用程序,充分利用CPU并行和GPU并行。虽然GPU可以同时拥有成千上万个线程,而CPU要少得多,但利用CPU的并行性也能对整体加速比有所帮助。理想情况下,CPU并行技术和GPU并行技术是兼容的,方法也有很多。
SIMD是一种能使处理速度变得更快的重要方法,也即单指令流多数据流(Single Instruction, Multiple Data)。在一个典型的应用程序中,一次只能取一个指令,不同的指令操作要作为应用控制流来执行。但是如果执行的是一个大型的数据并行操作,例如矩阵加法,那么反复的指令(矩阵整数或浮点数元素的加操作)就都是相同的。这意味着取指令的开销可以分摊到大量操作上,在不同数据(例如,矩阵的不同元素)上执行相同的指令。这么做可以大大加快执行速度,同时减少计算功耗。
向量化指的是应用程序从每次以独立的指令流处理一份数据,变成一下子处理一个信息向量,在处理时对向量中的每个元素采取相同的指令。有些编译器可以自动为循环和其他并行操作进行向量化。
微软Visual Studio 2012支持通过SSE(SIMD流指令扩展)内建函数(intrinsic function)进行手工向量化。表面来看内建函数就是代码函数,但它们会直接映射成汇编语言指令序列,从而避免函数调用的开销。与内联汇编不同,优化器能够理解这些内建函数,它会对代码的其他部分进行相应的优化。虽然内建函数比内联汇编更容易迁移,但它们仍然会有一些可移植性问题,因为它们要依赖于目标平台体系架构的特殊指令。开发人员要弄清楚在目标机型上是否配有芯片可以支持这些内建函数。因此,内建函数_ _cpuid()
自然就出现了,它生成指令填充4个整数,描述处理器的功能。(因为_ _cpuid()
是编译器相关的,所以它以两个下画线作为前缀。)要检查SSE3是否被支持,可以使用下述代码:
int CPUInfo[4] = { -1 };
__cpuid(CPUInfo, 1);
bool bSSEInstructions = (CpuInfo[3] >> 24 & 0x1);```
注意事项:
MSDN的“`_ _cpuid`”专题是关于`_ _cpuid`的完整帮助文档,讲解了为什么要把第二个参数设置成1,要用哪个位来检查SSE3指令集支持的细节信息,以及如何检测是否支持其他待用特性,专题地址是: http://msdn.microsoft.com/en-us/library/hskdteyh(v=vs.100).
aspx.aspx)
究竟要使用哪个内建函数完全取决于我们要如何设计并行性。比方说现在我们要把许多数对加起来。一个内建函数_mm_hadd_epi32一次可以对4对32位数做加法。我们用输入值填充两个内存对齐的128位数,然后马上调用内建函数将它们相加,最后得到一个128位值,这个值可以拆分成4个32位数,表示各数对的求和。下面是MSDN中的示例程序:
int main ()
{
__m128i a, b;
a.m128i_i32[0] = -1;
a.m128i_i32[1] = 1;
a.m128i_i32[2] = 0;
a.m128i_i32[3] = 65535;
b.m128i_i32[0] = -65535;
b.m128i_i32[1] = 0;
b.m128i_i32[2] = 128;
b.m128i_i32[3] = -32;
__m128i res = _mm_hadd_epi32(a, b);
std::wcout << "Original a: " <<
a.m128i_i32[0] << "t" << a.m128i_i32[1] << "t" <<
a.m128i_i32[2] << "t" << a.m128i_i32[3] << "t" << std::endl;
std::wcout << "Original b: " <<
b.m128i_i32[0] << "t" << b.m128i_i32[1] << "t" <<
b.m128i_i32[2] << "t" << b.m128i_i32[3] << std::endl;
std::wcout << "Result res: " <<
res.m128i_i32[0] << "t" << res.m128i_i32[1] << "t" <<
res.m128i_i32[2] << "t" << res.m128i_i32[3] <
return 0;
}`
结果的第一个元素等于a0
+ a1
,第二个等于a2
+ a3
,第三个等于b0
+ b1
,第四个等于b2
+ b3
。如果想重新设计代码以结对的方式做加法,将这些对分成4组,就可以借用内建函数来把代码并行化。我们可以找到针对不同“宽度”执行各种操作的内建函数(包括加、减、绝对值、取反,甚至对16×16的8位整数做点积),或同时执行多次计算的内建函数。
使用这些内建函数进行向量化的一个缺点是,代码的可读性和可维护性会急剧下降。通常会将代码先从头到尾写下来,测试其正确性,然后再用剖析工具诊断哪块代码是性能瓶颈且适合向量化,最后代码被调整成现在这样可读性比较差的状态。
此外,Visual Studio 2012能够对代码进行自动向量化和自动并行化。如果有可能,编译器会对循环进行自动向量化。向量化会重新组织循环,如求和循环,这样CPU可以在同一时间内执行多个迭代。通过使用自动向量化,在支持SIMD指令的CPU上执行循环时,其速度可快到8倍以上。例如,支持SSE2指令集的最新处理器,可以让编译器指示CPU在同一时间对4个数做数学运算。甚至在单核机器上也可以实现加速比,而且压根不需要改变代码。
自动并行化重新组织循环,让它可以同时在多个线程上执行,利用多核CPU和多处理器,将整块工作分配到所有可用的处理器上。与自动向量化不同的是,我们要使用#pragma并行化指令,告诉编译器哪些循环需要并行化。这两个特性能同时工作,以便向量化循环可在多个处理器上并行。
OpenMP(MP代表多处理)是一种跨语言、跨平台的CPU并行应用编程接口(API),起源于1997年。OpenMP支持Fortran、C和C++,在Windows和非Windows平台上都可以调用。Visual C++通过一组编译器指令可以支持OpenMP。确定有多少个内核可用、创建线程、在线程之间分割任务这些事情都由OpenMP完成。下面是一个例子:
// size is a compile-time constant
double* x = new double[size];
double* y = new double[size + 1];
// get values into y
#pragma omp parallel for
for (int i = 1; i < size; ++i)
{
x[i] = (y[i - 1] + y[i + 1]) / 2;
}```
这段代码使用向量x和向量y,遍历y的每个元素创建x。通过增加pragma,并用/openmp标记重编译程序,就可以把任务分配给多个线程(每个内核一个线程)。例如,如果有4个内核,且向量由10 000个元素组成,那么第一个线程负责从1到2 500的i值,第二个负责2 501到5 000的i值,依此类推。当循环结束时,x向量恰好会被填完。开发者要负责编写可并行循环,当然这也是最难的部分。例如,下面的循环在当前形式下是不可并行化的:
for (int i = 1; i <= n; ++i)
a[i] = a[i - 1] + b[i];`
这段代码有循环传递相关性(loop-carried dependency)。例如,要确定a[2502]的值,线程必须要使用a[2501]的值,这意味着第二个线程必须等第一个线程结束后才能启动。开发人员可以在这段代码中加入pragma,避免警告,但这样做代码的运行结果可能会不正确。
OpenMP的主要限制之一来自于它的简单性。一个循环从1到size,size值在循环开始时已知,可以很容易地在多线程之间切分任务。OpenMP只能处理for循环体三个组成部分中变量(本例中是i)相同的情况,且只有当测试条件和步长的值在循环一开始时就已知的情况下才能行得通。
如代码:
for (int i = 1; (i * i) <= n; ++i)
不能用#pragma omp parallel for
并行化 ,因为它测试的是i的平方,而不是i。下面这段代码:
for (int i = 1; i <= n; i += Foo(abc))
也无法通过#pragma omp parallel for并行化,因为i每次的递增值无法事先得知。
同样,“读取一个文件中所有行”的循环或者用一个迭代器遍历一个集合的循环,也都不能这样并行化。我们可能需要把所有行都按顺序读到一个数据结构里,然后再使用支持OpenMP的循环来处理。
微软并发运行时库是由介于应用程序和操作系统之间的4个子系统组成的。
并行模式库(Parallel Patterns Library,PPL)。为代码提供通用的、类型安全的容器和算法。
异步代理库(Asynchronous Agents Library)。为多个操作之间的无锁异步通信,提供了基于角色的编程模型和进程内消息传递。
任务调度器(Task Scheduler)。采用工作密取(work stealing)策略在运行时协调任务。
资源管理器(Resource Manager)。任务调度器利用它在运行时针对发生的工作负载分配CPU核或内存等资源。
PPL感觉很像标准库,利用模板来简化并行循环等结构。C++在C++11中增加了lambda表达式特性(虽然在2010年发行的Microsoft Visual C ++版本中就已经提供了该功能),这大大提高了PPL的可用性。
例如,下面这个顺序循环:
for (int i = 1; i < size; ++i)
{
x[i] = (y[i - 1] + y[i + 1]) / 2;
}```
能够通过用parallel_for代替for变成并行循环:
// . . .
concurrency::parallel_for(1, size, =
{
x[i] = (y[i-1] + y[i+1])/2;
});`
parallel_for
的第三个参数是一个lambda表达式,它和旧的循环体一样。开发人员需要知道的只是这个循环是可以并行的,其他所有的工作都丢给库来承担。如果你不熟悉lambda表达式,请阅读3.5节中的“C ++11中的lambda表达式”进行了解。
parallel_fo
r循环有如下限制:采用索引变量从初值增长到终值减1(递进步长如果不是1处理成本会增加),不能随意设置终止条件。这些条件与OpenMP的约束条件非常相似。检验循环变量的平方值是否小于某个上限值,或是通过函数调用来获得步长,这样的循环都无法通过parallel_for进行并行化,这和OpenMP的要求是一样的。
其他一些算法,如parallel_for_each
和parallel_invoke
,支持通过其他方式遍历数据集。为了和迭代容器配合使用,如标准库中的迭代容器,要在parallel_for_each
中使用前向迭代器,或者为了达到更好的性能,也可以使用随机访问迭代器。迭代不会以一个确定的顺序发生,但容器中的每个元素都将被访问到。如果要随意并行执行大量操作,可以使用parallel_invoke
,如将三个lambda
表达式作为参数传入。
值得一提的是,Intel线程构建模块(Threading Building Block,TBB)3.0与PPL兼容,这意味着,使用PPL不会约束代码仅能使用微软的编译器。TBB提供了“语义层兼容接口和等价的并行STL容器方案”,如果需要,可以将代码移植到TBB上。
任务并行库是一种托管(.NET框架)的并行开发方法。它不仅为使用C#、F#或VB的开发人员提供了并行循环方案,还提供了工作和未来。CLR线程池负责调度和管理线程。托管开发人员还有其他选择,例如PLINQ。
Direct3D平台支持一种驱动程序模型,这种模型可以让任意硬件插入Microsoft Windows并执行图形相关代码。这也是Windows支持GPU的方式,从屏幕位图渲染等简单的图形任务,直到DirectCompute这种可以处理任意GPU计算的技术,都是Windows对GPU提供支持的方式。同时,该框架还支持使用CPU代码实现的显卡驱动程序。WARP就是这样一种纯软件实现的图形设备,它会随着操作系统一起发布。WARP在CPU上既能够执行简单的图形任务,也能执行复杂的计算任务。为了高效地执行Direct3D任务,它既利用了多线程,也利用了向量化。当没有物理GPU可用时,或者数据集较小时,就经常会使用WARP。事实证明,这两种环境下,WARP都是比较灵活的解决方案。
OpenGL,即开放图形库,可以追溯到1992年,它是一个跨语言、跨平台的API规范,可以为2D图形和3D图形提供支持。GPU要计算屏幕图像的颜色值和其他信息。OpenCL,即开放计算语言,是基于OpenGL的,提供GPGPU能力。OpenCL看起来像C语言。它有C语言没有的类型和函数,但也缺少某些C语言具有的特性。使用OpenCL并不会将开发人员绑定到特定的显卡或硬件上。然而,因为OpenGL没有二进制标准,所以在进行下一步之前需要先对OpenCL源代码进行编译,或针对特定目标机进行预编译。我们可以找到许多工具来编写、编译、测试和调试OpenCL应用程序。
Direct3D是一系列技术的统称,包括用于Windows图形编程的Direct2D API和Direct3D API,还包括DirectCompute。DirectCompute是一个支持GPGPU的API,类似于OpenCL。DirectCompute使用了一种非主流语言——HLSL(高级着色器语言),它看起来很像C语言,但又与C语言有很大的不同。HLSL广泛应用于游戏开发,与OpenCL语言有很多相同的功能。开发人员可以从在CPU上运行的顺序代码开始,编译和运行应用程序中的HLSL代码段。和Direct3D家族其他成员一样,这两种代码之间的交互也是通过COM接口实现的。不同于OpenCL,DirectCompute是编译成字节码,易于硬件移植,这意味着可以对更多的体系结构进行编程。然而,它是Windows专用的。
统一计算设备架构(Compute Device Unified Architecture,CUDA)是指用来编程的硬件和语言。CUDA由NVIDIA开发,仅当应用程序被部署到拥有NVIDIA显卡的计算机上时方可使用。应用程序是用“CUDA C”编写的,CUDA C不是C语言,但和C语言类似。CUDA C的概念和功能与OpenCL和DirectCompute是相似的。该语言比OpenCL和DirectCompute“更高一级”,语言中内置了更简单的GPU调用语法。此外,它还支持编写在CPU和GPU之间共享的代码。同时,还有一个名为Thrust的并行算法库,它从C++标准库的设计中汲取了灵感,目标是要显著提高CUDA开发人员的生产力。CUDA一直都在积极发展当中,不断新增了许多功能和库。
这三种可以发挥GPU功能的方式都有各自的限制和问题。由于OpenCL是跨平台、跨硬件(至少源码如此)、跨语言的,因此它非常复杂。DirectCompute基本只能在Windows系统上使用。而CUDA实际上只支持NVIDIA显卡。最重要的是,所有这三种方式不仅需要学习新的API和新的看待问题的方法,还需要学习全新的编程语言。这三种语言都是类C的,但又并非完全是C。只有CUDA类似C++,OpenCL和DirectCompute都不能提供诸如类型安全和泛型这样的C++抽象。这些限制意味着,主流开发人员为了使用更通用的技术而往往会忽视GPGPU。
在编写要求充分利用异构性的应用程序时,我们当然需要了解部署目标。如果应用程序的设计初衷是要运行在各类计算机上,这些计算机中有些可能并没有安装可以支持待处理负载的显卡,有些甚至可能根本没有GPU处理能力,我们的代码就应该能够应对各种执行环境,至少能够在部署了它的计算机上工作,尽管有可能不会获得任何加速。
在GPGPU的发展早期,浮点计算也是一种挑战。起初,双精度操作并非完全可用。同时还存在数学库的操作准确性和错误处理问题。即使在今天,单精度浮点运算的速度还是比双精度运算的要快,而且永远都会如此。我们可能需要投入一些精力确定我们的计算所需要的精度,以及GPU是否能真的比CPU计算得更快。一般情况下,除了支持早期硬件上的粗浅的数学运算以外,GPU正致力于提供双精度数学计算能力,向IEEE 754标准兼容的数学方向靠近。
了解将数据输入到GPU进行处理和从GPU获得输出结果的时间成本同样也很重要。如果这个时间成本超过GPU处理数据所节省的成本,那么除了会将应用程序搞得更复杂以外,没有任何好处。我们必须要有一个可以感知GPU的性能剖析工具,才能确保在处理大量数据时可以获得实际的性能提升。
对主流开发者来说,工具的选择很重要。以往GPGPU应用程序可能只有一小撮用户,他们本身也是开发者。随着GPGPU进入主流市场,用GPU获得超额处理能力的开发者们也在不断地同普通用户进行交流。这些用户会要求改进,想让他们的应用程序能够在新平台发布时就用上新特性,这可能需要改变正在执行的底层的业务规则或计算。编程模型、开发环境、调试器都必须支持开发人员做出调整适应这些变化。如果必须使用不同的工具开发应用程序的不同代码段,譬如所用的调试器只能处理应用程序的CPU代码(或GPU代码),或者没有一个GPU感知的性能剖析工具,就会发现为异构环境进行开发会异常困难。有些工具集,对于那些面向单个用户或只面向开发者自己提供支持的开发者来说有用,但对于面向非开发者用户团体提供支持的开发者来说,就不一定有用。而且,新接触并行程序设计的开发人员第一次不太可能写出理想的并行化代码;工具必须支持一种迭代方式,这样开发人员可以了解他们应用程序的性能,以及算法和数据结构的设计效果。