作者 陈巍 博士:存算一体/GPU架构和AI专家,高级职称。中关村云计算产业联盟,中国光学工程学会专家,国际计算机学会(ACM)会员,中国计算机学会(CCF)专业会员。
作者 耿云川 博士:资深SoC设计专家,软硬件协同设计专家,擅长人工智能加速芯片设计。
流式多处理器(Stream Multi-processor,SM)是构建整个 GPU的核心模块(执行整个 Kernel Grid),一个流式多处理器上一般同时运行多个线程块。每个流式多处理器可以视为具有较小结构的CPU,支持指令并行(多发射)。流式多处理器是线程块的运行载体,但一般不支持乱序执行。每个流式多处理器上的单个Warp以SIMD方式执行相同指令。
图 3-1 流式多处理器在GPU架构中的位置(以NVIDIA Tesla架构为例,修改自NVIDIA)
图 3-3是流式多处理器(SM,AMD称之为计算单元)微架构(根据公开文献和专利信息综合获得)。
流式多处理器按照流水线可以分为SIMT前端和SIMD后端。整个流水线处理划分为六个阶段,包括取指、译码、发射、操作数传送、执行与写回。
图 3-2 GPGPU的流式多处理器结构划分
SIMD即单指令多数据,采用一个控制器来控制多组计算单元(或处理器),同时对一组数据(向量)中的每一个数据分别执行相同的操作从而实现空间并行性计算的技术。
SIMT即单指令多线程,多个线程对不同的数据集执行相同指令。SIMT的的优势在于无须把数据整理为合适的矢量长度,并且SIMT允许每个线程有不同的逻辑分支。
按照软件级别,SIMT层面,流式多处理器由线程块组成,每个线程块由多个线程束组成;SIMD层面,每个线程束内部在同一时间执行相同指令,对应不同数据,由统一的线程束调度器(Warp scheduler)调度。
一般意义上的CUDA核,对应于流处理器(SP),以计算单元和分发端口为主组成。
线程块调度程序将线程块分派给 SIMT 前端,线程在流式多处理器上以Warp为单位并行执行。
图 3-3 GPGPU的流式多处理器微架构
流式多处理器中的主要模块包括:
取指单元(I-Fetch):负责将指令请求发送到指令缓存。并将程序计数器 (PC)指向下一条指令。
指令缓存(I-Cache):如来自取指单元的请求在指令缓存中被命中,则将指令传送给译码单元,否则把请求保存在未命中状态保持寄存器(MSHR)中。
译码单元(Decode):将指令解码并转发至I-Buffer。该单元还将源和目标寄存器信息转发到记分牌,并将指令类型、目标地址(用于分支)和其他控制流相关信息转发到 SIMT 堆栈。
SIMT 堆栈(SIMT Stack):SIMT堆栈负责管理控制流相关的指令和提供下一程序计数器相关的信息。
记分牌(Scoreboard):用于支持指令级并行。并行执行多条独立指令时,由记分牌跟踪挂起的寄存器写入状态避免重复写入。
指令缓冲(I-Buffer):保存所有Warp中解码后的指令信息。Warp 的循环调度策略决定了指令发射到执行和写回阶段的顺序。
后端执行单元:后端执行单元包括CUDA核心(相当于ALU)、特殊功能函数、LD/ST单元、张量核心(Tensor core)。特殊功能单元的数量通常比较少,计算相对复杂且执行速度较慢。(例如,正弦、余弦、倒数、平方根)。
共享存储:除了寄存器文件,流式多处理器也有共享存储,用于保存线程块不同线程经常使用的公共数据,以减少对全局内存的访问频率。
图 3-4 GPU执行流程(修改自 GPGPU-Sim)
取指-译码-执行,是处理器运行指令所遵循的一般周期性操作。
取指一般是指按照当前存储在程序计数器(Program Counter,PC)中的存储地址,取出下一条指令,并存储到指令寄存器中的过程。在取指操作结束时,PC 指向将在下一个周期读取的下一条指令。
译码一般是指将存储在指令寄存器中的指令解释为传输给执行单元的一系列控制信号。
图 3-5 取指译码结构
在GPGPU中,译码之后要对指令进行调度,以保证后继执行单元的充分利用。这一调度通过线程束调度器(Warp Scheduler)实现。
线程束是为了提高效率打包的线程集合(NVIDIA称之为Warps,AMD称为Wavefronts)。在每一个循环中的调度单位是Warp,同一个Warp内每个线程在同一时刻执行相同命令。
取指与译码操作过程如下:
取指模块(I-Fetch)根据PC指向的指令,从内存中获取到相应的指令块。需要注意的是,在GPGPU中,一般没有CPU中常见的乱序执行。
图 3-5 取指模块
指令缓存(I-Cache)读取固定数量的字节(对齐),并将指令位存储到寄存器中。
对I-Cache的请求会导致命中、未命中或保留失败(Reservation fail)。保留失败发生于未命中保持寄存器 (MSHR) 已满或指令缓存中没有可替换的区块。不管命中或者未命中,循环取指都会移向下一Warp。
在命中的情况下,获取的指令被发送到译码阶段。在未命中的情况下,指令缓存将生成请求。当接收到未命中响应时,新的指令块被加载到指令缓存中,然后Warp再次访问指令缓存。
指令缓冲(I-Buffer)用于从I-Cache中获取指令后对译码后的指令进行缓冲。最近获取的指令被译码器译码并存储在 I-Buffer 中的相应条目中,等待发射。
每个 Warp 都至少对应两个 I-Buffer。每个 I-Buffer 条目都有一个有效位(Valid)、就绪位(Ready)和一个存于此 Warp 的已解码的指令。有效位表示在 I-Buffer 中的该已解码的指令还未发射,而就绪位则表示该Warp的已解码的指令已准备好发射到执行流水线。
图 3-4 指令缓冲
当Warp内的I-Buffer 为空时,Warp以循环顺序访问指令缓存。(默认情况下,会获取两条连续的指令)这时对应指令在I-Buffer中的有效位被激活,直到该Warp的所有提取的指令都被发送到执行流水线。
当所有线程都已执行,且没有任何未完成的存储或对本地寄存器的挂起写入,则 Warp 完成执行且不再取指。当线程块中的所有Warp都执行完成且没有挂起的操作,标记线程块完成。所有线程块完成标记为内核已完成。
相对于CPU,GPU的前端一般没有乱序发射,每个核心的尺寸就可以更小,算力更密集。
发射是指令就绪后,从指令缓冲进入到执行单元的过程。
在(译码后的)指令发射阶段,指令循环仲裁选择一个Warp,将I-Buffer中的发射到流水线的后级,且每个周期可从同一Warp发射多条指令。
所发射的有效指令应符合以下条件:
在Warp里未被设置为屏障等待状态;
在I-Buffer中已被设置为有效指令(有效位被置为1);
已通过计分板(Scoreboard)检查;
在GPU中,不同的线程束的不同指令,经由SIMT堆栈和线程束调度,选择合适的就绪的指令发射。
在发射阶段,存储相关指令(Load、Store等)被发送至存储流水线进行相关存储操作。其他指令被发送至后级SP(流处理器)进行相关计算。
SIMT堆栈用于在Warp前处理SIMT架构的分支分化的执行。一般采用后支配堆栈重收敛机制来减少分支分化对计算效率的负面影响。
SIMT 堆栈的条目代表不同的分化级别,每个条目存储新分支的目标 PC、后继的直接主要再收敛 PC 和分布到该分支的线程的活动掩码。在每个新的分化分支,一个新条目被推到栈顶;而当 Warp 到达其再收敛点时,栈顶条目则被弹出。每个 Warp 的 SIMT 堆栈在该 Warp 的每个指令发出后更新。
线程束分化
从功能角度来看,虽然SIMT架构下每个线程独立执行,但在实际的计算过程中会遇到一些分支的处理,即有些线程执行一个分支,而另外的线程则执行其他分支。如果在同一个Warp内不同的线程执行不同的分支,就会造成线程束分化,导致后继SIMD计算的效率降低。因此应尽量避免线程束的分化。
图 3-6 线程束分化与重聚合
SIMT堆栈功能
SIMT堆栈模块可有效改善线程束分化引起的GPGPU执行单元利用率下降的问题。
SIMT堆栈重点解决:
控制流嵌套问题(Nested Control Flow)
在控制流嵌套中,一个分支严重地依赖另一个分支,这极大影响了线程的独立性。
如何跳过计算过程(Skip Computation)
由于线程束分支的存在,导致同一个Warp内的有些线程并不必要执行某些计算指令。
3) SIMT掩码
SIMT堆栈中使用了SIMT掩码(SIMT Mask)来处理线程束分化问题,以下例来说明掩码如何控制整个Warp的执行。
4) SIMT 掩码引起的死锁问题
SIMT 掩码可以解决Warp内分支执行问题,通过串行执行完毕分支之后,线程在Reconverge Point(重合点)又重新聚合在一起以便最大提高其并行能力。
但对于一个程序来说,如果出现分支就表明每个分支的指令和处理是不一致的,容易使一些共享数据失去一致性。如果在同一个Warp内如果存在分支,则线程之间可能不能够交互或者进行数据交换,在一些实际算法中可能使用锁定(Lock)机制来进行数据交换。但掩码恰恰可能因为调度失衡,造成锁定一直不能被解除,造成死锁问题。
5) GPGPU解决死锁的方法
图 3-8 V100 Warp调度对比图[2]
解决死锁的方法如下:
NVIDIA为V100 中Warp内的每个线程都分配了一个PC指针和堆栈,将PC指针的颗粒度细化到每一个线程中去,保障数据交换避免死锁。(图3-5)
为避免细粒度的PC指针和堆栈与GPU的SIMT执行模型产生冲突,硬件仍以Warp为单位来进行线程调度。
使用了Schedule Optimizer(调度优化器)硬件模块来决定哪些线程可以在一个Warp内进行调度,将相同的指令重新进行组织排布到一个Warp内,并执行SIMD模型,以保证利用效率最大化[2]。
进行线程束(Warp)调度的目的是充分利用内存等待时间,选择合适的线程束来发射,提升执行单元计算效率。
在理想的计算情况下,GPU内每个Warp内的线程访问内存延迟都相等,那么可以通过在Warp内不断切换线程来隐藏内存访问的延迟。
GPU将不同类型的指令分配给不同的单元执行,LD/ST硬件单元用于读取内存,而执行计算指令可能使用INT32或者FP32硬件单元,且不同硬件单元的执行周期数一般不同。这样,在同一个Warp内,执行的内存读取指令可以采用异步执行的方式,即在读取内存等待期间,下一刻切换线程其他指令做并行执行,使得GPU可以一边进行读取内存指令,一边执行计算指令动作,通过循环调用(Round Robin)隐藏内存延迟问题,提升计算效率。
在理想状态下,可以通过这种循环调用方式完全隐藏掉内存延迟。但在实际计算流程中,内存延迟还取决于内核访问的内存位置,以及每个线程对内存的访问数量。
内存延迟问题影响着Warp调度,需要通过合理的Warp调度来隐藏掉内存延迟问题。
1) 指令顺序调整的原因
在同一个Warp的单个线程中,调整发送到ALU将要执行的指令顺序,可以隐藏掉一部分内存延迟问题。例如读取指令和加法指令使用的是不同的硬件单元,在第一个时钟周期执行内存读取指令之后,下一个时钟周期不必等待读取内存指令,而是可以直接执行加法指令,从而实现一边计算一边读取,来提高整个运行效率。
但在实际情况中,后一个指令有可能是依赖于前一个指令的读取结果。要解决该问题就需要GPU提前对指令之间的依赖关系进行预测,解析出指令之间的独立性和依赖关系。
图 3-11动态线程束示例(来源:WILSON W. L. FUNG等)
2) 记分牌与指令顺序调整的方法
GPU在这里参考了CPU设计,为了解析指令之间的独立性,采用顺序记分牌(In-Order Scoreboard)。
对于单线程束情况,
每个寄存器在记分牌中都对应一个标志位,用于表示该寄存器是否已被写入数据,如果置1则表示该寄存器已经被写入。
此时如果另外一个指令想要读或者写该寄存器,则会处于一直等待状态,一直到该寄存器的标志位被清零(表明之前写寄存器操作完成)。这样就可以阻止Read-After-Write和Write-After-Write的问题。
当顺序记分牌和顺序指令(In-Order Instruction)结合时,能避免Write-After-Read的问题。
图 3-11数据冲突与流水线结构相关
对于多线程束情况,将上述方法应用到GPU时,还需要解决两个问题:
由于有大量寄存器GPU,在每组寄存器中增加一个标志位将需要占用更多额外的寄存器。
在GPU中,一般会有很多个线程同时执行同一指令,一旦其执行的指令被打断,会有很多线程同时访问Scoreboard造成读取阻塞。
对于多线程束情况,可通过动态记分牌解决上面的两个问题:
图 3-9 记分牌Entry流程
为每个Warp创建几个入口(Entry),每个入口与一个即将被写但操作尚未完成的寄存器相对应。记分牌在指令进入指令缓冲区(Instruction buffer,I-Buffer)和写操作完成结果存入Register File时能被访问(图3-6)
当一个指令从内存中读取出来放入到I-Buffer时,将该指令中的源寄存器和目的寄存器与Entry做比较,看是否有其他指令集已经对该寄存器在做写操作,如果有则返回一个bit Vector,与该寄存器一起写入到I-Buffer中。如果该指令集的写操作完成了,将会刷新I-Buffer中的该指令集寄存器的bit Vector,将bit Vector清除掉。