GPU编程高级优化技术杂谈前言数年前,我初入编程领域,一开始根据兴趣GPGPU这个方向,那是CUDA和OpenCL还未出现,那是底层汇编找色器的时代,而我当时正是通过OpenGL使用GPU汇编指令,Cg以及GLSL编写着色器来进行GPU通用计算,直至现在一直从事基于GPU和CPU高性能异构计算的工作。数年前以网名cyrosly经常混迹于CSDNCUDA论坛和CUDA计算QQ群,讨论各种相关技术或是对一些网友的问题答疑解惑。现在想来那段时期,有过狂妄,有过激情,更结识了友情。是我的好友郑经维正是在学习CUDA技术的过程中结识的,当时虽只一面之缘,却成为了这个圈子中最要好的朋友,也正是因为他的劝说才有了我写此书的决定,或许一本对上感觉的书对于读者的意义远大于在论坛上回答成千上百个问题。本来经纬是想让我尽可能出版的,但是由于工作的原因,没有多余的精力继续写下去,因此打算把还远未未完成的残稿贡献出来。本书的目的跳过众多相关书籍频繁重复的内容通过几个有趣的实例直接介绍GPU编程中的高级优化技术,读者可从本书中一窥诸如cublas,cufft那些高性能库的大概面貌和其中所使用的主要优化技术。当然,即使是初学者,也可以通过本书达到技术上跳跃式的升级,而作者也信奉一个观念:一看就懂的书不是好书,因为这代表了读者最终可以从中获取的信息量太少抑或是自己潜意识里早已知晓但并未显现,并可能引发部分读者的思考:是否物有所值。本书内容绝不雷同,力求精简,节奏很快,希望读者可以通过分析本书中的代码找到开发高质量GPU程序的感觉。作者本人没有写书的经验,,甚至自认不太擅长摆弄文字,所以本书未必是一本好书,但绝对有其独特之处,如果读者可从中或多或少的学到些其它相关书籍中没有见过的内容,那么也不枉此书了。写作过程历时约一个半月,由于写作仓促,因此不免有疏漏之处,若有发现,可联系作者更正。作者的联系方式:QQ:295553381微信:13710058492目录第一章设备微架构1.0CUDA设备1.0.0核心微架构1.0.1寄存器文件结构1.0.2指令流水线1.1GCN设备1.1.0核心微架构1.1.1寄存器文件结构1.1.2指令流水线1.2GPU设备上的条件分支第二章GPU矩阵乘法的高效实现2.0前言2.1指令级并行和数据预取2.2双缓冲区2.3宽数据内存事务2.4二级数据预取2.5细节调优第三章基于GPU的稀疏矩阵直接求解器3.0简介3.1基于quotientgraph的符号分析3.1.1顶点重排序3.1.2构建消去树3.1.3寻找超结点3.1.4符号分解3.2多波前法3.3超节点方法3.4多波前+超节点方法的并行分解算法小结参考资料第四章高性能卷积神经网络的实现4.0简介4.1卷积层的高效计算4.1.1基于矩阵乘法的卷积4.1.2改进-无需额外存储空间的矩阵乘法卷积4.1.3高效的FFT实现4.1.4基于FFT的快速卷积4.2采样层的高效计算4.2.1下采样4.2.2上采样4.3梯度更新的高效实现4.3.1偏置的更新4.3.2激活值的更新第五章多设备编程建议第六章GPU编程优化技术总结6.1.0CUDA设备上的优化技术6.1.1访存优化6.1.2指令优化6.1.3内核调用优化6.2.0GCN设备上的优化技术6.2.1访存优化6.2.2指令优化6.2.3内核调用优化6.3构建性能可移植的程序小结参考资料第一章设备微架构前言第一章我们介绍CUDA设备和GCN设备的微架构做。对设备微架构的了解可以在深度优化时提供理论依据和方向指导,对微架构方面细节的掌握有时甚至是帮助某些应用达到最优性能必须要的。当然,对底层架构细节的了解并不是必须的,若读者对这些内容没有兴趣,可以跳过本章。1.0CUDA设备微架构kepler架构包含了数组SMX,每个SMX有以下功能单元构成:1指令预取单元和微指令缓冲区24个warp调度器,每个warp调度器对应两个指令分派单元3192个CUDACore和64或8个双精度计算单元432个超越函数计算单元(SFU)5分支逻辑控制单元632个LD/ST存储器访问单元7片上缓存结构,包括共享内存,L1缓存,纹理缓存,常量内存以及只读缓存,不同的设备大小可能不同kepler设备SMX微架构图各种指令的在不同的功能单元上执行,大致可分为四类:简单计算指令,复杂计算指令,分支指令和访存指令,下面是各个单元所支持的操作(仅以kepler和maxwell设备为例)CUDACore:32位单精度浮点加法,乘法,积和融加运算;32位整数加法;32位数据的比较操作,最小和最大操作;32位数据的位逻辑操作(and,or,xor);8位,16位数据和32位数据之间的转换操作。双精度计算单元:双精度浮点加法,乘法,积和融加运算。SFU:整数乘法,除法;单精度浮点数除法以及基本的数学函数如正弦,余弦和指数等操作;popc,clz,brev,bfe和bfi操作。分支逻辑控制单元:分支,跳转等逻辑操作。LD/ST单元:全局内存,共享内存,局部内存,常量内存,纹理加载等存储器访问操作。warpvote和warpshuffle操作时在是专门的组合逻辑单元上完成的。maxwell设备和kepler类似,但是每个SMM里包含了四组独立的warp计算单元,每个warp单元包含了一个微指令缓冲区,两个指令分派单元,1个warp调度器,32个CUDACore,1个双精度计算单元,8个单精度SFU,4个纹理单元,16k个32位寄存器组成的寄存器文件以及24k纹理缓存。所有四个warp单元共享一个指令缓存以及64k~96k的共享/L1缓存。虽然每个SMM中的CUDACore数量少于SMX,但是每个计算单元具有更高的性能功耗比。相对来说,maxwell具有更优秀的单精度计算效能,但为了平衡性能功耗比,所有计算能力的maxwell设备对双精度计算的支持都十分有限,而kepler更适合那些需要双精度计算的专业领域。每三个SMX或每四个SMM组成一个GPC,所有GPC共享512k~2M的二级缓存。缓存小结:对于计算能力2.x的设备,纹理管线完全独立于L1/L2缓存结构。对于计算能力3.x的设备,纹理和L1缓存是各自独立的,但都通过L2缓存加载全局内存数据。对于计算能力5.x的设备,共享内存,纹理缓存都属于L1缓存的一部分,所有对全局数据的访问都通过L2缓存路径,除非采用直写策略。maxwell设备微架构图1.0.1寄存器文件结构正因为寄存器索引包含在指令编码中的,且距离计算单元最近,因此其延迟比其它任何类型的内存都要低,带宽也要远高于其它类型的内存,这也正是为什么充分使用寄存器是某些应用达到峰值性能所必须且唯一的原因。在计算能力3.5+的kepler设备和maxwell设备上的每个SM中的64k个32-bit寄存器文件被划分为4个bank,每个bank中的16k个32-bit寄存器共享32个通道(lane),每个通道32位宽。由于每个线程最多可使用255个寄存器,那么如果使用单个bank的话,每个源或目标寄存器在指令编码中需要占用8位;如果使用4个bank,那么每个目标寄存器或是源寄存器只需占用6位,剩下的位数可以留作它用。比如对与四操作数指令(比如FMA),如果采用单个bank结构的寄存器文件,那么寄存器索引在指令编码中需要占用32位;而采用4-bank结构的寄存器文件的话,寄存器索引在指令编码中只需要占用24位,节省了8位。在32位操作数的情况下,寄存器文件和4个bank的映射关系为bank0:R0,R4,R8,R12,…bank1:R1,R5,R9,R13,…bank2:R2,R6,R10,R14,…bank3:R3,R7,R11,R15,…因此每个源或目标寄存器在指令编码中无需额外的2位表示寄存器的bank,否则将无任何节省。了解设备的寄存器文件结构对于性能分析以及深度优化具有至关重要的作用,因为不合理的寄存器分配会造成bank冲突(bank-conflict),类似于共享内存的bank冲突;在没有bank冲突的情况下,指令同时从多个bank存取寄存器数据。由于源操作数最多为3个,所以寄存器的bank冲突分为1-way,2-way两种情况;目标寄存器和元寄存器之间不存在bank冲突的问题,因为取源操作数和写结果操作是在不同的时钟次序下进行的,。每增加一路冲突,就会增加一个时钟周期的延迟。比如FMAR1,R7,R3,R1具有1路bank冲突(R7和R3)。如果调整寄存的分配如下FMAR1,R0,R3,R1则可以消除bank冲突。使用向量加载可以保证寄存器分配对齐到避免bank冲突的边界上,比如LD.E.128R0,[R13]R0,R1,R2,R3四个寄存器分别对应于bank0,bank1,bank2,bank3。注意到R13位于bank1中,但是这并不会和上面代码中的R1产生冲突,因为LD/ST单元会首先取出R13中的数据作为地址发送到数据总线上,然后(一定的数据传输延迟后)才将数据加载到bank0中的R0寄存器。使用向量类型时寄存器的分配需要对其到偶数编码的寄存器边界上,因此有时候使用向量类型会增加寄存器的使用数量,这有点类似于在分配对齐内存时(比如使用AVX256指令时最好将数据首地址对其到32字节边界上),实际分配的内存空间会比通常的内存分配大一点。寄存器文件分配需要注意的另一个问题是操作数端口对齐,简单来说就是在为指令分配寄存器时,相邻的指令中位于同一个bank中的寄存器最好是具有相同的位置,例如FMAR2,R0,R1,R2FMAR6,R1,R4,R6{R0,R4}/R1位于同一个bank中,但是在指令中的次序却不同,如果调整为FMAR2,R0,R1,R2FMAR6,R4,R1,R6则可以对齐到操作数端口上,编译器也往往在在指令间插入MOV操作来达到操作数端口对齐的目的FADDR2,R0,R1FADDR2,R1,R2=FADDR2,R0,R1MOVR4,R2FADDR2,R4,R1在64位操作数的情况下,寄存器和4个bank的映射关系为bank0:R[0:1],R[8:9],…bank1:R[2:3],R[10:11],…bank2:R[4:5],R[12:13],…bank3:R[6:7],R[14:15],…你可能会奇怪,为什么对于32位操作数和64位操作数寄存器和bank之间的映射关系不同。这是因为bank和寄存器之间并不是隶属关系,bank仅仅相当于数据通道,寄存器文件中的任何数据都可以从任何一个bank“进出”。可以将bank设想成地铁站的多个出入口,将每个warp中的bank对应的32个通道比作地铁的各个门,而将人比作数据。那么,人们首先从地铁站的各个出入口进站,然后再从各个地铁的门进入地铁;反向的情况则是人们从地铁的各个门离开地铁,然后从各个出入口离站。注意,“进出”的不是寄存器本身,而是寄存器中所包含的数据,寄存器文件本身是一组固定在芯片上的门电路阵列,是无法移动的;而我们在编程中所说的寄存器指的仅是物理寄存器的标示符;所以为了区分,常将两种情况分称为物理寄存器和逻辑寄存器,以下若无特别说明,指的均是逻辑寄存器。由于每个warp中的32个线程共享四个bank,每个bank有32个32位通道,因此对于64位操作数需要连续的两个通道或是根据高低32位分两次存取数据,所以每个warp调度器为对应的warp中所有的线程发射指令需要两个时钟的延迟(或者说每个warp调度器在1个时钟周期只能为16个双精度单元发射指令,这样在没有bank冲突的情况下,4个warp调度器在单个时钟周期内可以以全吞吐率为SM内的全部64个双精度单元进行指令译码),下面以DFMA操作说明warp中32个线程通过32路通道存取寄存器文件的操作DFMAR4,R0,R2,R4第一个时钟周期:lane012…31bank0R0,R0,R0,…,R0bank1R2,R2,R2,…,R2bank2R4,R4,R4,…,R4第二个时钟周期:lane012…31bank0R1,R1,R1,…,R1bank1