第2章 使用GPU理解并行计算 摘录

2.2 传统的串行代码  

并行程序通常与硬件的联系更加紧密。引入并行程序的目的是为了获得更高的性能,但是这往往需要付出降低可移植性的代价。每隔一段时间,就会出现一种新的革命性并行计算机架构,从而导致所有的程序代码都要重新编写。

两个并行程序设计标准,OpenMP和MPI,却通过不断修改标准、完善而得以始终采用。面对包含多核处理器的共享存储并行计算机而设计的OpenMP标准,强调的是在单个节点内部实现并行处理。它不涉及节点间并行处理的任何概念。因此所能解决的问题只受到单个节点的处理能力、内容容量和辅存空间的限制。

MPI标准用于解决节点间的并行处理,常用于定义良好的网络内的计算机集群。它常是由几千个节点组成的超级计算机系统。每个节点只承担问题的一小部分。因此,公共资源(CPU、缓存、内存、辅存等)的大小就等于单个节点的资源量乘以网络中节点的个数。任何网络的Achille‘s hell就是它的互连结构,即把机器链接在一起的网络结构。通常,节点间的通信是任何基于集群的解决方案中决定速率的关键因素

当然,OpenMP和MPI也可以联合使用来实现节点内部的并行处理和集群中的并行处理。OpenMP的并行指令允许程序员通过定义并行区,从一个较高的层次分析算法中的并行性;而MPI却需要程序员做大量的工作来显示地定义节点间的通信模型。

线程是程序中一个独立的执行流,它可以在主执行流的要求下分出或聚合。通常情况下,CPU程序拥有的活动线程数量,不超过其包含的物理处理核数量的两倍。

然而,随着线程数量的增加,终端用户也开始感觉到线程的存在。因为在后台,操作系统需要频繁地进行上下文切换(即对一组寄存器内容的换入,换出)。而上下文切换是一件很费时间的操作,通常需要几千个时钟周期,所以CPU的应用程序的线程数比GPU的少。

2.3 串行/并行问题

多线程会引起并行程序设计中的许多问题,例如,资源的共享。通常,这个问题用信号semaphore来解决。而最简单的信号灯就是一个锁或者令牌。只有拿到令牌的那个线程可以使用资源,其他线程只能等待,直到这个线程释放令牌。

例如,如果线程0拥有令牌0,线程1拥有令牌1的情况下,线程0想要等到令牌1, 而线程1想要等到令牌0。由于想要的令牌没有了,线程0和线程1只好休眠,直到他们期待的令牌出现。由于没有一个线程会释放掉自己的令牌,所有的线程将永远等待下去。这就是所谓的死锁(deadlock)。

假如线程操作的是一个共享内存空间,这即可以带来不借助消息就可以完成数据交换的便利,也会引发对共享数据保护的问题。

可以用进程来代替线程。不过,因为代码和数据的上下文都必须由操作系统保存,所以操作系统装入进程就要吃力很多。相比之下,只有线程代码的上下文(一个程序计数器和一组寄存器)由操作系统保存,而数据空间是共享的。

默认情况下,进程在一个独立的内存空间内运行。这样就可以确保一个进程不会影响其他进程的数据。因此,一个错误的指针访问将引发“越界访问”异常,或者很容易在进程中找到bug。不过,数据传递只能通过进程间消息的发送和接受来完成。

一般来说,线程模型较适合于OpenMP,而进程模型较适合于MPI。在GPU的环境下,就需要将他们混合在一起。CUDA使用一个线程块(block)构成的网格(grid)。这可以看成一个进程(线程块)组成的队列(网格),而进程间没有通信。每一个线程块内部有很多线程,这些线程以批处理的方式运行,称为线程束(warp)。

2.4 并发性

并发性的内涵,对于一个特定的问题,无需考虑用那种并行计算机来求解,而是需要关注求解方法中那些操作是可以并行执行的。

如果求解问题的算法在计算每一点的值的时候,必须知道与其相邻的其他点值时,那么算法的计算比最终很难提高。因为处理器或者线程需要花费很多时间来进行线程的通信以实现数据的共享,而不做任何有意义的计算。糟糕情况,取决于通信的次数和每次通信的开销。

