GPU编程之进击的优化系列一设备微架

第一章设备微架构

1.0CUDA设备

1.0.0 核心微架构

1.0.1 指令编码格式

1.0.2 寄存器文件结构

1.0.3 指令流水线

1.0.4 Pascal架构(待续)

1.1 GCN设备

1.1.0 核心微架构

1.1.1 指令编码格式

1.1.2 寄存器文件结构

1.1.3 指令流水线

1.2 GPU设备上的条件分支

第一章 设备微架构

前言

          第一章我们介绍CUDA设备和GCN设备的微架构做。对设备微架构的了解可以在深度优化时提供理论依据和方向指导,对微架构方面细节的掌握有时甚至是帮助某些应用达到最优性能必须要的。当然,对底层架构细节的了解并不是必须的,若读者对这些内容没有兴趣,可以跳过本章。

1.0 CUDA设备微架构

kepler架构包含了数组SMX,每个SMX有以下功能单元构成:

1 指令预取单元和微指令缓冲区

2 4个warp调度器,每个warp调度器对应两个指令分派单元

3 192个CUDA Core和64或8个双精度计算单元

4 32个超越函数计算单元(SFU)

5 分支逻辑控制单元

6 32个LD/ST存储器访问单元

7 片上缓存结构,包括共享内存,L1缓存,纹理缓存,常量内存以及只读缓存,不同的设备大小可能不同

                                    kepler设备SMX微架构图

[attach]3509[/attach]

各种指令在不同的功能单元上执行,大致可分为四类:简单计算指令,复杂计算指令,分支指令和访存指令,下面是各个单元所支持的操作(仅以kepler和maxwell设备为例)

CUDA Core :32位单精度浮点加法,乘法,积和融加运算;32位整数加法;32位数据的比较操作,最小和最大操作;32位数据的位逻辑操作(and,or, xor);8位,16位数据和32位数据之间的转换操作。

双精度计算单元:双精度浮点加法,乘法,积和融加运算。

SFU:整数乘法,除法;单精度浮点数除法以及基本的数学函数如正弦,余弦和指数等操作;popc,clz,brev,bfe和bfi操作。

分支逻辑控制单元:分支,跳转等逻辑操作。

LD/ST单元:全局内存,共享内存,局部内存,常量内存,纹理加载等存储器访问操作(寄存器数据的访问无需通过LD/ST单元)。

warp vote和warpshuffle操作时在是专门的组合逻辑单元上完成的。

   maxwell设备和kepler类似,但是每个SMM里包含了四组独立的warp计算单元,每个warp单元包含了一个微指令缓冲区,两个指令分派单元,1个warp调度器,32个CUDACore,1个双精度计算单元,8个单精度SFU,4个纹理单元,64k个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设备微架构图

[attach]3510[/attach]

1.0.1 指令编码格式

kepler或maxwell设备的指令编码长度在32位系统和64位系统下均为64位无符号整型数据。指令的编码中包含了操作码,寄存器索引以及浮点计算的舍入模式等位域信息。而溢出标志位,进位标志位,零标志等则用一个特殊的条件寄存器存放。很可惜,NVIDIA并未公开指令集编码格式的详细信息。但这并不妨碍我们做出合理的猜测并通过大量实验对猜测进行验证(顺便吐槽下,AMD在这方面做的厚道多了)。

1.0.2 寄存器文件结构

正因为寄存器索引包含在指令编码中的,且距离计算单元最近,因此其延迟比其它任何类型的内存都要低,带宽也要远高于其它类型的内存,这也正是为什么充分使用寄存器是某些应用达到峰值性能所必须且唯一的原因。在计算能力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位。不同的架构寄存器bank的数和编码规则可能不同,如在keplerGPU上,寄存器bank编码为:

          bank0 :            R0,      R2,      R8,     R10,    …

          bank1 :            R1,      R3,      R9 ,     R11,   …

          bank2:            R4,      R6,      R12,     R14,    …

          bank3 :            R5,      R7,      R13,     R15,   …

在maxwell GPU上,寄存器bank编码为:

                    bank0 :            R0,      R4,     R8,      R12,   …

                    bank1:             R1,     R5,      R9,     R13,    …

                    bank2:             R2,      R6,      R10,     R14,    …

                    bank3:             R3,      R7,      R11,     R15,    …

