Bootstrap

3维线程格 gpu_GPU的线程模型和内存模型

遇见C++ AMP:在GPU上做并行计算

Written by Allen Lee

I see all the young believers, your target audience. I see all the old deceivers; we all just sing their song.

– Marilyn Manson, Target Audience (Narcissus Narcosis)

从CPU到GPU

在《遇见C++ PPL:C++的并行和异步》里,我们介绍了如何使用C++ PPL在CPU上做并行计算,这次,我们会把舞台换成GPU,介绍如何使用C++ AMP在上面做并行计算。

为什么选择在GPU上做并行计算呢?现在的多核CPU一般都是双核或四核的,如果把超线程技术考虑进来,可以把它们看作四个或八个逻辑核,但现在的GPU动则就上百个核,比如中端的NVIDIA GTX 560 SE就有288个核,顶级的NVIDIA GTX 690更有多达3072个核,这些超多核(many-core)GPU非常适合大规模并行计算。

接下来,我们将会在《遇见C++ PPL:C++的并行和异步》的基础上,对并行计算正弦值的代码进行一番改造,使之可以在GPU上运行。如果你没读过那篇文章,我建议你先去读一读它的第一节。此外,本文也假设你对C++ Lambda有所了解,否则,我建议你先去读一读《遇见C++ Lambda》。

并行计算正弦值

首先,包含/引用相关的头文件/命名空间,如代码1所示。amp.h是C++ AMP的头文件,包含了相关的函数和类,它们位于concurrency命名空间之内。amp_math.h包含了常用的数学函数,如sin函数,concurrency::fast_math命名空间里的函数只支持单精度浮点数,而concurrency::precise_math命名空间里的函数则对单精度浮点数和双精度浮点数均提供支持。

代码 1

把浮点数的类型从double改成float,如代码2所示,这样做是因为并非所有GPU都支持双精度浮点数的运算。另外,std和concurrency两个命名空间都有一个array类,为了消除歧义,我们需要在array前面加上"std::"前缀,以便告知编译器我们使用的是STL的array类。

代码 2

接着,创建一个array_view对象,把前面创建的array对象包装起来,如代码3所示。array_view对象只是一个包装器,本身不能包含任何数据,必须和真正的容器搭配使用,如C风格的数组、STL的array对象或vector对象。当我们创建array_view对象时,需要通过类型参数指定array_view对象里的元素的类型以及它的维度,并通过构造函数的参数指定对应维度的长度以及包含实际数据的容器。

代码 3

代码3创建了一个一维的array_view对象,这个维度的长度和前面的array对象的长度一样,这个包装看起来有点多余,为什么要这样做?这是因为在GPU上运行的代码无法直接访问系统内存里的数据,需要array_view对象出来充当一个桥梁的角色,使得在GPU上运行的代码可以通过它间接访问系统内存里的数据。事实上,在GPU上运行的代码访问的并非系统内存里的数据,而是复制到显存的副本,而负责把这些数据从系统内存复制到显存的正是array_view对象,这个过程是自动的,无需我们干预。

有了前面这些准备,我们就可以着手编写在GPU上运行的代码了,如代码4所示。parallel_for_each函数可以看作C++ AMP的入口点,我们通过extent对象告诉它创建多少个GPU线程,通过Lambda告诉它这些GPU线程运行什么代码,我们通常把这个代码称作Kernel。

代码 4

我们希望每个GPU线程可以完成和结果集里的某个元素对应的一组操作,比如说,我们需要计算10个浮点数的正弦值,那么,我们希望创建10个GPU线程,每个线程依次完成读取浮点数、计算正弦值和保存正弦值三个操作。但是,每个GPU线程运行的代码都是一样的,如何区分不同的GPU线程,并定位需要处理的数据呢?

这个时候就轮到index对象出场了,我们的array_view对象是一维的,因此index对象的类型是index<1>,这个维度的长度是10,因此将会产生从0到9的10个index对象,每个GPU线程对应其中一个index对象。这个index对象将会通过Lambda的参数传给我们,而我们将会在Kernel里通过这个index对象找到当前GPU线程需要处理的数据。

既然Lambda的参数只传递index对象,那Kernel又是如何与外界交换数据的呢?我们可以通过闭包捕获当前上下文的变量,这使我们可以灵活地操作多个数据源和结果集,因此没有必要提供返回值。从这个角度来看,C++ AMP的parallel_for_each函数在用法上类似于C++ PPL的parallel_for函数,如代码5所示,我们传给前者的extent对象代替了我们传给后者的起止索引值。

代码 5

那么,Kernel右边的restrict(amp)修饰符又是怎么一回事呢?Kernel最终是在GPU上运行的,不管以什么样的形式,restrict(amp)修饰符正是用来告诉编译器这点的。当编译器看到restrict(amp)修饰符时,它会检查Kernel是否使用了不支持的语言特性,如果有,编译过程中止,并列出错误,否则,Kernel会被编译成HLSL,并交给DirectCompute运行。Kernel可以调用其他函数,但这些函数必须添加restrict(amp)修饰符,比如代码4的sin函数。

计算完毕之后,我们可以通过一个for循环输出array_view对象的数据,如代码6所示。当我们在CPU上首次通过索引器访问array_view对象时,它会把数据从显存复制回系统内存,这个过程是自动的,无需我们干预。

代码 6

哇,不知不觉已经讲了这么多,其实,使用C++ AMP一般只涉及到以下三步:

创建array_view对象。

调用parallel_for_each函数。

通过array_view对象访问计算结果。

其他的事情,如显存的分配和释放、GPU线程的规划和管理,C++ AMP会帮我们处理的。

并行计算矩阵之和

上一节我们通过一个简单的示例了解C++ AMP的使用步骤,接下来我们将会通过另一个示例深入了解array_view、extent和index在二维

;