对于易并行问题,CUDA是很理想的并行求解平台。它是基于片上资源的,显示的通信原语来支持线程间通信。但是块间的通信只有通过顺序调用多个内核程序才能实现,而且内核间的通信需要用到片外的全局内存。块间通信还可以听过全局内存的原子操作来实现,当然这会有一些限制。

CUDA将问题分解成线程块的网格,每块包含多个线程。块可以按照任意顺序执行。不过在某个时间点上,只有一部分块处于执行中。一旦被调度到GPU包含的N个SM中的一个执行,一个块必须从开始执行到结束。网格中的块可以被分配到任意一个有空闲槽的SM上。期初,可以采用轮询调度(round-robin)策略,以确保分配到每个SM的块数基本相同。对绝大多数内核程序而言,分块的数目应该是GPU中物理SM数量的八倍或者更多倍。

当思考CUDA程序如何实现并发处理时,你应该用这种非常层次化的结构来协调几千个线程的工作。

局部性:

计算性能的提高已经从受限于处理器的运算吞吐率的阶段,发展到迁移数据成为首要限制因素的阶段。计算单元ALU(Algorithmic Logic Unit)是很便宜。然而,ALU的工作却离不开操作数。将操作数送入计算单元,然后从计算单元取出结果,耗费了大量的电能和时间。

这个问题是通过多级缓存来解决问题的。缓存的工作基础是空间的局部性(地址空间相对簇集)和时间局部性(访问时间的簇集)。因次,之前被访问过的数据,很可能还要被再次访问(时间局部性);刚刚被访问过的数据附近的数据很可能马上就会被访问(空间局部性)。

当任务被多次重复执行时,缓存的作用会充分地发挥。

经管固态硬盘比硬盘要快得多,好比一个普通的送货员需要几天才能把硬盘里的数据送到,而送货员一个晚上就能把固态硬盘里的数据送到,但是要访问全局内存相比,它还是很慢的。

在某些英特尔处理器中,每个处理器核支持2个硬件线程即“超线程(hyperthreading)”。在处理某项任务,每当需要一个新的工具或零件,工人就派他的助手去取,然后工人就切换去处理另一项任务。

访问全局内存的延迟大概有几百个时钟周期。对于传统的处理器设计,解决方法是不断增大缓存的容量。实际上,为了降低访问仓库的次数,必须采用一个更大的仓库。代价一则在于更大的工具柜需要投入更多资金。二则在一个更大的工具箱里查找工具或零件需要更多时间。

无论处理器是否试图向程序员隐藏局部性,局部性是客观存在的。为了降低访存延迟而需要引入大量的硬件并不能否认局部性是客观存在的这个事实。

对于GPU程序设计,程序员必须处理局部性。对于一个给定的工作,他需要事先思考需要哪些工具或零件(即存储地址或数据地址),然后一次性把它们从硬盘仓库(全局内存)取来,在工作开始就把它们放在正确的工具柜(片内存储器)里。

可以一次性给处理器提供许多数据,而不是一个数据。这个简单计划使得程序员能够在需要数据前就把它们装入片内存储器。这项工作即适合于诸如GPU内共享内存的显式局部存储模型,也适合基于CPU的缓存。在缓存情况下,你可以用特殊的缓存指令来把你认为的程序将要用到的数据,先行装入缓存。

与共享内存相比,缓存的麻烦是替换和对“脏”数据的处理。所谓“脏”数据是指缓存中被程序写过的数据。为了获得缓存空间以接纳新的有用数据,“脏”数据必须在新数据装入之前写回到全局内存。这就意味着,对于延迟未知的全局内存访问,我们需要做两次,而不是一次。第一次是写旧的数据,第二次是取新的数据。

引入受程序员 控制的片上内存,带来的好处是程序员可以控制发生写操作的时间。如果你正在进行数据的局部变换,可能就没有必要将变换的中间数据写回到全局内存。反之用缓存的话,缓存控制器就不知道那些数据应该写,那些数据可以抛弃。因此,它全部写入。这势必增加了很多无用的访存操作,甚至会造成内存接口拥堵。

程序的局部性要么“与生具有的”,要么就根本没有。

2.5 并行处理的类型

2.5.1 基于任务的并行处理

如果仔细分析一下典型的操作系统,我们就会发现它实现的是一种所谓的任务并行的并行处理,因为各个进程是不同的,无关的。

一个程序的输出作为下一个程序的输入,这种并行处理称为流水线并行处理(pipeline parallelism)。

