11.4 C++ AMP与OpenCL对比

为了将OpenCL映射到一个新的编程模型中,先将一些重要的步骤进行映射。表11.1中就展示了OpenCL相关步骤与C++ AMP的对应关系。

表11.1 OpenCL的相关步骤在C++ AMP的对应情况

OpenCL C++ AMP
内核 parallel_for_each中定义Lambda函数,或将一个函数直接指定给parallel_for_each
内核名 使用C++函数操作符直接调用给定函数或Lambda函数
内核启动 parallel_for_each
内核参数 使用Lambda函数获取变量
cl_mem内存 concurrency::array_view或array

图11.1中使用了Lambda函数作为并行执行的具体实现。这就类似于OpenCL中的内核函数,其会并行执行在各个工作项上。

通常会使用内核名称生成具体的OpenCL内核对象,这里可以直接使用c++的函数操作符直接运行Lambda函数或给定的函数。C++中为了避免不命名冲突,使用了作用域规则生成对应的内核对象。

其余的映射就是有关于主机端和设备端交互的。C++ AMP使用parallel_for_each来替代OpenCL中使用API进行传递内核函数和启动内核函数的过程。图11.1中使用的Lambda函数可以自动获取相关的参数,也就是自动的完成了向内核传递参数这一步骤。另外,Lambda中所是用到的array_view,都可以认为是显式的cl_mem内存。

从概念上了解了二者的映射关系后,对于C++ AMP编译器来说只需要提供如下支持就可以:

  1. 如何从周围的代码中获取OpenCL内核所需要的参数。
  2. 主机端需要在一条语句中完成创建内核、准备参数和内存,以及加载内核并运行的操作。

C++Lambda函数可以当做为匿名函数,为了更加贴近于C++我们会重写这个Lambda表达式(如图11.3所示)。

  1. class vecAdd{
  2. private:
  3. array_view<const float, 1>va, vb;
  4. array_view<float, 1> vc;
  5. public:
  6. vecAdd(array_view<const float, 1> a,
  7. array_view<const float, 1> b,
  8. array_view<float, 1> c) restric(cpu)
  9. :va(a), vb(b), vc(c){}
  10. void operator()(index<1> idx) restrict(amp){
  11. cv[idx] = av[idx] + bv[idx];
  12. }
  13. };

图11.3 仿函数版本的C++ AMP向量相加。

这段代码中,显式的将Lambda函数携程一个仿函数版本,其可以获取变量,va、vb和vc作为这个类的输出,并且将Lambda作为函数操作符的函数体。最后,构造函数通过主机端传入的参数生成对应的输出。

不过,我们含有一些东西漏掉了:

  1. 函数操作符在这里与OpenCL内核相对应。C++ AMP中使用parallel_for_each执行对应内核函数。不过,该仿函数是一个类,需要我们先创建一个实例。
  2. 运行时如何对内核的名称进行推断?
  3. array_view在主机端可能包含有cl_mem,不过在OpenCL设备端只能操作原始cl_mem指针,而不允许在主机端对cl_mem指针进行操作。这种关系需要理清楚,以便满足主机端和设备端不同的操作。

为了弥合这些漏洞,我们需要在图11.3中的类中添加更多的东西。图11.4中的第1、21、29和31行。

  1. // This is used to close the gap #3
  2. template<class T>
  3. class array_view{
  4. #ifdef HOST_CODE
  5. cl_mem _backing_storage;
  6. T *_host_ptr;
  7. #else
  8. T *_backing_storage;
  9. #endif
  10. size_t _sz;
  11. };
  12. class vecAdd{
  13. private:
  14. array_view<const float, 1> va, vb;
  15. array_view<float, 1> vc;
  16. public:
  17. vecAdd(
  18. array_view<const float, 1> a,
  19. array_view<const float, 1> b,
  20. array_view<float, 1> c)restrict(cpu)
  21. :va(a), vb(b), vc(c){}
  22. // This new constructor is for closing gap #1
  23. #ifndef HOST_CODE
  24. vecAdd(__global float *a, size_t as, __global float *b, size_t bs, __global float *c, size_t cs) restrict(amp)
  25. :va(a, as), vb(b, bs), vc(c, cs){}
  26. void operator()(index<1> idx) restrict(amp){
  27. cv[idx] = av[idx] + bv[idx];
  28. }
  29. #endif
  30. // The following parts are added to close the gap #1 and #2
  31. #ifndef HOST_CODE
  32. // This is to close the gap #2
  33. static const char *__get_kernel_name(void){
  34. return mangled name of "vecAdd::trampoline(const __global float *va, const __global float *vb, __global float *vc)"
  35. }
  36. #else // This is to close the gap #1
  37. _kernel void trampoline(const __global float *va, size_t vas, const __global float *vb, size_t vbs, __global float *vc, size_t vcs){
  38. vecAdd tmp(va, vas, vb ,vbs, vc, vcs);// Calls the new constructor at line 20
  39. index<1> i(get_gloabl_id(0));
  40. tmp(i);
  41. }
  42. #endif
  43. };

图11.4 扩展之后的向量相加——C++ AMP版本

