文章目录
- 2.7 常见的并行模式
- 2.7.1 基于循环的模式
- 循环的迭代依赖是指循环的一次迭代依赖于之前的一次或多次先前迭代的结果。
- 基于循环的迭代是实现并行化的模式中最容易的一个。
- 对问题的分解 依据可用的逻辑处理单元数
- 在一个物理设备上支持多个线程可以使设备的吞吐率最大化,也就是说在某线程等待访存或者IO类型的操作时,设备可以处理其他线程的工作。
- 然而对于CPU,过多的线程数量却可能会导致性能下降,这主要是由于上下文切换时,操作系统以软件的形式来完成。
- 当考虑采用循环并行来处理一个串行程序时,最关键的是发现隐藏的依赖关系。
- 我们还需要考虑的另外一种情况是有一个内循环和多个外循环。
- 如果分配给GPU执行的内循环是很小的,通常用一个线程块内的线程来处理。
- 大多数循环是可以展开的,因此把内循环和外循环合并成一个循环。
- 2.7.2 派生、汇集模式
- 此模式是串行程序设计中常见的模式,该模式中含多个同步点且仅有一部分内容是可以并行的,
- 如图2-2,
- 通常,派生/汇聚模式采用数据的静态划分来实现,
- Openmp这样的系统跟GPU类似,实现动态的调度分配。
- 图2-2中,
- 通常,OS执行的是一个“公平的”调度策略。
- so对CPU而言,程序员或者多数多线程库通常是按照处理器的个数来派生相同数目的逻辑处理器线程。
- GPU则相反,我们的确需要成千上万个线程。
- 派生/汇聚模式常用于并发事件的数目事先并不确定的问题。
- 由于在启动内核程序时,块/线程的数量是固定的,所以GPU并不是天生就支持这种模式。
- 求解某些问题时,内核程序内部的并发性会不断变化,内部也会出现一些问题。
- 最后,新GPU支持更快的原子操作和同步原语。
- 2.7.3 分条/分块
- 用CUDA来解决问题,都要求程序员把向题分成若干个小块,即分条/分块。
- 很多方面,GPU与集成在单个芯片上的SMP系统类似。
- 但是达到这个性能却需要一个精心设计的程序,
- 许多问题都存在并发性。
- CUDA提供简单二维网格模型。
- 当考虑并发性时,还可考虑是否可采用ILP
- 实现ILP的基础是 指令流可在处理器内部以流水线的方式执行。
- 2.7.4 分而治之
here!!!
2.7 常见的并行模式
- 很多并行处理问题都可以按照某种模式来分析。
- 在很多程序中,尽管并不是每个人都意识到它们的存在,但我们也可以看到不同的模式,按照模式来分析,使得我们能够对问题进行深入的解构或抽象,这样就很容易找到解决问题的办法。
2.7.1 基于循环的模式
- 任何人都会对循环很熟悉。
- 不同循环语句(如,for、do. while、while)的主要区别在于入口、退出条件
- 及两次循环送代之间是否会产生依赖
循环的迭代依赖是指循环的一次迭代依赖于之前的一次或多次先前迭代的结果。
- 这将并行算法的实现变得十分困难,而这是我们希望消除的。
- 如果消除不了,则通常将循环分解成若干个循环块,块内的迭代是可以并行执行的。
- 循环块0执行完后将结果送给循环块1,然后送给循环块2,以此类推。
- 本书的后面有一个例子就是采用这种方法来处理前缀求和( prefix-sum)算法。
基于循环的迭代是实现并行化的模式中最容易的一个。
- 若循环间的依赖被消除,则剩下的问题就是在可用的处理器上如何划分工作。
- 划分的原则是让处理器间通信量尽可能少,片内资源(GPU上的寄存器和共享内存,CPU上的一级/二级/三级缓存)的利用率尽可能高。
- 糟糕的是,通信开销通常会随着分块数目的增多而迅速增大,成为提高性能的瓶颈、系统设计的败笔
对问题的分解 依据可用的逻辑处理单元数
- CPU:逻辑硬件线程数;
- GPU:流处理器簇(SM)的数量乘以每个SM的最大工作负载。
- 依赖于资源利用率、最大工作负荷和GPU模型,SM的最大工作负载取值范围是1~16块。
- 用的是逻辑硬件线程而不是物理硬件线程。
- 英特尔CPU采用所谓的“超线程”技术,在一个物理CPU核上支持多个逻辑线程。
- 由于GPU在一个SM内运行多个线程块,所以需要用SM的数量乘以每个SM支持的最大块数。
在一个物理设备上支持多个线程可以使设备的吞吐率最大化,也就是说在某线程等待访存或者IO类型的操作时,设备可以处理其他线程的工作。
- 这个倍数的选择有助于在GPU上实现负载平衡( load balancing),并可以应用于改进新一代GPU。
- 当数据划分导致负载不均时,这一点表现得尤为明显一一某些块花费的时间远远大于其他块。
- 这时,可以用几倍于SM数目的数量作为划分数据的基础。
- 当一个SM空闲下来后,它可以去存放待处理块的“池子”里取一个块来处理
然而对于CPU,过多的线程数量却可能会导致性能下降,这主要是由于上下文切换时,操作系统以软件的形式来完成。
- 对缓存和内存带宽竞争的增多,也要求降低线程的数量。
- 因此对于一个基于多核CPU的解决方案,通常它划分问题的粒度要远大于面向GPU的划分粒度。
- 如果在GPU上解决同一个问题,你则要对数据进行重新划分,把它们划分成更小的数据块。
当考虑采用循环并行来处理一个串行程序时,最关键的是发现隐藏的依赖关系。
- 在循环体中仔细地査找,确保每一次迭代的计算结果不会被后面的迭代使用。
- 对于绝大多数循环而言,循环计数通常是从0~设置的最大值。
- 当遇到反过来采用递减计数的循环,你就应该小心些。
- 为什么这个程序员采用相反的计数方法呢?很可能就是循环中存在某种依赖。
- 如果不了解这一点就将循环并行化了,很可能把其中的依赖破坏掉
我们还需要考虑的另外一种情况是有一个内循环和多个外循环。
- 如何将它们并行化呢
- 对于CPU,由于你只有有限的线程,所以只能将这些外循环并行化。
- 不过,像前面提到的那样,可以这样处理的前提是不存在循环迭代依赖。
如果分配给GPU执行的内循环是很小的,通常用一个线程块内的线程来处理。
- 由于循环迭代是成组进行的,所以相邻的线程通常访问相邻的内存地址,这就有助于我们利用访存的局部性,这一点对CUDA程序设计十分重要。
- 外循环的并行处理都是用线程块来实现的,这部分内容将在第5章详细介绍。
大多数循环是可以展开的,因此把内循环和外循环合并成一个循环。
- 例如,图像处理算法中,沿X轴的处理是内循环,而沿Y轴的处理是外循环。
- 可以通过把所有像素点看成是一个一维数组来展开循环,这样迭代就是沿像素点而不是图像坐标进行。
- 尽管编程时麻烦一些,但每次循环包含的迭代次数很小时,收效很大。
- 因为这些小的循环带来的循环开销相对毎次迭代完成的有效工作比较大,所以这些循环的效率很低。
2.7.2 派生、汇集模式
此模式是串行程序设计中常见的模式,该模式中含多个同步点且仅有一部分内容是可以并行的,
- 先运行串行代码,当运行到某一点时会遇到一个并行区,这个并行区内的工作可分布到P个处理器上。
- 这时,程序就fork出N个线程或进程来并行完成这些工作。
- N个线程或进程的执行是独立的、互不相关的,当其工作完成后,则“汇聚”(join)起来。
- Open MP中常可看见这种处理方法
- 程序员用编译指令语句定义可并行区,并行区中的代码被分成N个线程,随后再汇聚成单个线程。
如图2-2,
- 有一个输入的数据项队列和三个处理单元(即CPU核),输入数据队列被分成三个小的数据队列,一个处理单元处理一个小的数据队列,每个队列的处理是互不相关的,处理结果分别写在结果队列的相应位置。
通常,派生/汇聚模式采用数据的静态划分来实现,
- 即串行代码派生出N个线程并把数据集等分到这N个线程上。
- 如果每个数据块的处理时间相同的话,这种划分方法是很好的。
- 但是,由于总的执行时间等于最慢线程的执行时间,所以如果分配给一个线程太多的工作,它将成为决定总时间的一个因素。
Openmp这样的系统跟GPU类似,实现动态的调度分配。
- 办法是,先建一个“线程池”(对GPU而言是个“块池”),
- 然后池中的线程取一个任务执行,执行完后再取下一个。
- 设有1个任务要10个时间能完成,其余20个要1个单位时间就能完成,则它们只能分配到空闲的处理器核上执行。
- 现在有一个双核处理器,则把那个需要10个单位时间的大任务和5个需要1个单位时间的小任务分配给核1,而把其余的15个需要1个单位时间的小任务分配给核2。
- 这样,核1与核2就基本上可以同时完成任务了。
图2-2中,
- 选择生3个线程。
- 队列有6数据,为什么不派生6个线程?
- 因为实际工作中,要处理的数据好几百万,派生一百万个线程都会使任何操作系统崩溃
通常,OS执行的是一个“公平的”调度策略。
- 因此,每个线程都需要按顺序,分配到4个可用的处理器核中的某一个上处理,每个线程都需要一个它自己的内存空间,Windows中,每个线程1MB的栈空间。
- 这意味着,在派生出足够多的线程前,已用尽了全部的内存空间
so对CPU而言,程序员或者多数多线程库通常是按照处理器的个数来派生相同数目的逻辑处理器线程。
- 由于CPU创或删除一个线程的开销是大的,且线程过多也降低处理器的利用率
- 所以常用一个“工人”线程池,池中的“工人”每次从待处理的任务队列中取一个任务来处理,处理完后再取下一个
GPU则相反,我们的确需要成千上万个线程。
- 我们还是使用在很多先进的CPU调度程序中使用过的线程池的概念,将“线程池”改为“线程块池”
- GPU上可并发执行的“线程块”的数目存在一个上限。
- 每个线程块内含若干线程。
- 每个线程块内包含的线程的数目和并发执行的“线程块”的数目会随着不同系列的GPU不同。
派生/汇聚模式常用于并发事件的数目事先并不确定的问题。
- 遍历一个树形结构或者路径搜索这类算法,在遇到另一个节点或路径时,就很可能会派生出额外的线程。
- 当所有的路径都被考查后,这些线程就汇聚回线程池中或者汇聚后又开始新一轮的派生。
由于在启动内核程序时,块/线程的数量是固定的,所以GPU并不是天生就支持这种模式。
- 额外的块只能由主机程序而不是内核程序启动。
- 因此,在GPU上实现这类算法一般都需要启动一系列的GPU内核程序,一个内核程序要产生启动下一个内核程序所需的工作环境。
- 还有一种办法,即通知或与主机程序共同,启动额外的并发内核程序。
- 因为GPU是被设计来执行固定数目的并发线程,所以无论哪种方法实际效果都不算太好。
- 为了解决这个问题,开普勒架构GPU引人了“动态并行性"( dynamic parallelism)的概念。
- 关于这个概念的更多内容,请参见第12章。
求解某些问题时,内核程序内部的并发性会不断变化,内部也会出现一些问题。
- 为此,线程之间要通信与协调。
- 在GPU的一个线程块内,线程之间通信与协调有很多方法实现。
- 假设你有一个8×8的块矩阵,很多块仅需要64个工作线程。
- 然而,很可能其他块却需要使用256个线程。
- 你可以在每个块上同时启动256个线程,这时多数线程处于空闲状态直到需要它们进行工作。
- 由于这些空闲进程占用了一定的资源,会限制整个系统的吞吐率,但它们在空闲时不会消耗GPU的任何执行时间。
- 这样就允许线程使用靠近处理器的更快的共享内存,而不是创建一系列需要同步的操作步骤,而同步这些操作步骤需要使用较慢的全局内存并启动多个内核程序。
- 内存的类型6章介绍
最后,新GPU支持更快的原子操作和同步原语。
- 除了可实现同步外,这些同步原语还可实现线程间通信,本书的后面部分将给出这方面的例子。
2.7.3 分条/分块
用CUDA来解决问题,都要求程序员把向题分成若干个小块,即分条/分块。
- 数并行处理方法也是以不同的形式来使用“条/块化”的概念。
- 气候模型这样巨大的超级计算问题也必须分为成千上万个块,每个块送到计算机中的一个处理单元上去处理
- 这种并行处理方法在可扩展方面具有很大的优势。
很多方面,GPU与集成在单个芯片上的SMP系统类似。
- 每个流处理器簇(SM)就是一个自主的处理器,能同时运行多个线程块,
- 每个线程块有256或512个线程。
- 若干个SM集成在一个GPU上,共享一个公共的全局内存空间。
- 它们同时工作时一个GPU(GTX680)的峰值性能达3 Tflops
但是达到这个性能却需要一个精心设计的程序,
- 因为这个峰值性能并不包括诸如访存这样的操作,
- 而这些操作却是影响任何一个实际程序性能的关键因素。
- 无论什么平台上,为达到高性能,必须了解硬件的知识并深刻理解两个重要的概念
- 并发性和局部性
许多问题都存在并发性。
- 你也许不能立刻就看出问题中的并发性。
- 而“条/块模型”就很直观地展示了并发性的概念。
- 在二维空间里想象一个问题
- 数据的一个平面组织,
- 它可以理解为将一个网格覆盖在问题空间上。
- 在三维空间里想象一个问题,
- 就像一个魔方( Rubik’ s Cube),
- 可把它理解为把一组块映射到问题空间中
CUDA提供简单二维网格模型。
- 对很多问题,这样的模型足够
- 如果在一个块内,你的工作是线性分布的,那么你可很好地将其分解成CUDA块。
- 由于在一个SM内,最多可分配16个块,而在一个GPU内有16个(有些是32个)SM,所以把问题分成256甚至更多的块都可以。
- 实际倾向把一个块内的元素总数限制为128、256或者512,
- 这有助于在数据集内划分更多数量的块
当考虑并发性时,还可考虑是否可采用ILP
- 理论上认为一个线程只提供一个数据输出。
- 但如果GPU上已经充满了线程,同时还有很多数据需要处理,这时我们能够进一步提高吞吐量吗?
- 答案是肯定的,但只能借助于LP。
实现ILP的基础是 指令流可在处理器内部以流水线的方式执行。
- 因此,与“顺序执行4个加法操作”(压入一等待一压入一等待一压入ー等待一压入一等待)相比,
- “把4个加法操作压入流水线队列、等待然后同时收到4个结果”(压入一压入一压入一压入一等待)的效率更高。
- 绝大多数GPU,会发现每个线程采用4个ILP级操作是最佳。
- 9章更详细的研究和例子。
- 如果可能的话,更愿意让每个线程只处理N个元素,
- 这样就不导致工作线程的总数变少了
2.7.4 分而治之
也是种把大问题分解成的小,每个小是可控
- 通过把这些小的、单独的计算汇集在一起,使一个大问題得到解决。
常见的分而治之的算法用“递归”来实现,
- quick sort就是
- 反复递归地把数据一分为二,一部分是位于支点( pivot point)之上的那些点,另一部分是位于支点之下的那些点。
- 最后,当某部分仅含两数据,则对它们做“比较和交换”
绝大多数递归算法可用迭代模型表示。
- 由于迭代模型较适合于GPU基本的条块划分模型,所以该模型易于映射到GPU上。
- 费米架构GPU也支持递归算法。
- 尽管使用CPU时,你必须了解最大调用深度并将其转换成栈空间使用。
- 所以你可以调用API cudaDeviceGetLimit来查询可用的栈空间,
- 也可调用 API cudaDeviceSetLimito来设置需要的梭空间。
- 若没有申请到足够的梭空间,将产生软件故障。
- Parallel Nsight和CUDA-GDB这样的调试工具可检测出像“栈溢出”( stack overflow)
在选择递归算法时,须在开发时间与程序性能之间做出个折中
- 递归算法易理解,与将其转换成一个迭代的方法相比,编码实现递归算法也比较容易。
- 但递归调用需要把所有的形参和全部的局部变量压栈。
- GPU和CPU实现栈的方法是相同的,
- 从全局内存中划出一块存储区间作为栈
- CPU和费米架构GPU都用缓存栈,但与寄存器来传递数据相比,还很慢。
- 所以,在可能的情况下最好还是使用迭代的方法,这样可以获得更好的执行性能,并可以在更大范围的GPU硬件上运行。
here!!!