这种并行处理向着"粗粒度并行处理(coarse-grained parallelism)"发展,即引入许多计算能力很强的处理器,让每个处理器完成一个庞大的任务。

就GPU而言,我们看到的“粗粒度并行处理”是由GPU卡和GPU内核程序来执行的。GPU有两种方法来支持流水线并行处理模式。一是,若干个内核程序被依次排列成一个执行流。然后不同的执行流并发地并行;二是,多个GPU协同工作,要么通过主机来传递数据,要么直接通过PCI-E总线,以消息的形式在GPU之间直接传递数据。后一种方法,也叫作点对点通信(Peer-to-Peer, P2P)机制,是在CUDA4.0之后引入的。

和任何生产流水线一样,基于流水线的并行处理模式的一个问题就是,它的运行速度等于其中最慢的部件。解决方法是加倍(twofold)。给最慢的部件配备更多的计算资源。

最大加速比就等于程序执行时间最长那部分占整个程序比例的倒数。这就是阿姆达尔法则(Amdahl's law)。

2.5.2 基于数据的并行处理

基于数据的并行处理的思路是首先关注数据以及所需的变换,而不是待执行的任务。

基于任务的并行处理更适合粗粒度并行处理方法。

如果CPU核交替地使用数据,这意味着,缓存必须协调和组合来自不同CPU核的写操作。这是很糟糕的。

如果算法允许,还可以探讨另一种类型的数据并行处理--单指令多数据(SIMD)。这些需要特殊的SIMD指令。

与CPU核不同,每个SM可以处理多个数据块,每个数据块的处理被分成多线程来处理。由于GPU仅支持"加载(load)","存储(store)","移动(move)"三条显式原语。这这反过来促进了GPU指令级并行处理(Instruction-Level Parallelism)功能的增强。

在GPU上,相邻内存单元是通过硬件合并在一起进行存取的。因此单次访存的效率增高了。

GPU与CPU在缓存上一个重要的差别就是"缓存一致性(cache coherency)"问题。对于缓存一致的系统,一个内存写操作需要通知所有核的各个级别的缓存。因此,无论何时,所有处理器看到的内存视图完全一样的。随着处理器的增多,这种通知的开销迅速增加,使得缓存一致性成为限制一个处理器中核数不能太多的一个重要因素。缓存一致系统最坏的情况是,一个内存写操作会强迫每个核的缓存都进行更新,进而对每个核都要对相邻的内存单元进行写操作。

而GPU不遵循缓存一致原则。故GPU能够扩展到一个芯片内具有大数量的核心。(流处理器)。

CPU遇到线程过多的情形下,内存带宽就会变得很拥挤,缓存的利用率急剧下降,导致性能降低而不是增高。

使用GPU时,我们必须保证有足够多的线程块(通常至少是GPU内SM数量的8-16倍)。

2.7 常用的并行模式

2.7.1 基于循环的模式

循环的迭代依赖是指循环一次迭代依赖于之前的一次或先前多次迭代的结果。这种依赖会使得并行算法的实现变得非常困难。如果消除不了依赖,则通常将这些循环分解成若干个循环块,块内的迭代是可以并行执行的。

消除了循环依赖的循坏是实现并行化模式最简单的一个。因为剩下的问题就是在可用的处理器上如何划分工作。划分工作的原则是让处理器间的通信量尽可能少,片内资源的利用率尽可能的高。糟糕的是,通信开销通常会随着分块数目的增多而迅速增大,成为提高性能的瓶颈,系统设计的败笔。

对问题的宏观分解应该依据可用的逻辑处理单元的数量。对于CPU,就是可用的逻辑硬件线程数量;对于GPU,就是流处理器簇(SM)的数量乘以每个SM的最大工作负载。依据资源利用的功率表、最大工作负荷和GPU模型,SM的最大工作负载取值范围是1-16块。

在一个物理设备上支持多个线程可以使得设备的吞吐率最大化,也就是说在某个线程等待访存或者I/O类型操作时,设备可以处理其他线程的工作。这个倍数的选择有助于在GPU上实现负载平衡(load balancing)。

对于CPU,过多的线程数量却可能会导致性能下降,这主要是由于上下文切换时,操作系统以软件的形式来完成。对于缓存和内存带宽竞争的增加,也要求降低线程的数量。因此对于一个基于多核CPU的解决方案,通常它划分问题的粒度要远大于面向GPU的划分粒度。如果在GPU上解决同一个问题,你则要对数据进行重新划分,把它们划分更小的数据块。。

当考虑采用循环并行来处理一个串行程序时,最关键的是发现隐藏的依赖关系。在循环体中仔细地查找,确保每一次迭代的计算结果不会被后面的迭代使用。对于绝大多数循环而言,循环计数通常是从0~设置的最大值。有时候递减计数的循环,你就应该小心些。

如果分配给GPU执行的内循环是很小的,通常用一个线程块内的线程来处理。由于循环迭代是成组进行的,所以相邻的线程通常访问相邻的内存地址,这就有助于我们利用访存的局部性。

尽管编程时麻烦一些,但是在每次循环所包含的迭代次数很小时,收获很大。因为这些小的循环带来的循环开销相对迭代完成的有效工作比较大,所以这些循环的效率很低。

2.7.2 派生/汇集模式

派生/汇聚模式是一个在串行程序设计中常见的模式,该模式中包含多个同步点而且仅有一部分内容是可以并行处理的,即首先运行串行代码,当运行到某一点时遇到一个并行区,这个并行区的工作可以按照某种方式分布在P个处理器上。此时派生N个线程或进程的执行是独立的、互不相关的,当其工作完成后,则聚集(join)起来。

通常,派生/汇聚模式是采用数据的静态划分来实现。如果数据块的处理时间相同的话,这种划分方法是很好的。

诸如OpenMP这样的系统跟GPU的方案类似,实现动态的调度分配。具体办法是,先创建一个线程池(对GPU而言是一个块池),然后池中的线程去一个任务执行,执行完再取下一个。

每个线程都需要一定内存的栈空间。

GPU上可以并发执行的线程块的数目存在一个上限。每个线程块包含若干个线程。每个线程块内包含的线程的数目和并发并行的线程块的数目会随着不同系列的GPU而不同。

派生/汇聚模式通常用于并发事件的数目事先并不确定的问题。遍历一个树形结构或者路径搜索这类算法。

由于在启动内核程序时,块/线程的数量是固定的,所以GPU并不是天生支持派生/汇聚。额外的块只能由主机程序而不是内核程序启动。因此GPU上实现这些算法一般都需要启动一系列的GPU程序,一个内核程序要产生启动下一个内核程序所需的工作环境。另外一种办法是通知或与主机程序共同,启动额外的并发内核程序。因为GPU是被设计来执行固定数目的并发线程,所以实际上这两种方法效果都不理想。为解决这个问题,Kepler架构引入了动态性并行(dynamic parallelism)。

在求解某些问题时候,内核程序的并发性会不断变化,内部也会出现一些问题。为此,线程之间需要进行通信和协调。

若线程块中线程处于空闲状态知道需要它们工作。由于这些空闲线程占用了一定资源,会限制整个系统的吞吐率,但它们在空闲时不会消耗GPU的任何执行时间。这就允许线程使用使用靠近处理器的更快的共享内存,而不是创建一系列需要同步的操作步骤,而这些操作步骤需要使用较慢的全局内存并启动多个内核程序。

新的GPU支持更快的原子操作和同步原语。除了可以实现同步外,这些同步原语还可以实现线程间通信。

2.7.3 分条/分块

使用cuda来解决问题,都要求程序员把问题分成若干个小块,即分条/分块。绝大多数并行处理方法也是以不同的形式来使用“条/块化”的概念。

无论在什么平台,为了达到高性能,就必须很好地了解硬件知识并深刻两个概念--并发性和局部性。

当考虑并发性时,还可以考虑是否采用指令级并行性(ILP)。

实现ILP的基础是指令流可以在处理器内部以流水线的方式执行。

2.7.4 分而治之

分而治之模式是将一种大问题分解成小问题的模式,其中每个小问题都是可控制的。通过把这些小的、单独的计算汇集在一起,使得一个大的问题得以解决。

所有的递归调用都需要将形参和局部变量压人栈中。尽管费米架构GPU都用缓存栈,但与使用寄存器来传递数据相比,这还是很慢的。在可能的情况,GPU还是使用迭代的方法。

由于GPU启动内核程序时,块/线程的数量是固定的,所以GPU并不适合派生/汇聚情形。另外,GPU是被设计来执行固定数目的并发线程,

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值