图11.4中的版本中,将三个遗留的问题进行弥补。第一行简单的定义了一个concurrency::array_view,这个简单定义并不表示其就是标准concurrency::array_view的实现。这里使用的方式就是使用宏的方式,使得主机端和设备端所在同种容器中,使用不同的宏定义情况下,具有不同的类型数据成员。注意这里我们将array_view看作为一个OpenCL内存,这里我们就需要将OpenCL内存对象放入array_view中(命名为backing_storage)。同样,我们也需要添加两个成员函数,并且需要再定义一个新的构造函数,所以有些功能需要C++ AMP编译器在编译翻译过程中进行添加:

  • 需要在编译主机端代码阶段获取内核名称
  • 其次就是需要一个OpenCL内核,并且内核代码只能在设备端编译。并且运行时主机端就需要使用到这些内核的名字。编译器需要将这些函数对象拷贝一份,并连同内核参数传入设备端。
  • 第22行的新构造函数会编译成只有设备端能使用的代码。其目的是使用新的构造函数,在GPU上够造出一个Lambda函数的副本(携带参数),参数通常使用的地址并不是外部使用的那些。这就给了编译器相对自由的空间,并且可以在这之后进行一些优化。

不过,11.4图中的代码主要描绘了CLamp如何获取每个函数的名称(调用函数操作符),新的构造函数会将Lambda表达式构造成适合于GPU端使用的函数,并且使用宏的方式让同一个数组容器可以包含主机端或设备端的内存。最后,输出的OpenCL代码中,我们将数组容器中的cl_mem对象通过clSetKernelArg()API以一定的顺序设置入OpenCL内核当中。为了满足这些要求,需要实现的编译器具有如下的功能:

  • 从主机端的Lambda函数中获取cl_mem对象,并通过OpenCLclSetKernelArg() API对内存对象进行设置。
  • 将对应的内核参数地址进行查询,并且调用新构造函数实例化与Lambda不太相同的设备端内核,以及内核参数。
  • 参数的获取应该不会受到影响,并且参数的传递过程是不透明的。例如,array_view中_sz的值就需要通过主机端传递给设备端。

为了系统的实现这些功能,需要清晰的指明,Lambda函数中需要哪种类型的数据。表11.2中描述了图11.4中哪些数据需要在设备端和主机端使用。

表11.2 将主机端的数据成员与设备端进行映射

数据成员 主机端 设备端 注意
array_view va cl_mem va._backing_storage __global float *va_backing_storage 通过clSetKernelArg进行传递
va的尺寸 size_t va._sz size_t va._sz 字面方式传递
array_view vb cl_mem vb._backing_storage __global float *vb._backing_storage 通过clSetKernelArg进行传递
vb的尺寸 size_t vb._sz size_t vb._sz 字面方式传递
array_view vc cl_mem vc._backing_storage __global float *vc._backing_storage 通过clSetKernelArg进行传递
vc的尺寸 size_t vc._sz size_t vc._sz 字面方式传递

根据对应关系,可以通过C++ AMP的parallel_for_each生成OpenCL内核。这可以通过图11.4中的C++模板进行实现。基于表中的对应关系,可以实现对应的parallel_for_each,如图11.5所示。

  1. template<class T>
  2. void parallel_for_each(T k){
  3. // Locate the kernel source file or SPIR
  4. // Construct an OpenCL Kernel named k::__get_kernel_name()
  5. // We need to look into the objects
  6. clSetKernelArg(..., 0, k.va._backing_storage);// cf. line5 of Figure 3
  7. clSetKernelArg(..., 1, k.va._sz);
  8. clSetKernelArg(..., 2, k.vb._backing_storage);
  9. clSetKernelArg(..., 3, k.vb._sz);
  10. clSetKernelArg(..., 4, k.vc._backing_storage);
  11. clSetKernelArg(..., 5, k.vc._sz);
  12. // Invoke the kernel
  13. // We need to copy the results back if necessary from vc
  14. }

图11.5 主机端的parallel_for_each实现(概念代码)

为了产生适合图11.5中的OpenCL内核点,我们需要将所要执行的对象进行遍历,并筛选出相关的对象供内核函数调用(第6-11行)。另外,生成的内核代码的参数顺序也要和主机端实现对应起来。

之前,仿函数是一种向C++ AMP传递数据的方式,不过对于OpenCL内核来说,需要将相关的数据与内核参数顺序进行对应。基本上CPU端的地址,都会要拷贝并转化成GPU端能使用的地址:函数中有太多数据成员需要拷贝,这样的拷贝最好放在初始化的时候去做。

我们之前通过值的方式进行OpenCL内存的传递。不过,复杂的地方在于我们如何将内存通过不透明的方式传递给对应句柄,需要依赖于OpenCL运行时创建对应的GPU内存空间,并将主机端的内存拷贝到GPU上。

这里有的读者可能会发现,图11.5中的实例代码有点类似于对象的序列化。因为在实现的时候,我们不希望将相应的数据在外部进行存储和检索,所以我们需要压缩更多的数据,并通过clSetKernelArg()将相关数据设置到GPU内部。

需要注意的是,其他语言中(比如:java)序列化和反序列通过代码的顺序进行反映,这种反映并不是C++源码级别的,所以无需对编译器进行很大的改动。C++ AMP编译器中,序列化和反序列化代码的顺序,都会通过枚举数据变量的方式,进行内核参数的设置。