文章目录
Lightweight Dependency Checking for Parallelizing Loops with Non-Deterministic Dependency on GPU笔记
前言
使用CSDN好长时间了,很惭愧一直没发过什么东西。最近基于兴趣刚好看了一篇GPU加速的论文,半理解半翻译整理了一篇笔记,里面可能有理解不对的地方,希望各位帮忙指正。
原论文地址: https://i.cs.hku.hk/~clwang/papers/paper-295-camera-HongYuanLiu.pdf
摘要
最近十来年,虽然能用型CPU非常受欢迎,但GPU编码对于那些经验丰富的开发人员来说也依然是一项非常繁重的工作。虽然目前流行的并行编译器在编译时能够自动将循环计算下放到GPU上运行,减小了GPU编程的代价,但依然存在包含了不确认的数据依赖的循环体不能够被GPU并行执行。为了解决这类问题,我们提出了两种轻量级的依赖检测方案。这两种方案与目前并行编译器中采用的保守并行方案不一样。相比于之前的方案,本文提出的方案具有线性计算复杂度和线性的内存操作,同时对于内存要求也更少,假阳性的GPU执行报告也更低。本文实验使用了最新的AMD GPU来运行微基准测试和真实的应用。实验结果表明,本文提出的方案在没有数据依赖的测试集上相比于已有方案获得了2.2倍的加速比,对于存在数据依赖的测试集上的运行时间也只有现有方案的20%。
介绍
- 充分发掘设备的计算潜能
- 现有的编程模型应对循环体中出现的不确定边界和下标访问数组的情况比较保守且只支持并行展开有限的几种循环
- 事实上现有方案中认为存在不确定数据依赖的循环中有相当大一部分是能够被展开并正确并行运行的。
目标
- 设计一种GPU加速方案用于加速现有方案中认为存在不确定数据依赖而不能并行的循环体
现状
- Paragon提出了一种在GPU上检测数据依赖是否发生的方案,但它有如下缺点:
- 假阳性;如一个warp中 x < y x<y x<y (这里指Tid), x x x中写了 p p p, y y y中读了 p p p, 两次访问间隔了若干个操作,在Paragon中会被认为有数据依赖发生,但实际上并没有。(根据GPU本身的硬件特性(SIMT),在一个Warp内, x x x会先运行完, y y y后运行完)
- 占用内存很大;检测过程中会保存所有的状态,因而跑大的loop会受到内存上的限制
- 引入额外的复杂度。检测过程会引入 0 ( n 2 ) 0(n^2 ) 0(n2)计算复杂度。
- GPU-TLS通过推迟更新的方法减速少了假阳性的检测结果。它会在全局内存中保存所有的读写地址,写地址的值还会被保存在一个共享内存中。这种方式很有可能在Workgroup(工作组)执行时变成瓶颈。
- 虽然GPU-TLS占用内存多的问题可以通过布隆过滤器缓解。但使用布隆重过滤器会带来假阳性问题(与布隆过滤器的特性有关)。这一问题在采用多核和松内存模型的商用GPU上会被放大。
本文工作
- 提出了两种不使用读写集且计算复杂度为O(n)的检测算法。
- 两种方案的检测精度都比较高。第一种方案相比于之前的方案能检出更少的假阳性的数据依赖。第二种方案在检测过程中不会出现候阳性和假阴性的结果。
- 两种方案都支持检出数据依赖发生后提前结束GPU计算
Two-pass需要的内存很少,但生成代码时需要更多的静态的分析,wise则拥有更大的适用范围。
背景
OpenCL是一种通用GPU编程模型。该模型要求开发人员对数据级别并行的概率非常清楚。下面是GPU的一些概念
- GPU包含多个流处理器(SMs)
- 运行在GPU上的程序被称为Kernel
- Kernel将求解空间划分为多个工作组(Workgroup)。Kernel由宿主机通过命令提交,伴随一起的提交的还有一个N维的工作组参数(NDRange)
- GPU上有两种工作高度器,硬件级别的工作组调度器和任务调度器(Warp). Warp是GPU运行调度的单位,一个Warp包含多个Thread,同一个Warp中的Thread可以以任意顺序执行,当Warp执行完时,另一个Warp会被调进来执行。
考虑到并行的问题,GPU采用了一个松散的内存一致性模型,GPU的存储模型大致如下。
Two-pass 检测
Two-pass 是基于检测-执行模型的,即先检测后执行
- First-pass: 检测WAW(写后写)依赖
- Second-pass: 检测RAW(写后读)和WAR(读后写)
为了减少假阳性的检测结果,本文专门设计了一种时间戳结构
A. Warp内部的时间戳
- 松散的GPU内存组织不好记录时间戳
- Clock64寄存器虽然能标识节拍,但不能记录访存顺序
- 加锁虽然能解决记录时间戳不易的问题,但会引起效率下降的问题,也可能会引入死锁
正因为没有现成满足要求的时间戳,本文需要设计一种具有如下特点的新的时间戳:
- Warp内每次访存的时间须是递增的
- 时间戳是连续的
编译Kernel时,出于某些策略,编译器会在分支判断后插入一些汇聚指令。设计时需要保证在汇聚时,时间戳是所有线程中(同一Warp内)最大的。设计的32 bits整数存储在Warp中的片内存储中,访问延时非常低,不同的线程访问的是自己的时间戳,不会引入新的内存冲突(具体格式请参考论文)。
B. 依赖检测
虽然OpenCl的内存一致性模型能够保证装载和存储到一个Thread里的数据是一致的,但数据依赖可能发生在分配到GPU Thread上的迭代(loop)中的。
- 本文的算法能够检测在GPU上并行的Loop中因访问共享数组而出现的数据依赖
- 通过计算生成的下标表达式被称为索引表达式,它可能会导致发生数据依赖
数据结构
本文使用影子数组来标识共享数组每个元素的访问情况。影子数组的长度为共享数组的最大长度。6 bit用于记录Warp id. Warp id与硬件结构息息相关。目前64够用了。
观察者代码生成
代码中通常会包含很多变量,部分索引是由这些变量经过一定的计算后得出的。怎么找出跟索引有关的变量,去除其他变量的影响是一个挑战。本文设计了一种寻找算法(Loop中)。
- 将所有与索引计算直接有关的变量放到集合V中
- 对于V中的每个变量,将右侧不在V中的变量加到V中
- 在一次检测中,忽略所有变量不在V中的变量
- 获取了只含有与内存操作有关的的简化版的Kernel
WAW检查
这一步检测的结果会被用于第二步的输入。一旦检测过程中发现了数据依赖,race_flag会被置为true
- 初始化影子数组,使其代表的内存地址没有被标记访问到
- 当p位置被写入时,会通过原子操作将tid和访问时间写入影子数组,并返回上一次的写入时间和tid. 写入结果会有三种情况
- 上一次的值与初始值一样,表明是第一次访问
- 上一次的访问与此次的访问都在同一个warp中,这里需要检查local warp id。如果前一个写入的local warp id小于当前 local warp id, 这种依赖不会导致数据竞争。
- 如果前一个写入的warp id不等于当前操作的warp id,则表示发现了一个warp间的数据依赖。
RAW/WAR检查
首先假定进行此项检查时,没有WAW依赖。第一步的WAW依赖检测会将WAW检测出来。 RAW/WAR依赖检查时,影子数组中包含了WAW检查的结果,具体的检查过程如下:
- 如果当前的local id小于写入p的local id且当前时间比写入时间大,则发现了一个Warp内部的数据依赖
- 如果当前的local id大于写入p的的local id且当前时间小于写入时间,则检测到一个RAW依赖
如果local id和读取时间位于两次写入时间之间,那么也会发生数据依赖,但two-pass中RAW和WAR检测会出现假阳性报告。辛运的是这种情况很少。
WISE检测
虽然two-pass检测能满 足大部分的场景,但对于下标由复杂逻辑计算生成的情况况就显得比较局限了。WISE会在执行时检测重逻辑下的数据冲空,这点类似于TLS.
TLS(线程级猜测)
TLS假设程序中不存在数据冲突,将程序并行化编译以提高性能,将数据的冲突检测延后
TLS有两种方式
- 顺序提交(Serial Commit): 每个猜测的线程产生的数据采用专门的缓存(Buffer)管理,不直接写到内存;在猜测线程的提交阶段进行检测后才将最终数据从缓存中提交到内存中。
- 直接提交(In-Place Commit):每个猜测纯种产生的数据直接写到内存,写入前的内存数据会备份到专门的缓存中,在猜测线程的提交阶段,如果检测到数据冲突,则内存中的数据会被取消,并使用备份到缓存中的原始数据进行恢复。
CPU上有很多成熟的同步技术能够实现错误预测的恢复。GPU的内存模型与CPU不一样,错误的预测通常会造成更严重的问题。现在通用的估法是: 如果在运行过程中发现了错误的并行预测,那么GPU上算出来的数据会被丢弃,最后会通过CPU再算一次结果。
WISE因为在执行过程中引入了严格的时候戳来标明访问的顺序,所以是一种不会出现假阳性判断的方案。同时它也是一种与访存规模保持线性关系的方案。
WISE在two-pass的基础上新增了一路影子数据用于记录读操作
写检测算法
- 先检测Warp间是否有WAW依赖,同时Warp内部WAW也会被检测
a. 如果前一个写入warp id 不等于当前的warp id, 则检测到了一个warp间的WAW依赖
b. 如果warp id相等但前一个写入的local thread id大于当前的local thread id, 则检测到了warp内部的WAW依赖 - 检查Warp内部是否有RAW和WAR依赖
a. 如果前一个读的warp id不等于当前的warp id,则检测到warp间的RAW或WAR依赖
b. 如果warp id相等但前一个读取的local thread id大于当前的local thread id,则检测到warp内部的RAW依赖。 - 如果没有检查到依赖冲突,会更新读影子数组的读取线程为当前线程和历史纯线程的最大值
读检测算法
- 如果前一个写入p的warp id不等于当前warp id,则检测到了warp间的RAW和WAR依赖
- 如果warp id相等但当前local thread id 小于前一个写入p的local thread id,则检测到了warp内部的WAR依赖
race标记策略
nvidia的GPU提供类似的trap指令可以在数据依赖发生时提前终止在GPU上运行的任务,但有下面三个局限性
- 不能运行在其他品牌的GPU上
- 它会吸收GPU上运行出现的错误信息,不便于调试
- 需要与设备驱动一起才能正常工作
本文的方案
- 在Warp(Workgroup)内部设计一个内部各thread都能看到的race标记。
- 当发现数据依赖冲突时,置local的race和全局的race为true, 其他thread下次推测时看到标志位置已被设置则会立即退出
- 当后面未运行的WG观察到全局race标记被设置后会立即中止运行。
性能分析
本文从时间和空间复杂性上,比较了two-pass, paragon和GPU-TLS方案。
Paragon是一种CPU-GPU协同框架。当检测到数据依赖时,GPU上并行计算的结果会被丢掉,程序会在CPU上重新进行计算,检测算法包含三个过程。
- 先运行观察过程,运行过程中,读/写集会生成
- 比较读/写集的每条记录
- 判断出哪些记录发生了数据依赖
GPU-TLS是早前基于推测执行策略的一种新方案。主要有下面的特性:
- 片内共享内存被用来暂存日志和延迟的更新记录
- Intra-warp value forwarding(http://hub.hku.hk/handle/10722/180014)保证不会发生误报。
- 全局存储被用来记录读/写集
GPU-TLS执行过程:
- 先运行观察执行过程,填写读/写集
- 比较读/写集,这一点与Paragon很像
- 提交缓存的Address:Value到宿主机内存。因为这个过程是顺序的,且有自旋锁保证提交的顺序,所以当工作组比较多时会面临性能问题。
内存复杂性
w
a
w_a
wa:写
a
a
a的时间;
S
a
S_a
Sa:
a
a
a的大小;
t
t
t:线程数
本文的测试不依赖某次循环的某次写入时间,因此,能更好的扩展到有大量访问操作的情况。三个算法应对不同线程内存操作差异居的情况时,都会需要更多的内存来记录读写集数据(需要的大小为每个线程操作的数组的最大大小),当某些线程中的读写集大小无法静态分时出时会需要引入一个额外的分析过程来确定读/写集的大小。
时间复杂度
Paragon的时间主要耗在第二步O(xy),第三步是一个汇总过程,GPU-TLS时间上没什么优势,达到了 O ( t m a x i { W i } ) O(t_{max_i } \{W_i\}) O(tmaxi{Wi})的复杂度.
测试
CPU: AMD A10 7850K
GPU: AMD R9 290X 4GB GDDR5
GPU拥有2816个SM,使用了Microsoft C/C++ Compiler,关闭了优化,并从宿主机侧对数据进行统计
评估方法
- 设计了Listing 3所示的可调依赖类型的逻辑
- 评估时会用真实的科学计算程序展开的Loop进行测试
并行性VS循环大小
设置M=5, r, w数组被设置为无依赖,本文获得了如下结果
- Paragon加速比最低
- Wise比Wise-fp慢32%,原因是检测假阳性需要有更复杂的逻辑
- 2pass与2pass-fp加速比一样,这表明假阳性检测在2pass中占比极少(<5%)
- 当循环大小>262144时,2pass和wise加速比很相近,但循环大小<131072时,2pass更有优势
并行性VS内存访问
测试方法:考虑到loop_size=32768时,数据并行在GPU上运行能获得很好的加速比。因此,loop_size 被固定为32768。
本文获得了如下结果:
- Paragon需要O(n^2 )的时间复杂度来完成比较和计算,结果垫底
- Paragon的访存操作是wise的5倍,2pass的3.4倍。写操作的次数也是大大多于本文提出的方案
- 由于在检测过程中引入了访存优化,本文方案收获的加速比与基准非常接近
- wise因用了与循环大小M无关的内存操作且次数较大,加速比相对小一些。
检测敏感性
方法: 考虑到便于观察的问题,循环大小被固定为一个相对较大值1048576
结果:
- 因为没有提前结束的机制,所以Paragon执行时间都比较长
- 当依赖率由 1 0 − 6 10^{-6} 10−6上升到 1 0 − 4 10^{-4} 10−4时,wise的检测时间减少了56%
- 当依赖率>0.004%时,wise的检测时间只有Paragon的28%
- 当 d d > 2 × 1 0 − 5 dd>2×10^{−5} dd>2×10−5,2pass耗时只有Paragon的17.6%,只有wise的50%
程序跑分
结果: 大部分的程序中,2pass都拥有最高的加速比,wise其次,但都比Paragon要好。
- 流体动力学计算(CFD): 大意是算任决两点间的受力,数据依赖冲突不能通过静态分析确定。2pass与Paragon好29%,比wise好20%
- 分子动力学计算(MD):计算一对分郭间的受力。分子选取规则如下。2pass比paragon好55%,比wise好10%.
a. 分子从一个叫partners的数组中选取
b. 数据依赖会根据partners中值不同而发生在输入数组X和输出数据Y上
c. 只有WAW依赖会发生在计算过程中 - 重排向量(IPVec): 实验采用的A大小为65536,得出了2pass比wise快1%,比Paragon快16%的结论
a. 待重排的向量A
b. A中元素重排后位置的索引向量B
c. 按B进行交换后的数组C - FWD: 高斯消元法第一步,用于求解逆矩阵。这种特点的计算对于现有的并行编译器来说数据依赖是确定的。2pass和wise很接近,都比Paragon好26%以上。
- 稀疏矩阵向量乘法(SpMv): wise比Paragon好75%,2pass比Paragon好110%
a. 矩阵使用CSR(压缩稀疏矩阵)方式存储
b. 测试程序最外层循环可以被并行
c. 结果需要写回原来的矩阵
d. 测试矩阵大小为 3000 2 {3000}^2 30002,其中10%的非0数据随机分布
e. 不确定的读写都会发生 - 广度优先搜索(BFS): 在Rodinia graph 65536测试集上,本文方案比Paragon好30%,对于度不平衡的图,Paragon要用更多的存储来记录内存地址。
a. 每一个thread都会对应到BFS上的一次迭代。因为访问标志的设置(已访问结点)和边界的扩张会导致不确定的内存读写行为 - 背包问题: 两重循环会导致不确定性的数据依赖。基本原理是内层基于外层循环实现了一个动态规划算法。本文提出的方案都比Paragon更早的提交任务到CPU上进行计算。本文方案能够更早的结束GPU上的运行,因此,代价更小一些。
a. 模拟外层被错误的并行了
b. 有5000个物品,背包容量为600
相关工作
- 循环并行中的数据依赖检测
a. 部分研究采用离线的方式进行数据依赖检测;一旦被分类为可并行,运行时循环就是并行的,程序的相关状态和输入都会被忽略
b. 另一种则是在运行过程中对数据依赖进行检测;当程序的输入或其他过程的状态对并行循环过程的产生影响时,在线过程需要用尽可能小的代价(资源、时间)来发现这种影响并及时做出处理。本文的方案也属于这一种 - 数据竞争检测: 近年来在GPU上支持调试和验证的数据竞争检测慢慢受到了研究者们的关注。GPUVerify是一种工作在CUDA或OpenCL内核上的静态的Workgroup内的数据依赖的工具。GRace保留了静态分析的检测技术,同时又支持在运行时检测数据依赖。GRace本身的特性使得其只能检测GPU共郭存储上的内容。如果扩展到Global检测,其复杂度难以被接受。HAccRG使用了硬件检测技术,能够同时支持共享内存和全局存储,但是不适用于当前商用的GPU。数据竞争检测与迭代内部的依赖检测在思想上很类似,但是会在全量数据中的部分数据发生竞争依赖时给出错误的结果。
结论
本文提出了两种轻量级的检测并行Loop中不确定数据依赖的方案。在内部时间戳的支持下,2pass会串行的检查WAW, RAW/WAR依赖, 2pass方案基于基本的数据流分析精减后的计算相关的代码,这保证了过程中只会检查不确定数据依赖的内存访问
wise在执行过程中会通过观察机制检测依赖。这非常适合那些由大量索引计算访问数组导致的数据况争检测。另外wise也支持提前中止并行。
本文的两种方案都减少了假阳性,理论分析和实验结果都表明我们的方案效果很好。