了解设备的寄存器文件结构对于性能分析以及深度优化具有至关重要的作用,因为不合理的寄存器分配会造成bank冲突(bank-conflict),类似于共享内存的bank冲突;只有在没有bank冲突的情况下,诸如FMUL,FMA,FADD等操作才能全速执行,否则会花费更多的指令周期读取寄存器数据,增加的时钟延迟大小和寄存器的bank冲突数成正比。下面以maxwell-gpu为例:

          FMA   R0,    R7,    R3,    R1

  FMA    R14,    R9,     R1,     R10

分别具有1-way bank和2-way冲突。如果调整寄存的分配如下

          FMA   R0,    R6,    R3,    R1

          FMA    R14,    R9,      R3,    R12

则可以消除bank冲突。

使用矢量加载可以一定程度的控制寄存器按照某种对齐方式进行分配,但也通常会妨碍编译器进行寄存器bank优化,因为矢量加载指令要求第一个寄存器必须是加载宽度(按32-bit元素的个数算)的模0,如:

LD.E.128 R0, [R13]

LDS.U.128 R8, [R17]

{R0, R8}, {R1, R9},{R2, R10}, {R3, R11} 分别对应于bank0, bank1, bank2, bank3; 寄存器文件分配需要注意的另一个问题是bank对齐,简单来说就是在为指令分配寄存器时,相邻的指令中位于同一个bank中的寄存器最好是具有相同的位置,例如

                    FMA  R3,  R0, R1,  R2

                    FMA  R7, R4,  R5,  R6

这样可以将寄存器对齐到操作数端口上。编译器也往往在在指令间插入MOV操作来达到操作数端口对齐或是消除寄存器bank冲突的目的

                    FADD  R2,      R0,      R1

                    FADD  R6,      R1,     R5

                                =>

                    FADD  R2,      R0,      R1

      MOV    R4,      R1

                    FADD  R2,      R4,     R5

L1缓存,常量内存,共享内存以及纹理缓存的信息在很多资料中都有详细的叙述,这里不再多说,有兴趣的读者可查阅本章后面的参考资料。

1.0.3 指令流水线

首先指令预取单元从指令缓存(片上的缓存,物理存储介质和L1缓存相同)中取出多条指令进行译码,然后将译码后的指令存储在隶属warp调度器的微指令缓冲区中。指令发射单元从微指令缓冲区中取出两条指令分派给warp调度器选定的warp指令管线中,两条同类指令是顺序进入管线的,指令管线通过将一条指令的发射,取操作数时钟和另一条指令的取操作数和在功能单元上的执行时钟重叠来流水线化指令的操作,这样可以有效降低整个指令流水期间所有指令的总时钟数。流水线化在多个层次都同时存在,如不同功能单元之间的流水化,warp单元和warp单元之间的流水化以及指令和指令之间的流水化。不同功能单元之间的流水化,比如将计算单元上的操作和LD/ST单元上的操作重叠进行(不同功能单元在没有数据依赖的情况下可以真正同时执行)来隐藏数据访问的延迟。通过多个warp之间的切换让处于停顿延迟中的warp和正在执行的warp的时钟重叠隐藏warp中的计算或是访存延迟,亦即将warp流水化。单条指令在管线中的过程分为发射,执行和写回。发射阶段从寄存器文件中取操作数;在执行阶段根据指令中的操作码在相应的单元上执行特定的操作;写回阶段则将计算结果写入目标寄存器。因此,指令之间的流水化正是通过将前一条指令的执行和写回与下一条指令的发射和执行的时钟重叠来减少整个管线中所有指令的操作延迟。在指令译码阶段完成数据的存取后指令的计算结果并非立即可用,而是需要数个时钟的延迟。一般来说指令管线的深度至少不应小于指令的计算延迟,才足以保证指令流过管线后已经完成计算。理想情况下,每当一条指令从指令管线末端流出(完成计算)时,管线的前端就会流入一条新的指令,这样指令管线中的多条指令通过时钟重叠的方式有效的隐藏计算延迟(流水线中的N条指令的总延迟为N发射延迟+1计算延迟)。不同的指令可能具有不同的指令发射周期,亦即当发射一条指令后,需要几个时钟的停顿后才会发射下一条同类指令。我们假设一个指令管线的深度和指令的执行延迟均为4个时钟周期,每周期均可发射1条指令,那么指令流水线情况如下

 1 clock I0

 2 clockI1  I0

 3 clockI2  I1 I0

 4 clockI3  I2 I1  I0

 5clock     I3  I2 I1  I0

