这章将会说明一些kernel优化的小技巧。
8.1 kernel合并或者拆分
一个复杂的应用程序可能包含很多步骤。对于OpenCL的移植性和优化,可能会问需要开发有多少个kernel。这个问题很难回答,因为这涉及到很多的因素。下面是一些准则:
- 内存和计算之间的平衡。
- 足够多的wave来隐藏延迟。
- 没有寄存器溢出。
上面的要求可以通过执行以下操作实现:
- 如果这样做能够带来更好的数据并行,将一个大的kernel拆分成多个小的kernel。
- 如果内存的流量能够减少而且同样能保证并行性,可以将多个kernel合并成一个kernel,例如workgroup的尺寸能够足够地大。
8.2 编译选项
OpenCL支持一些编译选项,参考文献的《The OpenCLSpecification》的5.6.4节中进行了定义。编译选项可以通过APIsclCompileProgram和clBuildProgram传递。多个编译选项可以结合,如下所示。
clBuildProgram( myProgram,
numDevices,
pDevices,
“-cl-fast-relaxed-math ”,
NULL,
NULL );
通过这些选项,开发者能够针对他们自己的需求使能某些功能。比如,使用-cl-fast-relaxed-math,kernel会编译成使用快速数学函数而不是OpenCL标准函数,每一个OpenCL的说明中OpenCL标准函数都有很高的精度要求。
8.3 一致性 vs. 快速 vs. vs. 内部的数学函数
OpenCL标准在OpenCL C语言中定义了许多数学函数,默认情况下,因为OpenCL规范说明书的要求,所有的数学函数都必须满足IEEE 754 单精度的浮点精度数学要求。Adreno GPU有一个内嵌的硬件模块,EFU(elementary function unit 基本函数单元),来加速一些初级的数学函数。对于许多EFU不能直接支持的数学函数,可以通过结合EFU和ALU操作来优化,或者通过编译器使用复杂的算法来模拟进行优化。表8-1展示了OpenCL-GPU 数学函数的列表,并按照他们的相对性能来分类的。使用更好性能的函数是个较好的方法,比如使用A类中的函数
表8-1 OpenCL数学函数的性能(符合IEEE 754标准)
类别 |
实现 |
函数(可参考OpenCL标准获取更多细节) |
A |
仅简单使用ALU指令 |
ceil,copysign,fabs,fdim, floor,fmax, fmin, fract,frexp,ilogb, mad, maxmag,minmag,modf,nan,nextafter,rint,round,trunk |
B |
仅使用EFU,或者EFU机上简单的ALU指令 |
asin,asinpi,atan,atanh,atanpi,cosh,exp,exp2,rsqrt,sqrt,tanh |
C |
ALU,EFU,和位操作的结合 |
acos,acosh, acospi,asinh, atan, atan2pi,cbrt,cos,cospi,exp10,expml,fmod,hypot,ldexp,log,log10,loglp,log2,logb,pow,remainder,remquo,sin,sincos,sinh,sinpi |
D |
复杂的软件模拟 |
erf,erfc,fma,lgamma,lgamma_r,pown,powr,rootn,tan,tanpi,tgamma |
另外,如果应用程序对精度不敏感的话,开发者可以选择使用内部的或者快速的数学函数来替代标准的数学函数。表8-2 总结了使用数学函数时的3个选项。
- 使用快速函数时,在调用函数clBuildProgram时使能-cl-fast-relaxed-math。
- 使用内部的数学函数:
- 许多函数有内部实现,比如:native_cos, native_exp,native_exp2, native_log, native_log2, native_log10, native_powr,native_recip, native_rsqrt, native_sin, native_sqrt, native_tan ;
-
- 下面使用内部数学函数的例子:
原始的:int c = a/b ;// a和b都是整数。
使用内部指令:
int c =(int)native_divide((float)(a)),(float)(b));
表8-2 基于精度/性能的数学函数选择
数学函数 |
定义 |
怎么使用 |
精度要求 |
性能 |
典型应用 |
标准 |
符合IEEE754单精度浮点要求 |
默认 |
严格 |
低 |
科学计算,对精度敏感的情况下 |
快速 |
低精度的快速函数 |
kernel编译选项 -cl-fast-relaxed-math |
中等 |
中等 |
许多图像,音频和视觉的用例中 |
内部 |
直接使用硬件计算 |
使用native_function替换kernel中的函数 |
低,与供应商有关 |
高 |
对精度损失不敏感的情况下的图像,音频,和视觉用例中 |
8.4 循环展开
循环展开通常是一个好方法,因为它能够减少指令执行的耗时从而提高性能。Adreno编译器通常能基于试探法自动地将循环展开。然而,有时候编译器选择不将循环完全展开,因为基于考虑到,寄存器的分配预算,或者编译器因为缺少某些信息不能将它展开等因素。在这些情况下,开发者可以给编译器一个提示,或者手动的强制将循环展开,如下所示:
- kernel可以使用__attribute__((opencl_unroll_hint))或者__attribute__((opencl_unroll_hint(n))) 给出提示。
- 另外,kernel可以直接使用#pragma unroll展开循环。
- 最后一个选择是手动展开循环。
8.5 避免分支
一般地,当在同一个wave中的work item有不同的执行路径时,那么GPU就不是那么高效率。对于某些分支,一些work time必须执行,从而导致较低的GPU使用率,就像图8-1所示。而且,像if-else的条件判断代码通常会引起硬件的控制流逻辑,这个是非常耗时的。
图8-1 绘图表示出现在两个wave中的分支情况
有一些方法可以用来避免或者减少分支和条件判断。在算法层面,一种方法是将进入同一分支的work item组成一个不可分的wave。在kernel层面,一些简单的分叉/条件判断可以转变成快速的ALU操作。在9.2.6节中一个例子中,有耗时的控制流逻辑的一个三元操作被转变成一个ALU操作。其他的方式是使用类似于select函数,这个可能会使用快速的ALU操作来替代控制流逻辑。
8.6 处理图像边界
许多操作可能会获取图像边界外的像素点,比如滤波,变换等。为了更好地处理边界,可以考虑下面的选择:
- 如果可能的话,对图像进行扩边。
- 使用带有合适的采样器的image对象(texture引擎会自动处理这个)
- 编写单独的kernel函数去处理边界,或者让CPU处理边界。
8.7 32位 vs. 64位GPU内存访问
从Adreno A5X GPU开始,64位操作系统逐渐成为主流,而且许多的Adreno GPU支持64位操作系统。64位操作系统中最重要的改变是内存空间将能完全覆盖4GB,而且CPU支持64位指令集。
当GPU可以获取64位内存空间时,它的使用将会引起额外的复杂性,而且可能会影响性能。
8.8 避免使用size_t
64位的内存地址在许多情况下会提升编写OpenCL kernel的复杂度,开发者必须要小心。强烈建议避免在kernels中定义size_t类型的变量。对于64位操作系统,在kernel中定义成size_t的变量可能会被当成64位长度的数据。Adreno GPUs必须使用32位寄存器来模拟64位。因此,size_t类型的变量会需要更多的寄存器资源,从而因为可用的wave变少和更小的workgroup大小导致性能退化。所以,开发者应该使用32位或者更短的数据类型来替代size_t.
对于OpenCL中返回size_t的内嵌函数,编译器会根据它所知道的信息尝试推导并限制数据范围。比如, get_local_id返回的数据类型为size_t,尽管local_id永远不会超过32位。在这种情况,编译器尝试使用一个短的数据类型来替代。但是,更好的方法是,给编译器提供关于数据类型的最充分的信息,然后编译器可以产生更好的优化代码。
8.9 一般的内存空间
OpenCL2.0 介绍了一个新的特性,叫做一般性的内存地址空间,在这个地址空间中,指针不需要指定它的地址空间,在OpenCL2.0之前,指针必须指定它的地址空间,比如指定为是local,private,或者global。在一般性的地址空间中,指针可以动态地被指定为不同的地址空间。
这个特性降低了开发者的代码基础而且能重复使用已经存在的代码,使用一般性的内存地址空间会有轻微的性能损失,因为GPU SP硬件需要动态的指出真正的地址空间。如果开发者清楚知道变量的内存空间,建议清晰地定义内存地址。这将会减少编译器的歧义,从而会有更好的机器代码进而提升性能。
8.10 其他
还有很多其他的优化技巧,这些技巧看起来很小,但是同样可以提高性能,这些技巧如下所示:
- 已经计算过的数据,而且不会在kernel中被改变的。
- 如果一个数据可以在外面(host端)计算好,那么放到kernel中计算会很浪费。
- 已经计算好的数据可以通过kernel参数传递给kernel,或者用#define的方式。
- 使用快速的整型的内嵌函数。使用mul24计算24位的整型乘法,和使用mad24计算24位的整型乘加。
- Adreno GPU的内部硬件支持mul24,而32位的整型乘法需要用更多的指令模拟。
- 如果是在24位范围内的整型数据,使用mul24会比直接使用32位的乘法更快。
- 减少EFU函数
- 比如,像r=a/select(c,d,b
- 比如,像r=a/select(c,d,b
- 避免除法操作,特别是整型的除法。
- 整型的除法在Adreno GPUs上是极其耗时的。
- 不使用除法,可以使用native_recip计算倒数,像8.3节描述的那样。
- 避免整型的模操作,这个也很耗时。
- 对于常数的数组,比如说查找表,滤波tap等,在kernel的外面进行声明。
- 使用mem_fence 函数来分开或者组合代码段。
- 编译器会从全局优化的角度,使用复杂的算法产生最优的代码。
- mem_fonce 可以用来阻止编译器混排和混合前面或者后面的代码。
- mem_fonce 可以让开发者单独操作代码的某个部分来进行优化和调试。
- 使用位移操作替换乘法。