文章目录
- 前言
- 存取效率
- 计算效率
- 性能优化要点
- 展现足够的并行性
- 优化内存访问
- 优化指令执行
前言
CUDA算法的效率总的来说,由存取效率和计算效率两类决定,一个好的CUDA算法必定会让两类效率都达到最优化,而其中任一类效率成为瓶颈,都会让算法的性能大打折扣。
存取效率
存取效率即GPU和显存之间的数据交换效率,其中全局内存具有最大的容量和最慢的访问效率,且对是否对齐和连续访问很敏感;
共享内存访问速度快,且对是否对齐和连续访问不敏感,但是对Bank Conflict非常敏感,Bank Conflict的影响本文在后面会详细介绍,灵活使用共享内存会获得很高的存取效率,也是众多优秀CUDA算法替代全局内存的不二选择;
寄存器具有最快的访问速度,只对每个线程可见,线程内多使用寄存器是良好的习惯,但是需要注意一个SM内的寄存器数量有限,当单个线程的寄存器数量超过限制,会影响线程的实际占用率,从而影响加速效果;
其他存储介质如纹理内存常量内存等较共享内存和寄存器,在速度并没有太大优势,但是其具有的一些特殊特性使其有时候在特定的情况下被使用以获得更高的效率,比如纹理内存带有的纹理缓存具备硬件插值特性,可以实现最邻近插值和线性插值,且针对二维空间的局部性访问进行了优化,所以通过纹理缓存访问二维矩阵的邻域会获得加速,这个特性使得纹理内存在一些图像处理算法中具有一定的优势。
计算效率
计算效率就是指除去内存交换过程以外的算法计算部分的效率,GPU中主要有三类基础运算:整数运算、单精度浮点数运算和双精度浮点数运算,其中单精度浮点运算速度最快而双精度浮点运算速度最慢,FLOPS(floating-point operations per second, 每秒执行的浮点运算次数)也是衡量GPU运算性能的关键指标,如果一个程序内只有单精度浮点数运算,将发挥硬件的最大功效,因此应该尽量多使用单精度浮点数运算,而避免使用双精度浮点运算。
实际上,GPU的单核运算性能远不及CPU,因为单核运算速度取决于核心频率,而GPU的核心频率远不及CPU,目前主流的英特尔第七代桌面级CPU的核心频率都在3.5~4GHz左右,并支持超频,而NVIDIA在2016年发布的号称地球最快显卡NVIDIA TITAN X的核心频率也不过是1.4GHz,和CPU差距依然较大。
但是GPU的核心数是CPU所完全无法比拟的,其并行计算效率一般情况下远远大于CPU的单核甚至多核计算效率,核心数的优势让GPU的浮点运算效率远高于CPU,所以对GPU程序来说,让GPU利用率达到100%,让每个线程都处于活动状态,对提高程序的性能有着至关重要的作用。
此外,在提高CPU利用率的同时,还必须关注另一个因素:分支(if、else、for、while、do、switch等语句)对计算效率的影响,由于硬件每次只能为一个线程束获取一条指令,若线程束中一半的线程要执行条件为真的代码段,一半线程要执行条件为假的代码段,这时有一半的线程会被阻塞,而另一半线程会执行满足条件的那个分支,如此,硬件的利用率只达到了50%,大大影响并行性能。
性能优化要点
在基于CUDA优化算法设计过程中,除了使算法能够运行得到正确结果之外,更重要的是算法效率能达到理想的水平,而从上面的描述来看,要发挥CUDA算法的性能优势必须考虑全面,留意一些性能陷阱,采用合理的算法设计方案。
一般来说,优化一个CUDA算法的性能需要专注三个方面,按照重要性排序为:
展现足够的并行性
为了最大程度的利用GPU多线程的优势,应该在GPU上安排尽量多的并发任务,以使指令带宽和内存带宽都达到饱和,在一个SM(流处理器)中保证有足够多的并发线程束,这不单单是要为GPU每个线程都安排任务,还需要检查SM资源占用率的限制因素(共享内存、寄存器以及计算周期等)以找到达到最佳性能的平衡点,因为GPU的内存资源是有限的,为每个线程分配的资源也是有限的,如果算法设计者在一个线程中使用了过多的共享内存或者寄存器,那么并发运行的线程数必然会减少,使得SM资源的实际占用率小于理论占用率;另一方面可以为每个线程/线程束分配更多独立的工作。
优化内存访问
大部分GPU算法的性能瓶颈都在于内存访问速度,由于显存访问的高延迟和低效率,内存访问模式对内核性能有着显著的影响。内存访问优化的目标是最大限度地提高内存带宽的利用率,重点在于优化内存访问模式和保证充足的并发内存访问。在GPU中,线程是以线程束为单位执行的,一个线程束包含32个线程,所以一方面我们最好将并发线程数设置为32的倍数,另一方面当一个线程束发送内存请求(加载或存储)时,都是32个线程一起访问一个设备内存块,因此对于全局内存来说,最好的访问模式就是对齐和合并访问,对齐内存访问要求所需的设备内存的第一个地址是32字节的倍数,合并内存访问指的是通过线程束中的32个线程来访问一个连续的内存块。这表示在算法设计中一定要尽量为一个线程束的线程分配连续的内存块,比如0~31号线程(同一个线程束)访问影像中连续存储的31个像素,而不是访问不连续的31个像素,由于合并访问对内存访问效率影像非常大,所以我们在算法设计中建议严格遵守该要求。共享内存因为是片上内存,所以比本地和设备的全局内存具有更高的带宽和更低的延迟,使用共享内存有两个主要原因:①减少全局内存的访问次数;②通过重新安排数据布局避免未合并的全局内存的访问。在物理角度上,共享内存通过一种线性方式排列,通过32个存储体(bank)进行访问。Fermi和Kepler架构各有不同的默认存储体模式:4字节存储体模式和8字节存储体模式,共享内存地址到存储体的映射关系随着访问模式的不同而不同,当线程束中的多个线程在同一存储体中访问不同字节时,会发生存储体冲突(Bank Conflict),由于共享内存重复请求,所以多路存储体冲突可能要付出很大的代价,应该尽量避免存储体冲突,每个存储体(Bank)每个周期只能指向一次操作(一个32bit 的整数或者一个单精度的浮点型数据),一次读或者一次写,也就是说每个存储体(Bank)的带宽为每周期 32bit,比如一个32*32的二维单精度浮点数组,每一列属于一个Bank,如果一个线程束里的不同线程访问该数组里同一列的不同数据,则会发生Bank Conflict,解决或减少存储体冲突的一个非常简单有效的方法是填充数组,在合适的位置添加填充字,可以使其跨不同存储体进行访问,从而减少延迟并提高了吞吐量。寄存器是GPU上最快的存储机制,但是数量非常有限,如果一个线程使用过多的寄存器,会导致SM能够同时启动的线程数变少,实际上很多情况下寄存器都成为了资源占用率无法达到100%的主要限制条件,所以往往要注意监控寄存器的数量,当数量没有超标时,适当的增加数量可以提升性能,而一旦数量超标,最好还是将寄存器的数量减少以保证100%的资源占用率,这可以通过重新排列代码的顺序来实现,比如当变量的赋值和使用靠的很近时,编译器会重复使用少量寄存器以达到减少寄存器数量的目的。
优化指令执行
GPU属于单指令多数据流架构,每个线程束中的所有线程在每一步都执行相同的指令,如果每个指令都能够得到对结果有效的运算值,就能够避免线程的浪费,而如果由于条件分支造成线程束内有不同的控制流路径,则线程运行可能出现分化,这时线程束必须顺序执行每个分支路径,并禁用不在此执行路径上的线程,而如果算法的大部分时间都耗在分支代码中,必然显著的影响内核性能,所以尽量避免使用分支是很关键的,或者尽量使分支有非常大的概率执行对结果有效的哪一个路径。