为了减小GPGPU寄存器文件的面积并维持较高的操作数访问带宽,GPGPU的寄存器文件往往会采用包含多个板块的单端口SRAM来模拟多端口的访问。
多板块组织的寄存器文件基本结构如图4-3所示,其中数据存储部分由四个单端口的逻辑块组成。逻辑板块采用一个对等的交叉开关与RR/EX(register read/execution)流水线寄存器相连,将读出的源操作数传递给SIMT执行单元,执行单元的计算结果写回板块。板块前端的仲裁器用于控制如何对各个板块的访问,交叉开关如何将结果路由到合适的RR/EX流水线寄存器中。
每个线程的寄存器在多个板块中的分布:若32个相邻线程构成一个Warp,没分支的情况下,32个线程行为一致。寄存器文件将32个线程的标量寄存器打包成线程束寄存器统一读取和写入,线程束数据位宽为32bit x 32bit=1024bit 。假设每个线程束最多配备8个线程束寄存器(r0,r1,r2,...,r7),分配方式如图4-4所示。
(线程束执行到“add %r5,%r5,%r1”这条指令,32个线程的读取和写入的具体过程:
根据当前指令线程束WID和具体的寄存器编号RID才能在寄存器文件的逻辑板块定位一个寄存器条目。仲裁器通过ID/RR流水线寄存器中的特定字段,获得该条指令所有源操作数的寄存器编号。)
4.2.2 板块冲突和操作数收集器
板块冲突:由于指令中寄存器的请求往往呈现随机性,不均匀的请求如果访问到同一板块,会导致单端口的逻辑板块很容易发生板块冲突。发生板块冲突时,只能串行访问,导致板块利用率下降,降低寄存器访问效率。
1.操作数收集器
与寄存器文件基本结构相比,关键变化在于流水线寄存器被操作数收集器取代,每条指令进入寄存器读取阶段都会被分配一个操作数收集单元,操作数收集器中包含多个收集单元,允许多条指令同时访问寄存器文件。
每个收集单元包含一条线程束指令所需的所有源操作数的缓冲空间如图4-5所示的寄存器文件中有4个收集单元,每个收集单元包括一个线程束WID。因为每个指令至多可以包含3个源操作数寄存器,所以每个单元设置有3个条目。每个操作数条目又包含以下四个字段:
(1)1个有效位
(2)1个寄存器RID
(3)1个就绪位
(4)操作数数据字段
操作数收集器的工作原理:当接收到一条解码指令并且有空闲的收集单元可用时,会将该收集单元分配给当前指令,并且设置好线程束WID及操作数条目内的寄存器RID和有效位。同时,源操作数寄存器读请求在仲裁器中排队等待。仲裁器中包含了每个板块的读请求队列,队列中的请求保留到访问数据返回为止。寄存器文件中有4个单端口逻辑板块,允许仲裁器同时将至多4个非冲突的访问发送到各个板块中。当寄存器文件完成对操作数的读取并将数据存入收集单元的对应条目时,将就绪位修改为1。当一个收集单元中所有操作数都准备就绪后,通知调度器将指令和数据发送给SIMT执行单元并释放所占用的收集器资源。
2.寄存器的板块交错分布
如图4-6所示,寄存器多板块交错分布方式,消除了在访问相同编号寄存器时存在的板块冲突问题,有助于减少不同线程束指令间产生的板块冲突,但不能解决单一线程束板块冲突问题。
结合操作数收集器和寄存器的板块交错分布规则,并在消除单个线程束内寄存器板块冲突的情况下,分析图4-5的例子。
首先将i2的r5寄存器重新分配,消除同一个线程束内寄存器板块冲突的可能性。线程束w0的指令i1于周期0发出,线程束w1~w3的指令i2于周期1~3发出。例如周期4,w3对r1的读取和w1对r5的写回是可以并行的减少了一个线程束寄存器写回时与其他线程束读取之间的板块冲突。
4.2.3 操作数并行访问时的相关性冒险
针对 操作数并行访问时产生的WAR冒险可以采用以下方案避免:
(1)每个线程束每次最多执行一条指令,只有在当前指令完成写回后才能发射下一条指令;
(2)每个线程束每次仅允许一条指令在操作数收集器中收集数据。
1.增加前置寄存器文件缓存的设计
前置一个小容量的的寄存器文件缓存(RFC)来捕捉短生命周期的数据,从而过滤掉大部分的原有的主寄存器文件(MRF)的访问。
RFC的工作原理:待写回的目的寄存器首先会被写入RFC中,等待后续寄存器的读取操作。
只要是缓存机制就会有访问缺失的情况,RFC的下一级存储就是MRF,因此未命中的源操作数还是会从MRF中读取,并完成RFC条目的替换。默认情况下,RFC中替换出的寄存器值都需要写回MRF上。采用编译时产生的静态寄存器活性信息来辅助RFC的写回操作。在RFC中,已经完成最后一次读取的寄存器将被标记为死寄存器,在发生替换操作时无需将其写回MRF中。
为进一步减小RFC的大小,提出了两级线程调度器,将线程划分为活跃线程和挂起线程。只有活跃线程才拥有RFC资源,并且通过两级调度器尽可能反复调度,以提高RFC的周转速率,减少RFC的更新开销。若一个活跃的线程束遇到长延迟操作,将从活跃状态挂起,该线程束RFC分配的条目也会被刷新。之后两级调度器会从挂起的线程束中选择一个已经准备就绪的线程束,使其变为活跃状态。
下图4-10为一个可编程多处理器修改前后微架构的对比示意图。减少对MRF的访问来减少能耗,减少对MRF带宽的要求,减少MRF板块冲突的可能性。
2. 基于嵌入式动态存储器的寄存器文件设计
嵌入式动态随机访问存储器(eDRAM):提供了更高的存储密度和更低的静态功耗,其速度基本可以满足要求。
eDRAM面临有限的数据保留时间问题,为了保持存储单元数据完整性,需要更为频繁地周期性刷新操作。
板块气泡:一条指令中的两个源操作数均位于同一板块中,将会造成一个周期的指令停顿。
在板块气泡周期中,其他板块可能是空闲的,为刷新提供了时机,另外调度器不能发射或指令中寄存器访问未满载时,也可能有气泡发生,利用板块气泡进行寄存器文件刷新操作如图4-12。
当检测到一个气泡时,刷新生成器随机选择该板块中的一个计数低于预设阈值的寄存器条目进行刷新。不同板块的寄存器条目可以同时刷新,同一板块著能选择满足条件的其中一个刷新,并重置该计数器。若长时间没有气泡且存在即将到期的计数器值,则会对整个板块进行刷新,以保证数据完整性。
eDRAM相比SRAM达到相同性能所使用的面积更小,增加计数器并不会增加总的寄存器文件面积,利用写操作对寄存器的天然刷新,减少盲目刷新次数。
3.利用数据压缩的寄存器文件设计
相邻线程寄存器数值之间存在很强的“相似性”,尤其当线程束不执行分支代码时。GPGPU这种相似性源于两个因素:(1)GPGPU程序许多局部变量都是通过线程ID生成的,而相邻线程ID仅相差为1;(2)运行时输入数据的动态范围可能很小。
通过Base-Delta-Immediate(BDI)压缩方案来实现线程束寄存器的压缩和解压缩设计。
Base-Delta-Immediate(BDI)压缩方案:每次选择线程束中一个线程寄存器值作为基值,计算其他线程寄存器相对于这个基值的差值,差值就能用很少的bit来表示。提出了三种策略:<4,0>,<4,1>,<4,2>,第一个参数表示基值所用字节数,第二个参数表示差值所用字节数。
基于BDI压缩的寄存器文件整体结构如图4-13所示,通过压缩寄存器值,每次对线程束寄存器的访问都会激活更少的板块,使动态功耗降低。
4.编译信息辅助的小型化寄存器文件设计
GPGPU程序中所使用的寄存器:(1)中间寄存器:中间结果所用的寄存器和与程序控制相关的寄存器,如循环时所需要的寄存器;(2)长时间寄存器:作为数值输入与存放结果的寄存器。
Regless维持一块小容量OSU单元(operand staging unit)代替大容量的寄存器文件。通过对GPGPU程序进行合理划分,使得跨程序执行区域的寄存器数量尽量少,区域内所需要的寄存器会被及时地送到OSU中。多数寄存器仅在一个区域内有效,当该区域执行完毕后,原先存储该寄存器地空间可以被直接用来存储其他寄存器。
Regless设计只维持一小部分逻辑寄存器的分配,存在寄存器换入换出的过程。
当线程束由某个代码区域1执行到区域2时,区域1所有跨区域的寄存器会被标记为evict寄存器,被换出至下一级存储才能被重复使用,区域2所需要的寄存器会由区域2通过preload标记将这部分寄存器提前置入物理寄存器中,区域内的一次性寄存器可以直接使用,某个寄存器在区域1执行完后不再被后续的所有区域引用,则被编译器注释erase信息,指示这个寄存器不再被保留。
在GPGPU中,缓存根据所处层次分为可编程多处理器内局部的数据缓存,如L1缓存和L2缓存,L1数据缓存的容量较小,但其访问速度很快,能够与共享存储器共享一块存储区域;L2缓存作为低一级的存储设备,为了捕获更多访存请求,其容量大很多。
如果线程之间需要进行数据交互或协作,全都用可见的全局存储器来完成,将极大降低指令吞吐率。因此需要为线程块中的所有线程提供一块公共的高速读写区域,以便线程间进行协作和数据交互。加入共享存储器后,线程无须将数据写入全局存储器,只需要以降低代价写入共享存储器,且提高了线程间数据通信的带宽。共享存储器像是一种可编程的L1缓存或便签式存储器,为编程人员提供了一种可以控制数据何时寄存在可编程多处理器内的方法。
图4-14中展示了L1数据缓存和共享存储器的统一结构和数据通路,其中序号5为二者共用的SRAM阵列,根据控制逻辑进行配置,可以实现一部分是数据缓存,另一部分是共享存储器的访问方式。
以共享存储器读取指令LDS Rdst,[addr]为例,分析共享存储器的工作过程。
1.地址合并规则
图4-15展示了数据在共享存储器中的存储方式,可以看出同一行内响铃板块间的地址是连续的,通常情况下,相邻线程一次读写的数据在空间上也是相近的,这种分布方式允许一个线程束的32个线程一次读写所需的全部数据,充分利用了32个板块提供的访问并行度。
LDS Rdst,[addr]指令中每个线程的地址都是独立计算的,那么32个线程产生32个地址,这些地址可能是以下两种可能:
(1)32个线程的地址分散在32个板块上,也可能指向同一板块的同一位置;(无板块冲突访问)
(2)32个线程的地址分别指向同一个板块的不同位置。 (有板块冲突访问)
2. 无板块冲突的共享存储器读写
无板块冲突的共享存储器读写过程:
(1)共享存储器指令从发射到Load/Store单元开始执行,该指令为一个线程束内32个线程的访存指令集合,包括操作类型,操作数类型和32个线程指定的一系列地址信息;
(2)Load/Store单元识别请求和地址信息,Load/Store单元判断独立线程地址之间是否引起板块冲突,若无,仲裁器会接受并处理这部分访存请求,并控制地址交叉开关的打开与关闭;若有,则触发额外的冲突处理机制;
(3)共享存储器的访问请求会绕过标记单元中的tag查找过程,Load/Store单元为寄存器文件调度一个写回操作;
(4)SRAM阵列会根据地址交叉开关传递过来的地址,打开被请求的板块来服务给定地址的请求,若为读请求,则对应板块返回请求的数据,经由数据交叉开关写回到每个线程的目标寄存器中;若为写请求,共享存储器的写指令将待写入数据暂存在写入缓冲区中。经过仲裁器的仲裁,控制相应数据通过数据交叉开关写入SRAM阵列中。
3. 有板块冲突的共享存储器读写
当仲裁器识别到访存行为存在板块冲突时,仲裁器会对请求进行拆分,拆分为两部分:(1)不含板块冲突的请求子集;(2)其余的请求。对于第一部分的请求可以按照无板块冲突的流程正常完成,第二部分的请求的处理方式有两种:(1)退回指令流水线要求稍后重新发射,指令可以保存在流水线内的指令缓存中;(2)在Load/Store单元中设置一块小的缓存空间来暂存这些指令,没有占满就可以接管这些发生冲突的请求进行重播。
4.共享存储器的数据通路
使用共享存储器需要首先声明诸如__shared__A[ ]数组,接着将全局存储器的数据显式复制到A数组中,最后CUDA代码才能访问A。由图4-18左图可以看出,该过程跨越4个存储层级,中间需要多次读写操作才能完成共享存储器的加载。NVIDIA的Ampere对这一路径优化后如图4-18右图,直接从全局存储器加载数据,只需要一条指令即可完成,跳过了L1缓存,大大节省了共享存储器的加载开销。
1. L1数据缓存的读操作
Load/Store单元接收到指令后,对地址进行计算,将整个线程束的全局访存请求拆分成符合合并规则的一个或多个请求,交给仲裁器;仲裁器可能会接受,也可能因为资源不足而拒绝,如当MSHR单元没有足够的空间或缓存资源被占用时,Load/Store单元会等待至其空闲;如果资源足够,仲裁器会在指令流水线中调度产生一个寄存器文件的写回事件,表示未来会占用寄存器文件的写端口。
同时仲裁器会要求标记单元检查缓存是否命中,若命中,则将所需数据从板块中取出,写回寄存器文件;若未命中,仲裁器会采用重播策略,告知Load/Store单元保留该指令。此时,仲裁器会将访存请求写入MSHR单元,由MSHR进一步处理与下一级存储层次的交互。如果缓存空间不足,未命中的读请求需要进行替换。如果被替换的数据为脏数据,还需要先行写回。
MSHR提供了非阻塞式处理缓存缺失的处理机制和结构,通过额外的硬件资源记录并追踪发生的缓存缺失信息,还可以实现地址合并,减少对下一级存储层次的重复请求等。
经过MSHR处理后的外部访存请求被发送至MMU,经过虚实地址转换产生实地址,通过网络传递给对应的存储分区单元来完成数据读取并且返回。
数据从下级存储层次返回时经过MMU处理,根据MSHR中预留的信息,如等待数据的指令,待写回的reg_id等,告知Load/Store单元重播刚才访问缺失的指令;同时返回的数据通过Fill Unit回填到SRAM,完成缓存的更新,这一行不能被替换,直到它被读取,从而保证刚刚重播的指令一定会被命中。
2. L1数据缓存的写操作
写操作比读操作更为复杂,区别如下:
(1)写指令首先将要写入全局存储器的数据放置在写入数据缓冲器中;
(2)经由数据交叉开关将需要写入的数据写入SRAM阵列;
(3)写指令会要求L1数据缓存能够处理非完全合并或部分屏蔽的写请求。只有部分线程产生有效的写操作和写地址时,为保证正确性,对于非完全合并或部分屏蔽的写请求,L1数据缓存只能写入缓存行的一部分。
对于写命中而言:
对于局部存储器的数据,可能是溢出的寄存器,也可能是线程的数组,,写操作一般不会引起一致性问题,因此可以采用写回(write-back)策略,充分利用程序中的局部性减少对全局存储器的访问。
对于全局存储器的数据,L1缓存可以采用写逐出(write-evict)策略,将更新的数据写入L2缓存同时使L1缓存中对应的缓存行置为无效。
对于写缺失,L1缓存可以采用写不分配策略。
3.L1数据缓存的数据通路
L1会尽可能缓存那些不会导致存储一致性问题的数据:
(1)L1缓存可以缓存所有只读数据,包括纹理数据,常量数据,只读的全局存储器数据;
(2)L1缓存可以缓存不影响一致性的数据,一般只包括每个线程独有的局部存储器数据。
在GPGPU中,纹理存储器并不是一个常用资源,存储了物理纹理的2D图像,具有良好的空间局部性。为了节省渲染时纹理读取的带宽,在可编程多处理器内部也设计了纹理缓存,称为L1纹理缓存。纹理缓存的结构大体如图4-20所示。
纹理缓存与L1数据缓存有很大区别,纹理缓存内部有专门的地址计算单元,需要对空间坐标点进行计算,转换,解压缩及数据格式转换。
1.高速缓存的优化设计
利用缓存旁路的方式来管理GPGPU的L1缓存,这一技术CPU中也存在,CPU的L1缓存命中率较高,如图4-21(a),只是对最后一级缓存(LCC)进行旁路,通常在数据分配阶段进行。而GPGPU的L1缓存竞争严重,命中率普遍较低,存储请求可以选择绕过L1缓存,返回的数据也将直接转发到寄存器或运算单元,如图4-21(b)。将一些存储器请求直接转发到L2缓存,缓存旁路也可能有助于减少资源拥塞。
通过在全局存储器加载指令中新增标记位来进行更为细致的指示,如表4-2所示,对于每一条load指令是否需要旁路的判定方法整体上包括两个步骤:(1)编译时构建load指令控制图,进一步对程序进行代码分析,根据结果静态地确定是否开启缓存旁路(全局加载标记位);(2)程序运行时进行动态判断,决定是否再对某些线程块进行缓存旁路。引入地标记位对应这俩步骤(块标记)。
2.共享存储器的优化设计
根据将访存阶段拆分为访存通路和无访存通路的两条独立通路如图4-22(b),不足以改善板块冲突带来的性能损失。基于该思想,对于非访存指令来说,如果能够直接将其写回而不经历NOMEM阶段,能够避免流水线因板块冲突而产生的停顿。如图4-22(c),Wi+1隐藏了Wi指令引起的流水线停顿。能否隐藏取决于两个条件:
(1)指令的乱序提交不会有正确性问题;
(2)板块冲突指令及后续指令分属不同类型指令(n条访存指令则需要n-1条非访存指令隔离)。
GPGPU存储分区单元组成框图如图4-23所示:
L2 cache是可编程多处理器共享的,一边通过互连网络连接所有可编程多处理器单元,另一边通过FB连接全局存储,也称为最后一级缓存(LCC)。
L2 cache对读请求处理方式与传统缓存处理方式类似,对写请求处理方式采用与L1 cache类似的优化策略。对于合并写入请求,即使写缺失,也不从全局存储器读取数据,采用写不分配策略;对于非合并写请求,或完全旁路,则绕过L2 cache直接写回外部存储器。
帧缓存单元起到了存储访问调度器的作用,目的是减少DRAM的行切换操作,降低读取流数据的延时。结合L2 cache的结构,简单的设计方式就是在L2 cache的每一片配备一个调度单元,来处理L2cache发出的读写请求。
对于读操作,在调度器里存放两个表,一个是读请求排序表,将所有请求DRAM中某个板块同一行的读请求合并映射至一个读取指针上。另一个是读请求存储表,存放第一个表中的指针和每个指针所对应的一系列的读操作请求。
在GPGPU中,图形存储器GDDR为全局存储器,它是一种特殊类型的DRAM,以SRAM设计为基础。GDDR块相比DDR接口更宽为32位,而DDR设计多为4、8或16;GDDR数据管脚的时钟频率更高。
对于全局存储器,一般通过请求合并和地址对齐等方面给出访问规则,提升访问效率。
1.寄存器文件、共享存储器和L1 cache的静态融合设计
寄存器文件、共享存储器和L1 cache合并是静态的,在每次内核函数加载之前由编程人员或编译器决定寄存器文件和共享存储器的空间分配方式。编译器计算最少的寄存器数量和共享存储器使用量,剩余的空间则统一分配给L1 cache,因为寄存器文件和共享存储器分配总会有剩余,无形中扩大L1 cache容量,提升性能。静态融合结构如图4-25。
这种设计由原来的32个32bit板块变成8个128bit板块,实际上降低了板块并行度,增加了板块冲突风险,需要利用好线程间访问并行,才能尽可能降低风险,
2.寄存器文件和L1 cache的动态融合设计
在运行时对寄存器文件和L1缓存进行动态融合,通过对寄存器周期更为细致的管理实现了对寄存器空洞的及时回收和利用,弥补了L1 cache缓存容量的不足。识别寄存器空洞为该设计难点。
通过图4-28可以在运行过程中区分各个条目究竟是寄存器还是缓存状态,并得知寄存器是否失活而需要释放其所占用的条目,以及脏数据是否需要写回,从而实现对寄存器和缓存的全生命周期的管理。
3.利用线程限流的寄存器文件增大L1 cache容量
利用限流的方法制造出更多空洞,提供更为充足的L1缓存资源。通过在运行时限制激活的线程数量来减少实际寄存器用量,将闲置的寄存器作为L1 cache的补充,让当前被激活的线程能够获得更充足的缓存空间,减少局部数据被反复替换的可能,换取性能提升,但会降低线程并行度,需要合理平衡。
限流的工作流程如图4-29,该设计引入一个局部性监控器,它会在内核函数执行初期运行,获取各线程块内部load指令的局部性信息,识别出具有更高局部性的线程块。当找到至少一个种子线程块时,会开启限流阻塞其他线程块,并采用直接检测的方法来平衡寄存器用量和L1 cache容量,当IPC下降时,会激活一个先前被冻结的线程块。
(a)最基本,采用寄存器文件和L1 cache的分立设计;
(b)增加了RFC单元,重塑了可编程多处理器内部的片上存储层次;
(c)由编译信息辅助的RFC;
(d)利用缓存旁路区分对待L1 cache中的数据,识别更为重要的局部性数据进行缓存;
(e)寄存器文件、共享存储器和L1 cache静态合并,面向L1 cache的优化;
(f)寄存器文件和L1缓存动态融合,细粒度的片上管理策略,更智能地实现动态融合,面向L1 cache的优化;
(g)限流方案,牺牲一定线程并行度获取数据局部性获得性能收益。