文章目录
- 前言
- 8.1 内核融合和拆分
- 8.2 编译选项
- 8.3 Conformant(规范) vs. fast vs. native math functions
- 8.4 Loop unrolling
- 8.5 避免分支发散
- 8.6 Handle image boundaries
- 8.7 Avoid the use of size_t
- 8.8 通用 vs. 具名内存地址空间
- 8.9 Subgroup
- 8.10 Use of union
- 8.11 Use of struct
- 8.12 综合
前言
这一章节提供了有关内核优化的更多细节,这些内容可能与第6章的顶级优化提示和第7章的内存优化有一些重叠。
8.1 内核融合和拆分
一个复杂的应用可能包含许多阶段。对于进行 OpenCL 移植和优化的情况,人们可能会问应该使用多少个内核。这个问题很难回答,因为涉及到许多因素。以下是一些建议:
- 在内存和计算之间取得良好的平衡。
- 有足够的波(waves)来隐藏延迟。
- 避免寄存器溢出。
开发人员可以尝试以下操作:
- 将一个大内核拆分成多个小内核可能会更好地实现数据并行化。
- 将多个内核融合成一个内核(内核融合),如果可以通过良好的并行化来减少数据流量(工作组大小相当大)。
8.2 编译选项
APIs clCompileProgram 和 clBuildProgram 提供了许多编译器构建选项,用于性能优化。借助这些选项,开发人员可以根据其目的启用一些功能。例如,使用 -cl-fast-relaxed-math 将使用快速数学运算编译内核,而不是按照 OpenCL 规范提供的更高精度的数学运算:
clBuildProgram( myProgram,
numDevices,
pDevices,
"-cl-fast-relaxed-math ",
NULL,
NULL );
Adreno GPU 还可以支持一些特定于 Adreno 的选项,以启用特定功能,详见第 9 章的讨论。
8.3 Conformant(规范) vs. fast vs. native math functions
《OpenCL》标准在OpenCL C语言中定义了许多数学函数。默认情况下,所有的数学函数都必须符合IEEE 754单精度浮点数数学要求,这是OpenCL规范要求的。Adreno GPU具有一个内置的硬件模块,即基本函数单元(EFU),用于加速一些原始数学函数。EFU直接不支持的许多数学函数已经通过结合EFU和ALU操作进行了优化,或者通过编译器使用复杂算法进行了模拟。以下表格显示了基于相对性能对OpenCL-GPU数学函数进行分类的列表。使用高性能函数(例如,类别A中的函数)是一个良好的做法。
或者,如果应用程序对精度不敏感,开发人员可以使用本地或快速数学而不是符合规范的数学函数。表8-2总结了使用数学函数的这三个选项:
本地数学函数(native math function),由底层硬件(如GPU)本地支持的数学函数
- 对于快速数学,在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、native_divide;
- 以下是使用本地数学的示例:
- 原始:int c = a / b; // a和b都是整数
- 使用本地指令:int c = (int)native_divide((float)(a), (float)(b));
8.4 Loop unrolling
循环展开通常是一个良好的实践,因为它可以减少指令执行成本并提高性能。Adreno编译器通常可以根据一些启发式方法自动展开循环。然而,也有可能编译器选择不完全展开循环,这取决于诸如寄存器分配预算之类的因素,或者编译器由于缺乏知识而无法展开。在这些情况下,开发人员可以通过以下方式向编译器提供提示或手动强制展开循环:
- 一个内核可以通过使用 attribute((opencl_unroll_hint)) 或 attribute((opencl_unroll_hint(n))) 来提供提示。
- 或者,一个内核可以使用指令 #pragma unroll 来展开循环。
- 最后的选择是手动展开循环。
8.5 避免分支发散
通常情况下,当同一组(wave)中的工作项(work items)按照不同的执行路径时,GPU的效率并不高。一些工作项可能需要被屏蔽(masked)以适应不同的分支,导致GPU的占用率降低,如下图所示。此外,条件检查的代码,比如if-else,通常会触发控制流硬件逻辑,这是比较昂贵的操作。
有一些方法可以避免或减少分歧和条件检查。在算法层面上,可以将进入同一分支的工作项分组为一个非分歧的波(wave)。开发者可以将一些简单的分歧或条件检查操作转换为快速的ALU操作。:
- 第10.3.6节中的一个示例展示了如何将由昂贵的控制流逻辑执行的三元操作转换为快速的ALU操作。
- 另一种方法是使用像select这样的函数,它可能使用快速的ALU操作而不是控制流逻辑。
8.6 Handle image boundaries
许多操作可能访问图像边界之外的像素。为了更好地处理边界,应考虑以下选项:
- 如果可能的话,提前对图像进行填充。
- 使用具有良好取样器的图像对象(纹理引擎会自动处理)。
- 编写单独的内核来处理边界,或者让CPU处理边界像素。
8.7 Avoid the use of size_t
在许多情况下,64位内存地址对于OpenCL内核编译可能会带来复杂性,开发者必须谨慎。开发者应避免在内核中将变量定义为size_t类型。对于64位操作系统,内核中定义为size_t的变量可能必须被处理为64位长整型。Adreno GPU必须使用两个32位寄存器来模拟64位。因此,具有size_t变量需要更多的寄存器资源,这通常会导致性能下降,因为激活的波浪较少,工作组大小较小。因此,开发者应该使用32位或更短的数据类型,而不是size_t。
对于在OpenCL中返回size_t的内置函数,编译器可能会尝试根据其知识推导并限制其范围。例如,get_local_id返回结果为size_t,尽管local_id永远不会超过32位。在这种情况下,编译器使用了一个较短的数据类型。然而,通常最好为编译器提供最佳的数据类型,以获得更好的寄存器使用和代码质量。
8.8 通用 vs. 具名内存地址空间
自OpenCL 2.0以来引入了一种称为通用内存地址空间的特性。在OpenCL 2.0之前,指针必须指定其内存地址空间,比如local、private或global。通用内存允许在内核中不设置指针的地址空间,GPU会在内核执行期间确定实际的地址空间。这一特性使开发人员能够重用和减少代码基础,尤其适用于库开发等任务。
使用通用内存地址空间可能会带来性能损失,因为与识别内存空间相关的硬件成本。以下是一些建议关于内存地址空间的使用:
- 如果事先知道,开发人员应明确指定内存地址空间。这将减少编译器的歧义,避免GPU硬件识别实际内存空间的成本。
- 尽量避免工作项使用不同的内存地址空间。对于统一(固定)的情况,编译器可能能够提取内存空间并避免硬件识别其内存空间。
- 准备不同内存地址空间的不同版本代码。
8.9 Subgroup
OpenCL 2.0引入的新子组函数提供了比工作组更精细的对工作项的控制。一个工作组包含一个或多个子组,而在Adreno GPU中,子组与波(wave)概念对应。与1D/2D/3D工作组相比,子组只有一个维度。与工作组类似,一组函数允许工作项在子组内查询其本地ID和其他参数。
子组的强大之处在于OpenCL引入了一套丰富的函数,允许子组中的工作项共享数据并在子组内进行各种操作
。没有这个特性,工作项之间的数据共享可能不得不依赖于本地或全局内存,而这通常是昂贵的。
如何实现子组功能取决于硬件供应商。它可以通过硬件加速或通过软件仿真来实现。在Adreno GPU中,许多子组功能都是通过硬件加速的。
除了核心OpenCL中的子组功能之外,OpenCL 3.0还有关于子组的KHR扩展。在使用这些扩展之前,检查扩展的可用性是非常重要的。
子组功能大致可以分为两种类型:规约和洗牌。
- 规约:Adreno具有硬件支持的规约功能,比通过本地内存进行规约要快得多。
- 洗牌:洗牌允许数据从一个工作项传递到另一个工作项。通常支持shuffle-up, shuffle-down, and generic shuffle.
除了对标准子组函数的支持,Adreno GPU 还通过供应商扩展支持子组规约和洗牌功能。
8.10 Use of union
虽然联合(union)是 OpenCL 内核语言中的一个标准特性,但在 Adreno GPU 上它是低效的
。编译器需要分配额外的寄存器来处理不同大小的成员,因此性能通常较不使用联合的常规内核更差。开发者在 Adreno GPU 上应避免使用联合
。
8.11 Use of struct
结构体(struct)可以使代码更易于理解和组织,是将一组相关变量组织到一个地方的绝佳方式。尽管有这些优点,但在 Adreno GPU 上使用结构体可能会引起一些效率问题,因此不总是建议使用。以下是一些建议:
- 尽量避免在结构体内部使用指针。
- 明确分配单个成员,而不是将整个结构体变量分配给另一个变量。
- SoA(struct of array,结构体,元素是数组) 或 AoS(array of struct,数组,元素是结构体):
- 一个关键考虑因素是选择是否能够缓解内核的瓶颈。
- 例如,如果数组可以安排得使得从内存加载数据具有更好的合并性,那么结构体数组是一个更好的选择。
- 如果结构体中的成员导致良好的缓存局部性,那么数组结构体可能是一个更好的选择。
8.12 综合
许多其他看似细微的优化技巧都可能提高内核性能。以下是开发人员可以尝试的一些事项:
- 预先计算在内核中不会改变的值。
- 使用内核计算可以预先计算的值是不划算的。
- 可以通过内核参数或 #define 将预先计算的值传递给内核。
- 使用快速整数内建函数。对于 24 位整数乘法,使用 mul24,对于 24 位整数乘法和累加,使用 mad24。
Adreno GPU 在硬件上原生支持 mul24
,而 32 位整数乘法则需要多于一条指令。- 如果整数在 24 位范围内,使用 mul24 比直接的 32 位乘法更快。
- 减少 EFU(elementary function unit) 函数的使用。
- 例如,以下是一段代码:
其中 a、b 和 T 是 float 变量,c 和 d 是常量,可以重写为:r = a / select(c, d, b<T)
这样避免了 reciprocal(倒数) EFU 函数,因为 1/c 和 1/d 可以在编译时由编译器推导出。r = a * select(1/c, 1/d, b<T)
- 例如,以下是一段代码:
- 避免除法运算,尤其是整数除法。
- 在 Adreno GPU 中,整数除法非常昂贵。
- 不要使用除法,而是使用 native_recip 进行倒数运算,如第 8.3 节所述。
- 避免使用整数模运算 (mod,取余),这对 Adreno GPU 来说是昂贵的。
对于常量数组,如查找表、滤波器系数等,将它们声明在内核范围之外
。- 在 OpenCL 内核中使用 mem_fence 函数来分隔或组织代码段。
- 编译器具有从全局优化角度生成最佳代码的复杂算法。
- mem_fence 可能会
阻止
编译器在前后混合代码
之前进行重排
。 - mem_fence 允许开发人员操作一些代码以进行性能分析和调试。
- 使用16位ALU计算而不是8位。由于
Adreno GPU不支持通用的8位ALU操作
,8位可能需要转换为16位或32位ALU操作。 - 如果可能的话,使用位移操作而不是乘法。