6 clock I3 I2 I1 I0

 7 clock                      I3 I2  I1  I0

 8clock                I3  I2 I1  I0

           流水线化,红色部分是从管线末端流出的以完成操作

如果没有采用流水线化,那么

  1~ 5 clock I0

  6~10clock               I1

 11~15clock                      I2

 16~20clock                                I3

可以看出,采用流水线化的操作完成四条指令的译码和执行只需要8个时钟周期,而不采用流水线化的操作则需要20(4*(1issue-clock+4execute-clock))个时钟周期。

1.1 GCN设备微架构

1.1.1 指令编码格式

1.1.2 寄存器文件结构

GCN设备上的每个Compute Unit具有256k个32bit矢量寄存器文件用于向量单元上的浮点计算,同时每个计算单元各有512个32bit标量寄存器用于逻辑分支,地址计算等标量操作。向量寄存器文件被划分成4个bank,各自分属ComputeUnit内的4组向量计算单元使用。不同于kepler和maxwell架构,GCN架构上4个寄存器bank各自分属不同的16-wide向量计算单元,因此不存在寄存器bank冲突的问题。每个bank有16(不同的设备可能不同)个通道,通道宽度是4字节。当使用8字节或16字节的数据时,使用连续的2个或4个寄存器,因此寄存器的分配满足对其要求。对于8字节数据,寄存器编号需要对齐到2的倍数;对于16字节数据,寄存器编号需要对齐到4的倍数;因此当使用向量类型时有时会增加寄存器的使用数量,这一点类似于内存的对齐分配会比普通的分配略大。

1.2 GPU设备上的条件分支

这里并不假定任何型号的设备,因此这一节所讲的同时适用于CUDA设备和GCN设备。每个warp或wavefront拥有一组条件掩码寄存器堆栈(堆栈中包含了多个掩码寄存器,每个掩码寄存器32位(CUDA)或64位(GCN),每位对应一个线程的掩码),GCN设备上的掩码寄存器堆栈深度为6。假设我们有一个深度为为3的嵌套分支,那么warp或wavefront中线程的执行过程为

  1 首先将计算得到的最外层的条件掩码写入掩码寄存器堆栈顶部的第1个寄存器(掩码堆栈指针当前指向的位置)。

  2 掩码寄存器堆栈指针减1,接着将计算得到的第2层分支的条件掩码写入掩码寄存器堆栈的下一个寄存器(掩码堆栈指针当前指向的)。

  3 掩码寄存器堆栈指针减1,接着将计算得到的第3层分支的条件掩码写入掩码寄存器堆栈的下一个寄存器。

  4 从掩码寄存器堆栈指针指向的位置取出掩码,如果warp或wavefront中的线程号对应的位值为1,则执行第3层分支内的计算,否则忽略。

  5 掩码寄存器堆栈指针加1,取出掩码寄存器中的掩码,如果warp或wavefront中的线程号对应的位值为1,则执行第2层分支内的计算,否则忽略。

6 掩码寄存器堆栈指针加1,取出掩码寄存器中的掩码,如果warp或wavefront中的线程号对应的位值为1,则执行第1层分支内的计算,否则忽略。

小结

本章我们分别介绍了CUDA设备和GCN设备的微架构,重点分析了寄存器文件结构和指令管线以及GPU设备上实现条件分支的大概原理。深入了解这些内容不仅可以让你知道指令在硬件中具体的行为以及为什么会这样,同时还会在优化过程中的某个角落为你说明性能无法进一步提升的障碍所在。

通常理解和使用所有这些内容需要一个漫长的过程,建议经常到设备商家官方网站上下载最新的技术资料,同时一些编译原理和计算机结构方面的书籍也会帮到你,因为很多技术原理无论在CPU还是GPU上都是通用的。

参考资料

  1《CUDA Programming Guide》

2《CUDA BinaryUtilities》

3《NVIDIA KeplerGK110 Whitepaper》

4《NVIDIA GeforceGTX980 Whitepaper》

5《AMDGCN Architecture Whitepaper》

6《AMD Southern Island Series Instruction SetArchitecture》

7《Graphics Core Next Architecture,Generation 3》

8《Compute Systems A programmer’s Perspective》,Randal E.Bryant, DavidR.O’Hallaron

9Performance Upper Bound Analysis and Optimization of SGEMM on Fermi and KeplerGPUs,Junjie Lai, Andr_e Seznec