GPU是一种SIMT(单指令多线程)体系结构,即多个线程执行同一个指令,而每个线程操作的数据不同。这种结构令GPU天生具有大规模计算能力。GPU出色的浮点计算性能特别提高了深度学习两大关键活动:分类和卷积的性能,同时又达到所需的精准度。深度学习需要很高的内在并行度、大量的浮点计算能力以及矩阵预算,而GPU可以提供这些能力,并且在相同的精度下,相对传统CPU的方式,拥有更快的处理速度、更少的服务器投入和更低的功耗。NVIDIA介绍,TITAN X在工业标准模型AlexNet 上,花了不到三天的时间、使用 120万个 ImageNet 图像数据集去训练模型,而使用16核心的 CPU 得花上四十多天。更震撼的是使用 NVIDIA推出的DIGITS DevBox [1]来训练 AlexNet 则只要13个小时就能完成。
然而,这种庞大的并行能力需要付出代价:必须编写专门的软件才能利用这样的优势,GPU编程相对于CPU编程需要更多程序员的付出。目前GPGPU的程序模型仍不成熟,将数据划分为不同粒度,送到GPU的每个流处理器(SP)运算,这些工作仍需要程序员手工完成。此外,由于GPU不具有分支预测等复杂的流程控制单元,对于高度分支的程序执行效率差。GPU核心是虚拟化的,线程调度由硬件完成,无法动态调度。程序员需要避免写有高度分支的程序。GPU由于没有足够大的cache,读写主存导致latency。程序员需要利用大量线程隐藏latency。另外不同厂商的GPU硬件架构不同,用户可以获得的细节有限。这些都导致在GPU上设计通用的计算框架,仍然具有很大挑战。
近来,一些GPGPU 编程框架被提出,如NVIDIA的CUDA和AMD的Brook+,这些框架大大提升了GPU可编程性。Wenbin Fang等认为这些编程语言的接口依赖与特定厂商,并且他们的硬件抽象不适合于开发复杂应用。所以提出了一个易于在GPU上编程的MapReduce框架[2]。Mars框架可以用在分布式环境中,如hadoop。Mars可以应用在多核CPUs,NVIDIA GPUs,AMD GPUs或者联合一个多核CPU和一个GPU的单机上。Mars解决了三个技术挑战:首先MapReduce根据数据分割任务,利用GPU执行大量并行线程时,负载不平衡是一个固有问题,特别是GPU的线程由硬件管理。其次,GPU缺乏有效的全局同步机制,Map或Reduce任务中的线程在输出缓存上常常发生写入冲突。尽管GPU现在已经支持原子操作,原子操作的缺陷却会伤害大量GPU线程的可扩展性[3]。Mars提出一个lock-free调度方法来减少GPU线程同步带来瓶颈。第三,MapReduce应用通常是数据密集,且结果的规模也是依数据而变。这两个特性导致GPU编程有以下需求:1)足够多线程隐藏内存延迟,充分利用设备内存的高带宽。2)预先在设备内存上分配输出缓冲区,利用DMA减少内存存取时间。Mars中,有大量thread在GPU上并行运行,每个thread一次运算一个key/value pair,在Map阶段,框架平均分配key/value pairs到每个thread,Reduce阶段,Mars使用一种简单但高效的倾斜算法重新分配数据到Reduce任务,达到负载均衡。为了避免多线程写入冲突,Mars采用了一种lock-free策略保证并行程序的正确,仅付出很小的同步代价。
Mars的工作流程如下图。
以Mars的word count为例,Mars读取文件,将文件切分为ceil(2048)大小的一块,这里的ceil(2048)是指≥2048字符长度的连续字符,即块以非空字符开始,结尾是偏移≥2048字符长度的第一个空字符的前一位。每一块分配给一个GPU thread,256个thread组成一个block,多个block组成一个grid,一次GPU内核函数调用执行一个grid。从调度和运行方式看,GPU上block概念和CPU上的进程很相似,一个进程占用一个CPU核运行,多个进程轮转调度;一个block占用一个GPU SM运行,多个block轮转调度。从这个角度看,GPU的SM很像GPU核。
假设原始数据放在磁盘上,Mars利用CPU程序从磁盘读取数据,将输入转换为key/value pairs保存在主存中,之后传输到GPU设备内存。MapSplit阶段,将输入分配给GPU thread,分配的方式是一种分段式扫描的方式。
分段扫描,就是对数据集进行有规律的扫描操作(最大值,最小值,总和等),并附带一个额外的数组,将原来的数组分成不同大小的块。每块分配一个或多个线程进行计算。由于附加的数组可以在运行时进行更新,因此如果分段扫描能保持在一个单独的线程块内执行,就可以减少调用多内核的需要。否则,则需要采用一种更简单的解决办法。分段式扫描能够在多数情况下正常工作,并且线程和线程块的数量能随着并行度增加或缩减灵活改变。
MapCount用于计算Map输出的中间结果的大小,以便预先分配GPU内存,计算方式是通过求前缀和(Prefix Sum)获得输出大小和每个线程写入数据的位置。前缀和也叫累积和,一组数序列 x0,x1,x2,... 的前缀和还是一组数序列 y0,y1,y2,... ,计算方法如下:
例如,自然数的前缀和是三角形数:
input numbers | 1 | 2 | 3 | 4 | 5 | 6 | … |
---|---|---|---|---|---|---|---|
prefix sums | 1 | 3 | 6 | 10 | 15 | 21 | … |
通过前缀和,标记每个线程的输出的位置,提前分配GPU内存。最后GPU线程执行用户的Map函数,接着Map以lock-free方式获得每个线程输出结果的大小和写入位置,输出结果。
在Group阶段,按照key排序分组和hash分组都是可行的,Mars采用了排序分组,因为有些应用需要把输出排序,并且hash分组也必须为每个hash bucket进行排序。
ReduceCount和MapCount相似,不再赘述。
Reduce阶段,把key相同的中间结果分配给一个GPU thread,由于不同key的记录数量不同,这可能造成线程负载不均衡。Mars采用了一种倾斜处理策略减缓负载不均衡问题,可以跨reduce workers分配负载,即使用户定义的Reduce操作之间是关联的。这个策略就是迭代运行两步:1)把数据分为M大小相同的块。2)对每个块执行Reduction,M个thread执行Reduce函数,计算单个块内的一组记录。注意:在每次迭代中,Reduction只在具有相同keys中间结果上执行。
接着Reduce以lock-free方式获得每个线程输出结果的大小和写入位置。最后把所有Reduce workers输出到一个缓存区域。
在GPU运算前,Mars已经在设备上以array格式分配好内存。然而,Map和Reduce输出的大小都是未知的,多线程在一个共享的array上写结果常常发生冲突。为了解决这两个问题,Mars提出了Lock-free方案。每个线程运行MapCount都会输出三个计数,如:中间结果的个数,中间结果keys的大小,和中间结果values的大小。根据中间结果key的大小,Mars计算prefix sum[6],产生写入地址,该地址是一个输出array开始位置加偏移量。前缀和(prefix sum)计算在并行计算中很有用,因为在处理负载平衡问题时,经常需要将若干段数据重新平分,计算前缀和通常是一种有效的将数据平分的方法。
通过这些prefix sum,可以知道中间结果的准确大小,这样可以预先在设备上分配内存保存中间结果。由于每个Map有确定的和不重叠的结果缓存区,就可以避免写入冲突。Lock-free非常适合于大量线程并行运行的程序。
众所周知,锁在解决并行过程中临界资源访问问题的同时可能会引入诸多新的问题,比如死锁(dead lock),另外锁的申请/释放对性能也有不小的影响,当然最大的问题还在于使用锁的代码模块通常难以进行组合。
Lock-free的目标就是要消除锁对编程带来的不利影响。那么lock-free是什么?一个lock-free的解释是一个“锁无关”的程序能够确保执行它的所有线程中,如果某一个线程被挂起,那么其绝对不会阻止其他线程继续运行(Non-Blocking)[2]。
换句话说,各个线程不会互相阻塞,那么你的程序才能成为lock-free的。像我们平常用的互斥锁,当有线程获得锁,其他线程就被阻塞掉了,这里的问题就是如果获得锁的线程挂掉了,而且锁也没有释放,那么整个程序其实就被block在那了,而如果程序是lock-free的那么即使有线程挂掉,也不影响整个程序继续向下进行。所以,如果程序中的某一部分符合下面的条件判定描述,则我们称这部分程序是符合lock-free的。反过来说,如果某一部分程序不符合下面的条件描述,则称这部分程序是不符合 lock-free的。
while (x == 0) {
x = 1-x;
}
在这里如果两个线程同时执行,可能同时进入while循环,然后x两次改变值之后,依然是0,那么两个线程就会一直互相在这里阻塞掉了,所以这里虽然没有锁,依然不是lock-free的。
当我们准备要满足 lock-free 编程中的非阻塞条件时,有一系列的技术和方法可供使用,如原子操作(Atomic Operations)、内存栅栏(Memory Barrier)、避免 ABA 问题(Avoiding ABA Problem)等。那么我们该如何抉择在何时使用哪种技术呢?可以根据下图中的引导来判断。
Read-modify-write是一类原子操作(such as test-and-set, fetch-and-add, and compare-and-swap),即同时读取一个内存位置和写入一个新值,不论写入的新值是一个全新的值或是前一个值的函数。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何线程切换。原子操作也大量用于非阻塞同步。
Compare-and-swap 比较内存中一个位置的内容和给定值,只有两个值相同时,用新的值更新内存中那个位置的内容。CAS由一个原子操作完成。原子性保证新的值是基于最新的信息计算的。如果那个值在这个过程中被其它线程更新过,则会发生写入失败。CAS的返回值表示操作是否成功,如可以返回一个bool值,这种CAS变体称为compare-and-set,也可以返回从内存中读到的值(不是被写入的值)。
function cas(p : pointer to int, old : int, new : int) returns bool {
if *p ≠ old {
return false
}
*p ← new
return true
}
下面是 ABA 问题发生的过程:
内存栅栏也叫内存屏障,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
在lock-free程序中,任何特定的线程可能会被其他线程阻塞,当给定线程被挂起时,其绝对不会阻止其他线程继续运行。CPUs可以继续执行其它线程中。那么lock-free算法提高系统的整体吞吐量,并且仅仅只增加特定事务的延时。
Wait-free算法确保CPUs持续做有用的工作,明确保证没有线程会被另一个线程阻塞[5]。相对于lock-free,wait-free算法更强力地保证高吞吐量, Linux内核的lockless page cache就是一个 wait-free例子。
我们回过头来看Mars中的设计,Mars中多线程读写的是共享内存吗?虽然名字上是共享内存,整个内存对于任一线程都是可存取的,但是每个线程只会读写属于自己的局部内存区,任何一个线程都不会被其他线程阻塞。是不是更应该是一个wait-free算法?
[1] https://developer.nvidia.com/digits
[2] http://preshing.com/20120612/an-introduction-to-lock-free-programming/
[3] Wenbin Fang, Bingsheng He, Qiong Luo, Naga K. Govindaraju: Mars: Accelerating MapReduce with Graphics Processors. IEEE Trans. Parallel Distrib. Syst. 22(4): 608-620 (2011)
[4] CUDA—Tutorial 5—Performance of Atomics. http://supercomputingblog.com/cuda/cuda-tutorial-5-performance-of-atomics
[5] Alistarh, Dan, Keren Censor-Hillel, and Nir Shavit. “Are lock-free concurrent algorithms practically wait-free?.” Proceedings of the 46th Annual ACM Symposium on Theory of Computing. ACM, 2014.
[6] https://en.wikipedia.org/wiki/Prefix_sum
[7] https://en.wikipedia.org/wiki/Memory_barrier
[8] https://en.wikipedia.org/wiki/Prefix_sum