在《遇见C++ AMP:在GPU上做并行计算》发布之后,我曾被多次问及为何选择C++ AMP,以及它与CUDA、OpenCL等相比有何优势,看来有必要在进入正题之前就这个问题发表一下看法了。
在众多可以影响决策的因素之中,平台种类的支持和GPU种类的支持是两个非常重要的因素,它们联合起来足以直接否决某些选择。如果我们把这两个因素看作两个维度,可以把平面分成四个象限,C++ AMP、CUDA和OpenCL分别位于第二象限、第四象限和第一象限,如图1所示。如果你想通吃所有平台和所有GPU,OpenCL是目前唯一的选择,当然,你也需要为此承担相当的复杂性。CUDA是一个有趣的选择,紧贴最新的硬件技术、数量可观的行业应用和类库支持使之成为一个无法忽视的选择,但是,它只能用于NVIDIA的GPU极大地限制了它在商业应用上的采用,我想你不会为了运行我的应用特意把显卡换成NVIDIA的。C++ AMP的情况刚好相反,它适用于各种支持DirectX 11的GPU,但只能在Windows上运行。
图 1
这些技术都有自己的特点和位置,你应该根据项目的具体情况选择合适的解决方案。如果你正在从事的工作需要进行大量计算,你想尽可能利用硬件特性对算法进行优化,而你的机器刚好有一块NVIDIA的显卡,并且你不需要在其他机器上重复执行这些计算,那么CUDA将是你的不二之选。尽管NVIDIA已经开源CUDA编译器,并且欢迎其他厂商通过CUDA编译器SDK添加新的语言/处理器,但AMD不太可能会为它提供在AMD的GPU上运行的扩展,毕竟它也有自己的基于OpenCL的AMD APP技术。如果你正在从事Windows应用程序的开发工作,熟悉C++和Visual Studio,并且希望借助GPU进一步提升应用程序的性能,那么C++ AMP将是你的不二之选。尽管微软已经开放C++ AMP规范,Intel的Dillon Sharlet也通过Shevlin Park项目验证了在Clang/LLVM上使用OpenCL实现C++ AMP是可行的,但这不是一个产品级别的商用编译器,Intel也没有宣布任何发布计划。如果你确实需要同时兼容Windows、Mac OS X和Linux等多个操作系统,并且需要同时支持NVIDIA和AMD的GPU,那么OpenCL将是你的不二之选。
GPU线程的执行
在《遇见C++ AMP:在GPU上做并行计算》里,我们通过extent对象告诉parallel_for_each函数创建多少个GPU线程,那么,这些GPU线程又是如何组织、分配和执行的呢?
首先,我们创建的GPU线程会被分组,分组的规格并不固定,但必须满足两个条件:对应的维度必须能被整除,分组的大小不能超过1024。假设我们的GPU线程是一维的,共8个,如图2所示,则可以选择每2个GPU线程为1组或者每4个GPU线程为1组,但不能选择每3个GPU线程为1组,因为剩下的2个GPU线程不足1组。
图 2
假设我们创建的GPU线程是二维的,3 x 4,共12个,如图3所示,则可以选择3 x 1或者3 x 2作为分组的规格,但不能选择2 x 2作为分组的规格,因为剩下的4个GPU线程虽然满足分组的大小,但不满足分组的形状。每个分组必须完全相同,包括大小和形状。
图 3
为了便于解释,我们的GPU线程只有寥寥数个,但真实案例的GPU线程往往是几十万甚至几百万个,这个时候,分组的规格会有大量选择,我们必须仔细判断它们是否满足条件。假设我们的GPU线程是640 x 480,那么16 x 48、32 x 16和32 x 32都可以选择,它们分别产生40 x 10、20 x 30和20 x 15个分组,但32 x 48不能选择,因为它的大小已经超过1024了。
接着,这些分组会被分配到GPU的流多处理器(streaming multiprocessor),每个流多处理器根据资源的使用情况可能分得一组或多组GPU线程。在执行的过程中,同一组的GPU线程可以同步,不同组的GPU线程无法同步。你可能会觉得这种有限同步的做法会极大地限制GPU的作为,但正因为组与组之间是相互独立的,GPU才能随意决定这些分组的执行顺序。这有什么好处呢?假设低端的GPU每次只能同时执行2个分组,那么执行8个分组需要4个执行周期,假设高端的GPU每次可以同时执行4个分组,执行8个分组只需2个执行周期,如图4所示,这意味着我们写出来的程序具备可伸缩性,能够自动适应GPU的计算资源。
图 4
说了这么多,是时候看看代码了。parallel_for_each函数有两种模式,一种是简单模式,我们通过extent对象告诉它创建多少GPU线程,C++ AMP负责对GPU线程进行分组,另一种是分组模式,我们通过tiled_extent对象告诉它创建多少GPU线程以及如何进行分组。创建tiled_extent对象非常简单,只需在现有的extent对象上调用tile方法,并告知分组的规格就行了,如代码1所示。值得提醒的是,分组的规格是通过模板参数告诉tile方法的,这意味着分组的规格必须在编译时确定下来,C++ AMP目前无法做到运行时动态分组。
代码 1
既然C++ AMP不支持运行时动态分组,肯定会为简单模式预先定义一些分组的规格,那么C++ AMP又是如何确保它们能被整除?假设我们创建的GPU线程是一维的,共10000个,C++ AMP会选择每256个GPU线程为1组,把前面9984个GPU线程分成39个分组,然后补充240个GPU线程和剩下的16个GPU线程凑够1组,执行的时候会通过边界测试确保只有前10000个GPU线程执行我们的代码。对于二维和三维的情况,C++ AMP也会采取这种补充GPU线程的策略,只是分组的规格不同,必要时还会重新排列GPU线程,以便分组能够顺利完成。需要说明的是,简单模式背后采取的策略属于实现细节,在这里提及是为了满足部分读者的好奇心,你的算法不该对它有所依赖。
共享内存的访问
既然简单模式可以自动分组,为何还要大费周章使用分组模式?为了回答这个问题,我们先要了解一下GPU的内存模型。在Kernel里,我们可以访问全局内存、共享内存和寄存器,如图5所示。当我们通过array_view对象把数据从主机内存复制到显卡内存时,这些数据会被保存在全局内存,直到应用程序退出,所有GPU线程都能访问全局内存,不过访问速度很慢,大概需要1000个GPU时钟周期,大量的GPU线程反复执行这种高延迟的操作将会导致GPU计算资源的闲置,从而降低整体的计算性能。
图 5
为了避免反复从全局内存访问相同的数据,我们可以把这些数据缓存到寄存器或者共享内存,因为它们集成在GPU芯片里,所以访问速度很快。当我们在Kernel里声明一个基本类型的变量时,它的数据会被保存在寄存器,直到GPU线程执行完毕,每个GPU线程只能访问自己的寄存器,寄存器的容量非常小,不过访问速度非常快,只需1个GPU时钟周期。当我们在Kernel里通过tile_static关键字声明一个变量时,它的数据会被保存在共享内存(也叫tile_static内存),直到分组里的所有GPU线程都执行完毕,同一组的GPU线程都能访问相同的共享内存,共享内存的容量很小,不过访问速度很快,大概需要10个GPU时钟周期。tile_static关键字只能在分组模式里使用,因此,如果我们想使用共享内存,就必须使用分组模式。
如果数据只在单个GPU线程里反复使用,可以考虑把数据缓存到寄存器。如果数据会在多个GPU线程里反复使用,可以考虑把数据缓存到共享内存。共享内存的缓存策略是对全局内存的数据进行分组,然后把这些分组从全局内存复制到共享内存。假设我们需要缓存4 x 4的数据,可以选择2 x 2作为分组的规格把数据分成4组,如图6所示。以右上角的分组为例,我们需要4个GPU线程分别把这4个数据从全局内存复制到共享内存。复制的过程涉及两种不同的索引,一种是相对于所有数据的全局索引,用于从全局内存访问数据,另一种是相对于单个分组的本地索引,用于从共享内存访问数据,比如说,全局索引(1, 2)对应本地索引(1, 0)。
图 6
在分组模式里,我们可以通过tiled_index对象访问索引信息,它的global属性返回全局索引,local属性返回本地索引,tile属性返回分组索引,它是分组作为一个整体相对于其他分组的索引,tile_origin属性返回分组原点的全局索引,它是分组里的(0, 0)位置上的元素的全局索引。还是以右上角的分组为例,(1, 2)位置的global属性的值是(1, 2),local属性的值是(1, 0),tile属性的值是(0, 1),tile_origin属性的值是(0, 2)。tiled_index对象将会通过Lambda的参数传给我们,我们将会在Kernel里通过它的属性访问全局内存和共享内存。
说了这么多,是时候看看代码了。正如extent对象搭配index对象用于简单模式,tiled_extent对象搭配tiled_index对象用于分组模式,使用的时候,两者的模板参数必须完全匹配,如代码2所示。parallel_for_each函数将会创建16个GPU线程,每4个GPU线程为1组,同一组的GPU线程共享一个2 x 2的数组变量,每个元素由一个GPU线程负责复制,每个GPU线程通过tiled_index对象的global属性获知从全局内存的哪个位置读取数据,通过local属性获知向共享内存的哪个位置写入数据。