从 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 在二维场景里的用法。
假设我们现在要计算两个 100 x 100 的矩阵之和,首先定义矩阵的行和列,然后通过 create_matrix 函数创建两个 vector 对象,接着创建一个 vector 对象用于存放矩阵之和,如代码 7 所示。
代码 7
create_matrix 函数的实现很简单,它接受矩阵的总容量(行和列之积)作为参数,然后创建并返回一个包含 100 以内的随机数的 vector 对象,如代码 8 所示。
代码 8
值得提醒的是,当 create_matrix 函数执行“return matrix;”时,会把 vector 对象拷贝到一个临时对象,并把这个临时对象返回给调用方,而原来的 vector 对象则会因为超出作用域而自动销毁,但我们可以通过编译器的 Named Return Value Optimization 对此进行优化,因此不必担心按值返回会带来性能问题。
虽然我们通过行和列等二维概念定义矩阵,但它的实现是通过 vector 对象模拟的,因此在使用的时候我们需要做一下索引变换,矩阵的第 m 行第 n 列元素对应的 vector 对象的索引是 m * columns + n(m、n 均从 0 开始计算)。假设我们要用 vector 对象模拟一个 3 x 3 的矩阵,如图 1 所示,那么,要访问矩阵的第 2 行第 0 列元素,应该使用索引 6(2 * 3 + 0)访问 vector 对象。
图 1
接下来,我们需要创建三个 array_view 对象,分别包装前面创建的三个 vector 对象,创建的时候先指定行的大小,再指定列的大小,如代码 9 所示。
代码 9
因为我们创建的是二维的 array_view 对象,所以我们可以直接使用二维索引访问矩阵的元素,而不必像前面那样计算对应的索引。还是以 3 x 3 的矩阵为例,如图 2 所示,vector 对象会被分成三段,每段包含三个元素,第一段对应 array_view 对象的第一行,第二段对应第二行,如此类推。如果我们想访问矩阵的第 2 行第 0 列的元素,可以直接使用索引 (2, 0) 访问 array_view 对象,这个索引对应 vector 对象的索引 6。
图 2
考虑到第一、二个 array_view 对象的数据流动方向是从系统内存到显存,我们可以把它们的第一个类型参数改为 const int,如代码 10 所示,表示它们在 Kernel 里是只读的,不会对它包装的 vector 对象产生任何影响。至于第三个 array_view 对象,由于它只是用来输出计算结果,我们可以在调用 parallel_for_each 函数之前调用 array_view 对象的 discard_data 成员函数,表明我们对它包装的 vector 对象的数据不感兴趣,不必把它们从系统内存复制到显存。
代码 10
有了这些准备,我们就可以着手编写 Kernel 了,如代码 11 所示。我们把第三个 array_view 对象的 extent 传给 parallel_for_each 函数,由于这个矩阵是 100 x 100 的,parallel_for_each 函数会创建 10,000 个 GPU 线程,每个 GPU 线程计算这个矩阵的一个元素。由于我们访问的 array_view 对象是二维的,索引的类型也要改为相应的 index<2>。
代码 11
看到这里,你可能会问,GPU 真能创建这么多个线程吗?这取决于具体的 GPU,比如说,NVIDIA GTX 690 有 16 个多处理器( Kepler 架构,每个多处理器有 192 个 CUDA 核),每个多处理器的最大线程数是 2048,因此可以同时容纳最多 32,768 个线程;而 NVIDIA GTX 560 SE 拥有 9 个多处理器( Fermi 架构,每个多处理器有 32 个 CUDA 核),每个多处理器的最大线程数是 1536,因此可以同时容纳最多 13,824 个线程。
计算完毕之后,我们可以在 CPU 上通过索引器访问计算结果,代码 12 向控制台输出结果矩阵的第 14 行 12 列元素。
代码 12
async + continuation
掌握了 C++ AMP 的基本用法之后,我们很自然就想知道 parallel_for_each 函数会否阻塞当前 CPU 线程。parallel_for_each 函数本身是同步的,它负责发起 Kernel 的运行,但不会等到 Kernel 的运行结束才返回。以代码 13 为例,当 parallel_for_each 函数返回时,即使 Kernel 的运行还没结束,checkpoint 1 位置的代码也会照常运行,从这个角度来看,parallel_for_each 函数是异步的。但是,当我们通过 array_view 对象访问计算结果时,如果 Kernel 的运行还没结束,checkpoint 2 位置的代码会卡住,直到 Kernel 的运行结束,array_view 对象把数据从显存复制到系统内存为止。
代码 13
既然 Kernel 的运行是异步的,我们很自然就会希望 C++ AMP 能够提供类似 C++ PPL 的 continuation。幸运的是,array_view 对象提供一个 synchronize_async 成员函数,它返回一个 concurrency::completion_future 对象,我们可以通过这个对象的 then 成员函数实现 continuation,如代码 14 所示。事实上,这个 then 成员函数就是通过 C++ PPL 的 task 对象实现的。
代码 14
你可能会问的问题
- 开发 C++ AMP 程序需要什么条件?
你需要 Visual Studio 2012 以及一块支持 DirectX 11 的显卡,Visual C++ 2012 Express 应该也可以,如果你想做 GPU 调试,你还需要 Windows 8 操作系统。运行 C++ AMP 程序需要 Windows 7/Windows 8 以及一块支持 DirectX 11 的显卡,部署的时候需要把 C++ AMP 的运行时(vcamp110.dll)放在程序可以找到的目录里,或者在目标机器上安装 Visual C++ 2012 Redistributable Package 。 - C++ AMP 是否支持其他语言?
C++ AMP 只能在 C++ 里使用,其他语言可以通过相关机制间接调用你的 C++ AMP 代码:
- How to use C++ AMP from C#
- How to use C++ AMP from C# using WinRT
- How to use C++ AMP from C++ CLR app
- Using C++ AMP code in a C++ CLR project
- C++ AMP 是否支持其他平台?
目前 C++ AMP 只支持 Windows 平台,不过,微软发布了 C++ AMP 开放标准,支持任何人在任何平台上实现它。如果你希望在其他平台上利用 GPU 做并行计算,你可以考虑其他技术,比如 NVIDIA 的 CUDA (只支持 NVIDIA 的显卡),或者 OpenCL ,它们都支持多个平台。 - 能否推荐一些 C++ AMP 的学习资料?
目前还没有 C++ AMP 的书,Kate Gregory 和 Ade Miller 正在写一本关于 C++ AMP 的书,希望很快能够看到它。下面推荐一些在线学习资料:
评论