雲計算

带你读《基于CUDA的GPU并行程序开发指南》之三:改进第一个CPU并行程序

点击查看第一章
点击查看第二章

第3章

改进第一个CPU并行程序
我们并行化了第一个串行程序imflip.c,并在第2章中开发了它的并行版本imflipP.c。并行版本使用pthreads实现了合理的加速,如表2-1所示。当我们在具有4C/8T的i7-960 CPU上分别启动2个和3个线程时,多线程将执行时间从131 ms(串行版本)分别降低到70 ms和46 ms。然而引入更多的线程(即≥4)并没有帮助。在本章中,我们想让读者了解影响表2-1中结果数据的各种因素。我们可能无法改进它们,但我们必须能够解释为什么无法改进它们。我们不想仅仅因为运气而取得好的性能表现!

3.1 程序员对性能的影响

理解硬件和编译器可以帮助程序员编写好的代码。多年来,CPU架构师和编译器设计人员不断改进其CPU架构和编译器的优化功能。许多这些努力有助于减轻软件程序员的负担,因此,程序员在编写代码时不用担心底层的硬件细节。但是,正如我们将在本章中看到的,了解底层硬件和高效利用硬件也许会让程序员在某些情况下开发出性能提升10倍的代码。
这种说法不仅对CPU来说是正确的,当硬件得到有效的利用时,潜在的GPU性能改进更加明显,因为许多GPU性能的显著提升来自软件。本章将介绍所有与性能有关的因素及其相互之间的关系:程序员、编译器、操作系统和硬件(以及某种程度上的用户)。

  • 程序员拥有根本的智慧,应该理解其他部分的功能。没有任何软件或硬件可以与程序员所能做的相提并论,因为程序员具有最宝贵的资产:逻辑。良好的编程逻辑需要完全理解难题的所有方面。
  • 编译器是一个庞大的软件包,它的常规功能有两个:编译和优化。编译是编译器的工作,优化是编译器在编译时必须执行的额外工作,以优化程序员可能编写的低效代码。所以编译器在进行编译时是“组织者”。编译时,时间是静止的,这意味着编译器可以仔细考虑在运行时可能发生的许多情况,并为运行时选择最好的代码。当我们运行程序时,时钟开始滴答滴答。编译器唯一无法知道的是数据,它们可能会完全改变程序的流程。只有在操作系统和CPU工作时,才能在运行时知道数据的情况。
  • 在运行时,操作系统(OS)可以看作是硬件的“老板”或“经理”。它的工作是在运行时有效地分配和映射硬件资源。硬件资源包括虚拟CPU(即线程)、内存、硬盘、闪存驱动器(通过通用串行总线[USB]端口)、网卡、键盘、显示器、GPU(一定程度)等。好的操作系统知道它的资源以及如何很好地映射它们。为什么这很重要?因为资源本身(例如CPU)不知道该怎么做。它们只是遵循命令。操作系统是司令,线程是士兵。
  • 硬件是CPU+内存+外围设备。操作系统接受编译器生成的二进制代码,并在运行时将它们分配给虚拟核心。虚拟核心在运行时尽可能快地执行它们。操作系统还要负责CPU与内存、磁盘、键盘、网卡等之间的数据传输。
  • 用户是难题的最后一部分:了解用户对编写好的代码也很重要。一个程序的用户不是程序员,但程序员必须向用户提出建议,并且必须与他们沟通。这不是一件容易的事情!

本书主要关注硬件,尤其是CPU和内存(以及在后面第二部分中要讲的GPU和显存)。理解硬件是开发高性能代码的关键,无论是CPU还是GPU。在本章中,我们将发现是否有可能加速我们的第一个并行程序imflipP.c。如果可以的话,如何实现?唯一的问题是:我们不知道可以使用哪些硬件来更高效地提高性能。所以,我们会查看所有可能。

3.2 CPU对性能的影响

在2.3.3节中,我解释了当我们启动多线程代码时发生的事件序列。在2.4节中,我还列出了许多你可能会想到的如何解释表2-1的问题。让我们来回答第一类也是最明显的一类问题:

  • 当CPU不同时,这些结果会如何变化?
  • 取决于CPU的速度,还是核心数量,线程数?
  • 或者是其他与CPU有关的属性?比如高速缓存?

也许,回答这个问题最有趣的方法是在许多不同的CPU上运行相同的程序。一旦得到结果,我们可以尝试从它们中发现点什么。测试这些代码的CPU越多,得到的答案可能也会越好。我将在表3-1中列出的6个不同的CPU上运行此程序。
表3-1列出了一些重要的CPU参数,如核心数和线程数(C/T),每个核心拥有的L1$和L2$高速缓存的大小,分别表示为L1$/C和L2$/C,共享的L3$高速缓存大小(由所有的4个、6个或8个核心共享)。表3-1还列出了每台计算机的内存大小和内存带宽(BW),内存带宽以千兆字节每秒(GBps)为单位。在本节中,我们将重点关注CPU对性能的影响,但对内存在决定性能方面的作用的解释将贯穿本书。我们也将在本章中介绍内存的操作。目前,为了防止性能指标与内存而不是CPU有关,内存参数也被列在
表3-1中。


image.png

在本节中,我们不会评估这6个CPU的性能结果如何不同。我们只是想看CPU竞赛来开心一下!当看到这些数值时,我们可以得出哪些才是决定程序整体性能最关键因素的结论。换句话说,我们正在从远距离观察事物。稍后我们会深入探讨细节,但收集的实验数据将帮助我们找到一些方法来提高程序性能。

3.2.1 按序核心与乱序核心

除了CPU有多少个核心之外,还有另一个与核心相关的因素。几乎每个CPU制造商开始时都是制造按序(In-Order,inO)核心,然后将其设计升级到更先进的系列产品中的乱序(Out-of-Order,OoO)核心。例如,MIPS R2000是一个inO型CPU,而更先进的R10000是OoO。同样的,Intel 8086、80286、80386以及更新的Atom CPU都是inO,而Core i3、i5、i7以及Xeon都是OoO。
inO和OoO之间的区别在于CPU执行给定指令的方式,inO类型的CPU只能按照二进制代码中列出的顺序来执行指令,而OoO类型的CPU可以按照操作数可用性的顺序执行指令。换句话说,OoO型CPU可以在后面的指令中找到很多可做的工作。与之相对的是,当按照给定的指令顺序执行时,如果下一条指令没有可用的数据(可能是因为内存控制器还没有从内存中读取必要的数据),inO类型的CPU只是处于空闲状态。
OoO型CPU执行指令的速度更快,因为当下一条指令在操作数可用之前不能立即执行时,它可以避免被卡住。然而,这种奢侈的代价昂贵:inO型CPU需要的芯片面积更小,因此允许制造商在同一个集成电路芯片中安装更多的inO核心。由于这个原因,每个inO核心的时钟实际上可能会更快一些,因为它们更简单。
类比3.1:按序执行与乱序执行
Cocotown举办了一个比赛,两队农民比赛做椰子布丁。这是提供给他们的指导书:
(1)用自动破碎机敲碎椰子;(2)用研磨机研磨从破碎机敲碎的椰子;(3)煮牛奶;
(4)放入可可粉并继续煮沸;(5)将研磨后的椰子粉放入混有可可的牛奶中,继续煮沸。
每一步都要花费10分钟。第一队在50分钟内完成了他们的布丁,而第二队在30分钟内完成,震惊了所有人。他们获胜的秘密在赛后公开:他们同时开始敲椰子(步骤1)和煮牛奶(步骤3)。这两项任务并不相互依赖,可以同时开始。10分钟后,这两项工作都完成了,可以开始研磨椰子(步骤2)。与此同时,将可可与牛奶混合并煮沸(步骤4)。所以,在20分钟内,他们完成了步骤1~4。
不幸的是,步骤5必须等待步骤1~4完成后才能开始,使其总执行时间为30分钟。所以,第二队的秘诀就是乱序执行任务,而不是按照指定的顺序执行。换句话说,他们可以执行任何不依赖于前一步结果的步骤。
类比3.1强调了OoO运行的性能优势。一个OoO核心可以并行地执行独立的依赖链(即互不依赖的CPU指令链),而无须等待下一条指令完成,实现了健康的加速。但是,采用两种类型中的一种设计CPU时,还存在一些需要妥协的地方。人们想知道哪一种是更好的设计思路:(1)多一些inO核心;(2)少一些OoO核心。如果我们将这个想法推向极致,将CPU中的60个核心都设计为inO核心会怎么样?这会比拥有8个OoO核心的CPU更快吗?答案并不像选择其中的一种那么简单。
下面是一些inO型与OoO型CPU比较的真实情况:









  • 这两种设计思路都是可行的,也有一个真正按照inO类型设计的CPU,称为Xeon Phi,由Intel制造。Xeon Phi 5110P在每个CPU中有60个inO核心,每个核心有4个线程,使其能够执行240个线程。它被看成集成众核(MIC)而非CPU,每个核心的工作速度都非常低,如1 GHz,但是它的核心和线程的数量很大,从而可以获得计算优势。inO核心的功耗非常低,60C/240T的Xeon Phi的功耗仅略高于差不多拥有6C/12T的Core i7 CPU。稍后,我将给出在Xeon 5110P上的执行时间。
  • inO类型CPU只对某些特殊的应用程序有好处,并非每个应用程序都可以利用如此多的核心或线程。对于大多数应用程序来说,当核心或线程数量超过某一特定值后,我们获得的回报就会不断减少。一般来说,图像和信号处理应用程序非常适合inO类型CPU或MIC。高性能科学计算类的应用程序通常也是使用inO类型CPU的候选对象。
  • inO核心的另一个优势是低功耗。由于每个核心都简单得多,它消耗的功率比同档次的OoO核心要少。这就是当今大多数上网本都采用英特尔Atom处理器(具有inO核心)的原因。一个Atom CPU只消耗2~10瓦。Xeon Phi MIC一般有60个Atom核心,每个核心有4个线程,全部封装在一块芯片中。
  • 如果拥有较多的核心和线程能够使一些应用程序受益,那么为什么不让这种想法更进一步,将数千个核心都放入计算单元中,同时让每个核心可以执行超过4个的线程呢?事实证明,这种想法也是可行的。类似地,可以在大约数千个核心中执行数十万个线程的处理器称为GPU,也就是本书关注的对象!

3.2.2 瘦线程与胖线程

在执行多线程程序(如imflipP.c)时,可以在运行时给一个核心分配多个线程来执行。例如,在一个4C/8T的CPU上,两个线程运行在两个独立的核心上,也可以运行在一个核心上,这有什么区别吗?答案是:当两个线程共享一个核心时,它们必须共享所有的核心资源,例如高速缓存、整数计算单元和浮点计算单元。
如果需要大量高速缓存的两个线程被分配给同一个核心,那它们会把时间浪费在将数据从高速缓存中移进或移出,从而无法从多线程中获益。假设一个线程需要大量的高速缓存访问,而另一个线程只需要整数计算单元而不需要高速缓存访问。这样的两个线程在执行期间是放在同一核心中运行的优秀候选者,因为它们在执行期间不需要相同的资源。
另一方面,如果程序员设计的一个线程对核心资源的需求较少,那么它从多线程中的获益就会很大。这样的线程称为瘦线程,而那些需要大量核心资源的线程称为胖线程。程序员的责任是认真地设计这些线程以避免占用过多的核心资源,如果每个线程都是胖线程,增加线程数量就不会带来什么好处了。这就是为什么像微软这样的操作系统设计人员在设计线程时要考虑避免影响多线程应用程序的性能。最后要说的是,操作系统是一个终极多线程应用程序。

3.3 imflipP的性能

表3-2列出了imflipP.c在一些CPU(表3-1中列出)上的执行时间(以毫秒为单位)。列出线程总数只是为了显示不同的数值。CPU2一栏的结果与表2-1中的一样。每个CPU上的结果趋势似乎非常相似:开始性能会有所提升,但线程数达到一定数量时,性能的提升就会遇到一堵墙!当超过由CPU决定的某个拐点后,启动更多的线程对性能提升不会有帮助。
表3-2展现出了不少问题,比如:

  • 在已知最多能执行8个线程(.../8T)的4C/8T CPU上启动9个线程意味着什么?
  • 这个问题的正确问法也许是:启动和执行一个线程有什么不同?
  • 当我们将一个程序设计为“8线程”时,我们期待运行时会发生什么?我们是否假设全部8个线程都会被执行?
  • 2.1.5节中提到:某计算机上启动了1499个线程,但CPU利用率为0%。所以,不是每个线程都在并行地执行。否则,CPU利用率将达到峰值。如果一个线程没有被执行,它在做什么?运行时谁在管理这些线程?

image.png

  • 上面这些问题也许可以回答为什么超过4个以上的线程不能帮助我们提高表3-2中的性能结果。
  • 还有一个问题是,胖线程或瘦线程否可以改变这些结果。
  • 很明显的是:表3-1中的所有CPU都是OoO。

3.4 操作系统对性能的影响

我们还可以提出很多其他问题,这些问题的本质都是一样的:线程在运行时会发生什么?换句话说,我们知道操作系统负责管理虚拟CPU(线程)的创建/关联,但现在是了解细节的时候了。回到我们前面提及的与性能有关的因素列表:

  • 程序员通过为每个线程编写函数来确定一个线程需要做什么。这在编译时就决定了,此时没有任何运行时信息可用。编写该函数的语言比机器代码高级得多,而机器代码是CPU唯一能理解的语言。在过去,程序员直接编写机器代码,这使得程序开发可能困难100倍。现在我们有了高级语言和编译器,所以我们可以将负担转移给编译器。程序员的最终产品是一个程序,它是一组按指定顺序执行的任务,包含各种假设场景。使用假设场景的目的是对运行时各种不同的事件做出很好的响应。
  • 编译器编译时将线程创建函数编译为机器代码(CPU语言)。编译器的最终产品是可执行指令序列或二进制可执行文件。请注意,编译器将编程语言编译为机器代码时基本上不知道运行时会发生什么。
  • 操作系统负责运行时的问题。为什么我们需要这样的中间软件?因为执行由编译器生成的二进制文件时会发生许多不同的事情。可能发生的不好的事情有以下几种情况:(1)磁盘可能已满;(2)内存可能已满;(3)用户的输入可能导致程序崩溃;(4)程序可能请求了过多的线程数目,但已没有可用的线程句柄。另外,即使没有出错,也必须对资源效率负责,也就是说,要高效地运行程序,需要注意以下几点:(1)谁获得了虚拟CPU;(2)当程序申请内存时,是否应该获得,如果是的话,指针是什么;(3)如果一个程序想创建一个子线程,我们是否有足够的资源来创建它?如果有,线程句柄是什么;(4)访问磁盘资源;(5)网络资源;(6)其他任何你可以想象的资源。资源是在运行时管理的,无法在编译时精确地知道它们。
  • 硬件负责执行机器代码。操作系统在运行时将需要执行的机器代码分配给CPU。类似地,存储器主要由在OS控制下的外围设备(例如,直接存储器访问—DMA控制器)进行读取和传送。
  • 用户负责享受这些程序,如果程序写得很好并且运行时一切正常,将能够产生出色的结果。

3.4.1 创建线程

操作系统知道它拥有哪些资源,因为一旦计算机打开,大多数资源都是确定的。虚拟CPU的数量就是其中之一,也是我们最感兴趣的一个。如果操作系统确定它正在拥有8个虚拟CPU的处理器上运行(正如我们在4C/8T机器上的情况),它会给这些虚拟CPU分配名称,如vCPU0、vCPU1、vCPU2、vCPU3、……、vCPU7。在这种情况下,操作系统拥有8个虚拟CPU的资源并负责管理它们。
当程序用pthread_create()启动一个线程时,操作系统会为该线程分配一个线程句柄,比如1763。这样,程序在运行时会看到ThHandle[1]=1763。该程序将此解释为“tid=1被分配给了句柄ThHandle [1]=1763。”该程序只关心tid=1,操作系统只关心其句柄列表中的1763。尽管这样,程序必须保存该句柄(1763),因为这个句柄是告诉操作系统它正在和哪个线程进行对话的唯一方式,tid=1或ThHandle[]只不过是一些程序变量,而且对操作系统的内部工作并不重要。

3.4.2 线程启动和执行

操作系统在运行时将ThHandle[l]=1763分配给一个父线程后,父线程就会明白它获得了使用该子线程执行某个函数的授权。它会使用在pthread_create()中设置的函数名来启动相关代码。这是告诉操作系统,除了创建该线程,现在父线程想要启动该线程。创建一个线程需要一个线程句柄,启动一个线程则需要分配一个虚拟CPU(即找到某人完成这项工作)。换句话说,父线程说:查找一个虚拟CPU,并在该CPU上运行此代码。
在这之后,操作系统尝试查找可用于执行此代码的虚拟CPU资源。父线程不关心操作系统选择哪个虚拟CPU,因为这是操作系统负责的资源管理问题。操作系统会在运行时将刚分配的线程句柄映射到一个可用的虚拟CPU上(例如,句柄1763→vCPU 4),如果虚拟CPU 4(vCPU4)在pthread_create()被调用时正好可用。

3.4.3 线程状态

在2.1.5节提到过,计算机上启动了1499个线程,但CPU利用率为0%。因此,不是每个线程都在并行地执行着。如果一个线程没有执行,那么它在做什么?一般来说,如果一个CPU有8个虚拟CPU(如4C/8T处理器),那么处于运行状态的线程不会超过8个。正在执行的线程状态是这样的:操作系统不仅认为它是就绪的,并且该线程此刻正在CPU上执行(即正在运行)。除了运行,一个线程的状态还可以是就绪、阻塞,或者在其作业完成时终止,如图3-1所示。

image.png

当应用程序调用pthread_create()来启动一个线程时,操作系统会立即确定一件事情:我是否拥有足够的资源来分配句柄并创建此线程?如果答案为“是”,则为该线程分配一个线程句柄,并创建所有必需的内存和栈区。此时,线程的状态为就绪,并被记录在该句柄中。这意味着该线程可以运行,但尚未运行。通常它会进入可运行线程队列并等待运行。在未来的某个时刻,操作系统会决定开始执行这个线程。为此,必须发生两件事情:
(1)找到能够执行该线程的虚拟CPU(vCPU);(2)线程的状态更改为运行。
就绪→运行状态的改变由称为分发器的隶属于操作系统的一个模块来处理。操作系统将每个可用的CPU线程视为虚拟CPU(vCPU),例如,一个8C/16T的CPU有16个vCPU。在队列中等待的线程可以在vCPU上开始运行。一个成熟的操作系统会关注在哪里运行线程以优化性能。这种称为核心亲和性的分发策略实际上可以由用户手动修改,以覆盖操作系统默认的不太优化的分发策略。
操作系统允许每个线程运行一段时间(称为时间片),然后切换到另一个已处于就绪状态下等待的线程。这样做是为了避免饥饿现象,即一个线程永远停留在就绪状态。当一个线程从运行切换到就绪状态时,它所有的寄存器信息,甚至更多信息,必须被保存在某个地方,这些信息称为线程的上下文。同样,从运行到就绪状态的更改称为上下文切换。上下文切换需要一定的时间才能完成,并对性能有影响,但这是不可避免的现实。
在执行过程中(处于运行状态),线程可能会调用函数(如scanf())来读取键盘输入。读取键盘比任何其他的CPU操作要慢得多。所以,没有理由让操作系统在等待键盘输入时让我们的线程保持运行状态,这会使其他线程饥饿。在这种情况下,操作系统也无法将此线程切换为就绪状态,因为就绪队列存放的线程在时间允许时可立即切换到运行状态。一个正在等待键盘输入的线程可能会等待一段无法预知的时间,这个输入可能会立刻发生,也可能在10分钟后发生,因为用户可能离开去喝咖啡!所以,把这种状态称为阻塞
当一个线程正在一段时间内请求某个无法使用的资源时,或者它必须等待某个不确定何时会发生的事件时,它会从就绪状态切换到阻塞状态。当所请求的资源(或数据)变得可用时,线程又会从阻塞状态切换回就绪状态,并被放入就绪线程的队列中,即等待被再次执行。操作系统直接把这个线程切换到运行状态是没有意义的,因为这意味着另一个正在平静地执行的线程被胡乱地调度,即将它踢出核心!因此,为了有序地运行,操作系统将已阻塞的线程放回就绪队列中,并决定何时允许它再次执行。但是,操作系统可能会因为某个原因给该线程分配一个不同的优先级,以保证它能够在其他线程之前被调度。
最后,当一个线程执行完成并调用pthread_join()函数后,操作系统会执行运行→终止的状态切换,该线程被永久地排除在就绪队列外。一旦该线程的存储区域等被清除,该线程的句柄就会被释放,并可用于其他pthread_create()。





3.4.4 将软件线程映射到硬件线程

3.4.3节回答了关于图2-1中的1499个线程的问题:我们知道在图2-1中看到的1499个线程中,至少有1491个线程在4C/8T的CPU上处于就绪阻塞状态,因为处于运行状态的线程不能超过8个。可以把1499看作要完成的任务数量,但一共只有8个人来做!在任何时刻,操作系统都没有足够的物理资源来同时“做”(即执行)超过8件事情。它挑选1499个任务中的一个,并指定一个人来完成。如果另一项任务对于这个人来说更加紧迫(例如,如果网络数据包到达,需要立即处理),则操作系统会切换为执行更紧急的任务并暂停他当前正在执行的任务。
我们很好奇这些状态切换如何影响应用程序的性能。对于图2-1中的1499个线程,其中的1495个线程很可能是阻塞就绪的,它们在等待你敲击键盘上的某个键,或者等待某个网络包的到达,只有4个线程正处于运行状态,也许就是你的多线程应用程序代码。下面是一个类比:
类比3.2:线程状态
透过窗户,你在路边看到1499张纸片,上面写着任务。你还看到外面有8名员工都坐在椅子上,等待经理给他们分派执行任务。在某个时刻,经理告诉 #1号员工去拿起 #1256号纸片。然后,#1号员工开始执行写在 #1256号纸片上的任务。突然,经理告诉 #1号员工将 #1256号纸片放回原处,停止执行 #1256号任务并拿起 #867号纸片,开始执行 #867号纸片上的任务……
由于#1256号任务尚未执行完成,所以#1号员工执行#1256号任务的所有笔记都必须写在经理的笔记本中的某个地方,以便 #1号员工稍后能回忆起它们。事实上,该任务甚至可能会由不同的员工来继续完成。如果 #867号纸片上的任务已经完成,它可能会被揉成一团并扔进废纸篓,表明该任务已完成。
在类比3.2中,坐在椅子上对应线程的就绪状态,执行写在纸上的任务对应线程的运行状态,员工是虚拟CPU。批准员工切换状态的经理是操作系统,而经理的笔记本是保存线程上下文的地方,以供稍后在上下文切换期间使用。销毁纸片(任务)等同于将线程切换到终止状态。
所启动的线程数量可以从1499增加到1505或减小到1365等,但是可用的虚拟CPU的数量不会改变(例如,在这个例子中为8),因为它们是“物理”实体。一种好的方式是将这1499个线程定义为软件线程,即操作系统创建的线程。可用的物理线程(虚拟CPU)数量是硬件线程,即CPU制造商设计CPU能够执行的最大线程数。这容易让人有点困惑,因为它们都被称为“线程”,因为软件线程只不过是一种数据结构,包含关于线程将执行的任务以及线程句柄、内存区等信息,而硬件线程是正在执行机器代码(即程序的编译版本)的CPU的物理硬件部件。操作系统的工作是为每个软件线程找到一个可用的硬件线程。操作系统负责管理硬件资源,如虚拟CPU、可用的内存等。





3.4.5  程序性能与启动的线程

软件线程的最大数量仅受内部操作系统的参数限制,而硬件线程的数量在CPU设计时就固定下来。当你启动一个执行2个高度活跃线程的程序时,操作系统会尽可能快地使它们进入运行状态。操作系统中执行线程调度程序的线程可能也是另一个非常活跃的线程,从而使高度活跃的线程数为3。
那么,这如何帮助解释表3-2中的结果?虽然准确的答案取决于CPU的架构,但有一些明显的现象可以用我们刚刚学到的知识来解释。让我们选择CPU2作为例子。虽然CPU2应该能够并行执行8个线程(它是一个4C/8T),但程序启动3个以上的线程后,性能会显著下降。为什么?我们先通过类比来进行推测:

  • 回忆类比1.1,两位农民共用一台拖拉机。通过完美的任务安排,他们总共可以完成2倍的工作量。这是4C/8T能够获得8T性能的理论支持,否则,实际上你只有4个物理核心(即拖拉机)。
  • 如果代码2.1和代码2.2中发生了这种最好的情况,我们应该期望性能提升现象能够持续到8个线程,或者至少是6个或7个线程。但我们在表3-2中看到的不是这样!
  • 那么,如果其中一项任务要求其中一位农民以乱序的方式使用拖拉机的锤子和其他资源呢?此时,另一位农民不能做任何有用的事情,因为他们之间会不断产生冲突并导致效率持续下降。性能甚至不会接近2倍(即,1+1=2)!性能会更接近0.9倍!就效率而言,1+1=0.9听起来很糟糕!换句话说,如果2个线程都是“胖线程”,它们并不会与同一个核心中的另一个线程同时工作,我的意思是,高效率地……这就是代码2.1和代码2.2所发生情况的原因,因为从每个核心运行双线程中我们没有获得任何性能提升……
  • 内存又如何呢?我们将在第4章介绍完整的核心和内存的体系结构和组织。但是,现在可以说,无论CPU拥有多少核心/线程,所有线程只能共享一个主存。所以,如果某个线程是内存不友好的,它会扰乱每个人的内存访问。这是另一种解释,即为什么线程数≥4时,性能提升会遇到一堵墙。
  • 假设我们解释了为什么我们无法在每个核心中使用双线程(称为超线程)的问题,但为什么性能提升在4线程上停止?4线程的性能比3线程的性能低,这是违反直觉的。这些线程是否无法使用所有核心?几乎每一个CPU都可以看到类似的情况,尽管确切的数字取决于最大可用线程数,并且因CPU而异。

3.5 改进imflipP

与其回答所有这些问题,不如看看在我们不知道答案,而只是猜测问题原因的情况下,我们是否可以改进程序。毕竟,我们有足够的直觉能够做出有根据的猜测。在本节中,我们将分析代码,尝试确定代码中可能导致效率低下的部分,并提出修改建议。修改完成后,我们会看到它的效果如何,并将解释它为什么起作用(或不起作用)。
从什么地方开始最好呢?如果你想提高计算机程序的性能,最好从最内层循环开始。让我们从代码2.8中的MTFlipH()函数开始。该函数读入一个像素并将其移动到另一个存储位置,一次一个字节。代码2.7中显示的MTFlipV()函数也非常相似。对于每个像素,这两个函数需要一次移动一个字节的R、G和B值。这张照片有什么问题?太多问题了!当我们在第4章中详细讨论CPU和内存架构时,你会对代码2.7和代码2.8的效率低下感到惊讶。但是现在,我们只是想找到明显的修改方法,并定量地分析这些改进。在我们在第4章中更多地了解内存/核心架构之前,我们不会对它们发表评论。

3.5.1  分析MTFlipH()中的内存访问模式

MTFlipH()函数显然是一个“存储密集型”函数。对于每个像素来说,确实没有进行“计算”,而只是将一个字节从一个存储位置移动到另一个存储位置。当我说“计算”时,我的意思是通过减小RGB的值使每个像素值变暗,或者通过重新计算每个像素的新值将图像变成B&W图像等。这里没有执行这些类似的计算。MTFlipH()的最内层循环如下所示:
image.png

所以,要改进这个程序,我们必须仔细地分析内存访问模式。图3-2显示了在处理22 MB图像dogL.bmp期间MTFlipH()函数的内存访问模式。这张小狗图片由2400行和3200列组成。当水平翻转第42行(选择这个数字没有什么特定的原因)时,像素的交换模式如下所示(如图3-2所示):
42↔42,42↔42 ... 42↔42,42↔42

image.png

3.5.2  MTFlipH()的多线程内存访问

不仅是MTFlipH()的逐字节内存访问听起来很糟糕,还要记住这个函数是在多线程环境中运行的。首先,如果我们只启动一个线程,让我们来看看单线程的内存访问模式是怎样的:这个单线程会自行翻转所有2400行,从第0行开始,继续执行第1、2、…、2399行。在这个循环中,当它处理第42行时,MTHFlip()真正交换的是哪个“字节”?我们以第一个像素交换为例。它涉及以下操作:交换pixel42和pixel42,也就是依次交换第42行的bytes[0..2]和第42行的bytes [9597..9599]。
在图3-2中,请注意每个像素对应保存该像素RGB值的3个连续字节。在一次像素交换过程中,MTFlipH()函数请求了6次内存访问,3次读取字节[0..2]和3次将它们写入在字节[9597..9599]处翻转后的像素位置。这意味着,仅为了翻转一行数据,我们的MTFlipH()函数请求了3200×6=19 200次内存访问,包括读取和写入。现在,让我们看看当4个线程启动时会发生什么。每个线程都努力完成600行数据的翻转任务。
image.png

请注意,这4个线程中的每个线程请求内存访问的频率都和单线程一样。如果每个线程的设计不当,导致混乱的内存访问请求,那它们将会产生4倍的混乱!让我们看一下执行的最初部分,main()启动所有4个线程并把MTHFlip()函数分配给它们来执行。如果我们假设这4个线程在同一时间开始执行,这就是所有4个线程在处理最初几个字节时试图同时执行的操作:
image.png

虽然每个线程的执行会有细微的变化,但并不会改变故事。当你查看这些内存访问模式时,你会看到什么?第一个线程tid=0正在尝试读取pixel0,它的值位于内存地址mem(00000000..00000002)。这是任务tid = 0的开始,处理完第0行后,它将继续处理
第1行。
当tid=0正在等待它的3个字节从存储器中读入时,恰好在同一时间,tid=1正试图读取位于存储器中第600行的第一个像素pixel600,内存地址是mem(05760000 .. 05760002),即距离第一个请求5.5 MB(兆字节)处。等一下,tid=2也没闲着。它也在努力完成自己的工作,也就是交换第1200行的整行数据。第一个要读取的像素是pixel1200,位于内存地址mem(11520000..11520002)的3个连续字节,即距离tid=0读取的3个字节11 MB处。类似地,tid=3正在尝试读取距离前3个字节16.5 MB处的3个字节……请记住总图像为22 MB,并将其处理为4个线程,每个线程负责5.5 MB的数据块
(即600行)。
当我们在第4章中学习DRAM(动态随机存取存储器)的详细内部工作机制时,我们将理解为什么这种存储器访问模式是一场灾难,但现在针对这个问题,我们可以找到一个非常简单的修复方法。对于那些渴望进入GPU世界的人来说,请允许我在这里发表一个看法,CPU和GPU中的DRAM在操作上几乎相同。因此,我们在这里学到的任何东西都可以很容易地应用到GPU内存上,但也会由于GPU的大规模并行性而导致一些例外。一个相同的“灾难性的内存访问”示例可以应用到GPU上,并且你将能够依靠在CPU世界中学到的知识立即猜出问题所在。



3.5.3  DRAM访问的规则

虽然第4章的很大一部分会用来解释为什么这些不连续的内存访问对DRAM性能不利,但解决这个问题的方法却非常简单和直观。所有你需要知道的就是表3-3中的规则,它们对于获得良好的DRAM性能是很好的指导。让我们看看这张表格并从中理解些什么。这些规则基于DRAM架构,该架构旨在允许每个CPU核心都能共享数据,并且它们都以这样或那样的方式表达同样的观点:
当你访问DRAM时,应该访问大块连续数据,例如1 KB、4 KB,而不是只访问很小的1个或2个字节……
虽然这是改进第一个并行程序imflipP.c的非常好的指导,但我们首先检查一下原来的代码是否遵守这些规则。以下是MTFlipH()函数的内存访问模式(代码2.8)的总结:

  • 明显违反粒度规则,因为我们试图一次访问一个字节。
  • 如果只有一个线程,则不会违反局部性规则。但是,若有多个不同线程同时(和远程)访问则会导致违规。
  • L1、L2、L3高速缓存对我们根本没有帮助,因为该场景下没有好的 “数据重用”。这是因为我们不需要多次使用某个数据元素。

几乎所有的规则都被违反了,因此不难理解imflipP.c的性能为什么这么糟糕了。除非我们遵守DRAM的访问规则,否则我们只会创建大量低效的内存访问,进而影响整体
性能。

image.png
image.png

3.6 imflipPM:遵循DRAM的规则

现在是通过遵循表3-3中的规则来改进imflipP.c的时候了。改进的程序名为imflipPM.c(“M”表示“内存友好”)。

3.6.1 imflipP的混乱内存访问模式

我们再来分析一下MTFlipH(),它是imflipP.c中一个内存不友好的函数。当我们读取字节并用其他字节替换时,每个像素都会单独地访问DRAM来读取它的每个字节,如下所示:
image.png

关键一点在于,由于小狗图片位于主存储器(即DRAM)中,因此每个单独的像素读取都会触发对DRAM的访问。根据表3-3,我们知道DRAM不喜欢被频繁地打扰。

3.6.2 改进imflipP的内存访问模式

如果我们将整行图像(全部3200个像素,总计9600个字节)读入一个临时区域(DRAM以外的某个区域),然后在该区域内处理它,在处 理该行期间不再打扰DRAM,这会怎样?我们将这个区域称为缓冲区。因为这个缓冲区很小,它将被高速缓存在L1$内,可以让我们很好地利用L1高速缓存。这样,至少我们现在正在使用高速缓存并遵守了表3-3中的高速缓存友好规则。
image.png

当我们将9600 B从主存传输到Buffer时,我们依赖memcpy()函数的效率,该函数由标准C语言库提供。在执行memcpy()期间,9600字节从主存储器传输到我们称为Buffer的存储区。这种访问是非常高效的,因为它只有一个连续的内存传输,遵循表3-3中的每个规则。
我们不要自欺欺人:Buffer也位于主存。然而,使用这9600个字节的方式存在巨大的差异。由于我们将连续地访问它们,因此它们将被高速缓存且不再打扰DRAM。这就是为什么访问Buffer的效率能够显著提升,并符合表3-3中的大部分规则的原因。现在让我们重新设计代码来使用Buffer。

3.6.3 MTFlipHM():内存友好的MTFlipH()

MTFlipH()函数(代码2.8中)的内存友好版本是代码3.1中的MTFlipHM()函数。除了一个较为明显的不同,它们基本一样:在对每行像素进行翻转操作时,MTFlipHM()只访问一次DRAM,它使用memcpy()函数读取大块数据(图像的一整行,比如dogL.bmp图像中的9600 B)。定义了一个16 KB的缓冲区存储数组作为局部数组变量,在代码运行到最内层循环开始进行交换像素操作之前,整行数据会被复制到该缓冲区中。我们也可以只定义9600 B的缓冲区,因为这是dogL.bmp图像需要的缓冲区大小,但较大的缓冲区可以满足其他较大图像的需要。
代码3.1:imflipPM.c MTFlipHM(){ ... }
内存友好版本且符合表3-3中各项规则的MTFlipH()(代码2.8)。
image.png


尽管两个函数中的最内层循环都是相同的,但请注意,MTFlipHM()的while循环体只访问Buffer[]数组。我们知道操作系统会在栈区为所有的局部变量分配一块区域,我将在第5章详细介绍这一点。但是现在需要注意的是该函数定义了一个16 KB大小的局部存储区域,这将使MTFlipHM()函数符合表3-3中的L1缓存规则。
以下是代码3.1的部分代码,主要显示了MTFlipHM()中的缓冲区操作。请注意,全局数组TheImage[]在DRAM中,因为它是通过ReadBMP()函数读入DRAM的(见代码2.5)。该变量应严格遵守表3-3中的DRAM规则。我认为最好的方法是一次性读取9600 B数据并将这些数据复制到本地存储区域。这使其100%的DRAM友好。
image.png
image.png


最大的问题是:为什么局部变量Buffer []能起作用?我们修改了最内层的循环,并使其按照之前访问TheImage[]的方式来访问Buffer[]数组。Buffer[]数组到底有什么不同?此外,另一个令人费解的问题是Buffer[]数组的内容将被“高速缓存”,这是从哪里体现出来的?代码中并没有暗示“将这9600个字节放入高速缓存”,我们如何确信它会进入高速缓存?答案实际上非常简单:与CPU的架构设计有关。
CPU高速缓存算法可以预测DRAM(“不好的位置”)中的哪些部分应该暂时进入高速缓存(“较好的位置”)。这些预测不需要是100%准确的,因为如果某次预测不准确,总是可以稍后再对其进行纠正。后果只不过是效率上的惩罚,而不至于造成系统崩溃或其他什么。将“最近使用的DRAM内容”引入高速缓冲存储器的这个过程称为缓存。理论上,CPU可以偷懒,将所有内容都放入缓存中,但实际上这是不可能的,因为只有少量的高速缓存可用。在i7系列处理器中,L1高速缓存为32 KB,L2高速缓存为256 KB。L1的访问速度比L2快。缓存有益于性能主要有以下三个原因:

  • 访问模式:高速缓冲存储器是SRAM(静态随机存取存储器),而不是像主存储器那样的DRAM。与表3-3中列出的DRAM效率规则相比,主导SRAM访问模式的规则要宽松得多。
  • 速度:由于SRAM比DRAM快得多,一旦某些内容被缓存,访问它们的速度就会快很多。
  • 隔离:每个核心都有自己的缓存(L1$和L2$)。因此,如果每个线程频繁地访问不多于256 KB的数据,这些数据将非常有效地缓存在核心的高速缓存中,不会再去麻烦DRAM。

我们将在第4章中详细介绍CPU核心和内存如何协同工作。但是,我们已经学到了很多关于缓冲的知识,现在可以开始改进我们的代码了。请注意,缓存对于CPU和GPU都很重要,对GPU更为突出一些。因此,理解缓冲区概念,也就是数据被缓存非常重要。尽管有一些理论研究,但目前没有办法显式地让CPU将某些指定内容载入高速缓存。它完全由CPU自动完成。但是,程序员可以通过代码的内存访问模式来影响缓存过程。在代码2.7和代码2.8中,我们亲身体验到了当内存访问模式混乱时会发生什么情况。CPU的缓存算法根本无力纠正这些混乱的模式,因为它们简单的缓存/替换算法已经彻底投降。编译器也无法纠正这些问题,因为在很多情况下,它需要编译器理解程序员的想法!唯一对性能有帮助的是程序员的逻辑。

3.6.4 MTFlipVM():内存友好的MTFlipV()

现在,让我们看一下代码3.2中重新设计的MTFlipVM()函数。我们可以看到此代码与代码2.7中低效率的MTFlipV()函数之间的一些主要差异。下面是MTFlipVM()和MTFlipV()之间的区别:

  • 改进版中使用了两个缓冲区:每个缓冲区16 KB。
  • 在最外层循环中,第一个缓冲区用于读取图像起始行的整行数据,第二个缓冲区用于读取图像终止行的整行数据。随后这两行数据被交换。
  • 尽管最外层的循环完全相同,但最内层的循环被删除,改为使用缓冲区的批量内存传输。

代码3.2:imflipPM.c MTFlipHM(){ ... }
内存友好版本且符合表3-3中各项规则的MTFlipH()(代码2.7)。
image.png

3.7 imflipPM.C的性能

使用以下命令行运行改进的程序imflipPM.c:
imflipPM InputfileName OutputfileName [v/V/h/H/w/W/i/I] [1-128]
新添加的命令行选项W和I分别用于选择使用内存友好的MTFlipVM()和MTFlipHM()函数。大写或小写无关紧要,因此选项列出W/w和I/i。原有的V和H选项仍然有效,并且分别表示调用内存不友好的函数MTFlipV()和MTFlipH()。这让我们运行一个程序就可以将两个系列的函数进行比较。
表3-4所示为改进后的程序imflipPM.c的运行时间。当我们将这些结果与相应的“内存不友好”的imflipP.c(表3-2中列出)进行比较时,我们发现所有的性能均有显著的改进。这对读者来说不足为奇,因为用一整章的内容只展现一些微小的改进,这不会使读者开心!



image.png

除了改进效果很明显,另一个很明显的地方是改进的效果会基于是垂直翻转还是水平翻转而大不相同。因此,我们不做泛泛的评论,而是选择一个示例CPU,并列出内存友好和内存不友好的结果来进行深入的研究。由于几乎每个CPU都展现出了相同的性能改进模式,因此讨论一个具有代表性的CPU不会产生误导。最好的选择是CPU5,因为它的结果更丰富并可以将分析进行扩展,而非只针对几个核心。

3.7.1 imflipP.c和imflipPM.c的性能比较

表3-5列出了imflipP.c和imflipPM.c在CPU5上的实验结果。内存友好的函数MTFlipVM()和MTFlipHM()与内存不友好的函数MTFlipV()和MTFlipH()之间的加速比(即“加速”)在新增加的一列中给出。很难做出类似“全面改善”的评论,因为这不是我们在这里看到的情况。水平方向和垂直方向的加速趋势差别很大,需要单独对它们进行评论。

image.png

3.7.2 速度提升:MTFlipV()与MTFlipVM()

首先我们来看垂直翻转函数MTFlipVM()。从表3-5中的MTFlipV()函数(“V”列)转化到MTFlipVM()函数(“W”列)时有几点值得注意:

  • 加速比会随着启动线程的数量而变化。
  • 线程数越多,加速比可能会下降(34倍降至16倍)。
  • 即使线程数超过了CPU物理上可支持的线程数(例如,9、10),加速仍会继续。

3.7.3 速度提升:MTFlipH()与MTFlipHM()

接下来,我们来看看水平翻转函数MTFlipHM()。以下是从表3-5中的MTFlipH()函数(“H”列)变化到MTFlipHM()函数(“I”列)时的观察结果:

  • 与垂直系列相比,加速比的变化要小得多。
  • 启动更多线程会稍微改变加速比,但确切的趋势很难量化。
  • 几乎可以将加速比确定为“固定值1.6”,稍微有一些小的波动。

3.7.4 理解加速:MTFlipH()与MTFlipHM()

表3-5中的内容需要一点时间来消化。只有仔细阅读第4章后才能理解正在发生的事情。但是,在本章我们可以先做一些猜测。为了能够得到有根据的猜测,我们先来看看实际情况。首先,让我们解释一下为什么垂直和水平翻转系列会有不同,尽管这两个函数最终翻转了数量完全相同的像素。
比较代码2.8中的MTFlipH()和代码3.1中的内存友好版本MTFlipHM(),我们看到的唯一区别是本地缓冲,其他的代码是相同的。换句话说,如果这两个函数之间有任何的加速,则肯定是由缓冲引起的。所以,下面这种说法是很公正的:
本地缓冲使我们能够充分利用高速缓存,这导致了1.6倍的加速。
这个数字随线程数的增加而轻微地波动。
另一方面,将代码2.7中的MTFlipV()与代码3.2中的内存友好版本MTFlipVM()进行比较,我们看到函数从核心密集型转换为存储密集型。MTFlipV()一次只处理一个字节的数据,并且保持核心的内部资源处于忙碌状态,而MTFlipVM()使用memcpy()的批量内存复制函数,并通过批量内存数据传输完成所有操作,这样有可能完全避免核心的参与。当你读取大块数据时,神奇的memcpy()函数可以从DRAM中非常高效地复制某些内容,就像我们在这里一样。这也符合表3-3中的DRAM效率规则。
如果这些说法是正确的,为什么提速现象会饱和?换句话说,当我们启动更多的线程时,为什么会得到更低的加速比?看起来不管线程数是多少,程序执行时间不会低于大约1.5倍的加速比。直觉上,这可以解释如下:




  • 当程序是存储密集型时,其性能将严格地由内存的带宽决定。
  • 我们似乎在大约4个线程时就达到了内存带宽的极限。

3.8 进程内存映像

在命令行提示符启动以下程序会发生什么?
imflipPM dogL.bmp Output.bmp V 4
首先,我们请求启动可执行程序imflipPM(或Windows中的imflipPM.exe)。为了启动这个程序(即开始执行),操作系统创建一个进程并为其分配一个进程ID。当这个程序执行时,它需要三个不同的内存区域:

  • 栈,用于存储函数调用时的返回地址和参数,包括传递给函数的参数,或从函数返回的参数。该区域自上而下(从高地址到低地址)增长,这是所有微处理器使用栈的方式。
  • 堆,用于存放使用malloc()函数动态分配的内存内容。该内存区域沿着与栈相反的方向增长,以便操作系统使用每个可能的内存字节而不会与栈区冲突。
  • 代码区,用于存储程序代码和程序中声明的常量。代码区是不能被修改的。程序中的常量存储在这里,因为它们也不需要被修改。

操作系统创建的进程内存映像如图3-3所示:首先,由于程序只启动了运行main()的单个线程,因此内存映像如图3-3(左)所示。当用pthread_create()启动了四个线程后,内存映像如图3-3(右)所示。即使操作系统决定将某个线程替换出去以允许另一个线程运行(即上下文切换),该线程的栈也会被保存。线程的上下文信息保存在同一片内存区域。此外,代码位于内存空间的底部,所有线程共享的堆区位于代码区之上。当线程经过调度得以重新运行,并完成上下文切换后,这些是它恢复工作所需要的全部内容。
第一次启动imflipPM.c时,操作系统不知道栈和堆的大小。这些都有默认的设置,你也可以修改这些默认设置。Unix和Mac OS在命令提示符下使用参数来设定,而Windows通过单击右键修改应用程序属性来更改。由于程序员是最了解一个程序需要多少堆和栈的,应该给应用程序分配足够多的栈和堆区,以避免因无效内存地址访问而发生核心崩溃,这种情况大多由于内存的不同区域产生冲突而导致访问无效的内存地址。
让我们再看看我们最喜欢的图2-1,它显示了1499个启动的线程和98个进程。这意味着操作系统内部启动的许多进程甚至所有线程都是多线程的,此时的内存映像类似于图3-3。每个进程平均启动了15个线程,这些线程的活跃度都比较低。我们看到当5、6个线程在一段时间内超级活跃时会发生什么。在图2-1中,如果所有的1499个线程的活跃度都像迄今为止我们所写的线程那样高,那么你的CPU可能会窒息,你甚至无法在计算机上移动鼠标。


image.png

当涉及1499个线程时,还有一点需要注意:操作系统编写人员必须尽可能地将他们的线程设计得“瘦”一些,以避免操作系统影响应用程序的性能。换句话说,如果任何一个操作系统的线程在从就绪状态变为运行状态时产生过多的干扰,那么它们将使一些核心资源的负担过重,当你的线程与操作系统线程同时处于运行状态时,超线程机制将不能有效地工作。当然,并不是每项任务都可以被设计得非常“瘦”,刚才我对操作系统的描述也有一定的局限性。另一方面,应用程序的设计者也应该注意尽量使应用程序的线程“瘦”一些。我们只会简单地介绍这一点,因为本书不是一本关于CPU并行编程的书,而是一本关于GPU的书。当然,当我有机会介绍如何使CPU线程变得更“瘦”时,我会在书中阐述。

3.9 英特尔MIC架构:Xeon Phi

集成众核(Many Integrated Core,MIC)是一个与GPU类似的非常有趣的并行计算平台,它是英特尔为了与Nvidia和AMD GPU架构竞争而推出的一种架构。型号名为Xeon Phi的MIC体系结构包含很多与x86兼容的inO核心,这些核心可以运行的线程比Intel Core i7 OoO体系结构中标准的每核心两线程更多。例如,我将要测试的Xeon Phi 5110P处理器包含60个核心和4个线程/核心。因此,它能够执行240个并发线程。
与Core i7 CPU核心的工作频率接近4 GHz相比,每个Xeon Phi核心仅工作在1.053 GHz,大约慢了近4倍。为了弥补这个不足,Xeon Phi架构采用了30 MB的高速缓存,它有16个内存通道,而不是现代core i7处理器中的4个,并且它引入了320 GBps的内存带宽,比Core i7的内存带宽高出5~10倍。此外,它还有一个512位的向量引擎,每个时钟周期能够执行8个双精度浮点运算。因此,它具有非常高的TFLOP(Tera-Floating Point Operating)处理能力。与其将Xeon Phi看作CPU,将其归类为吞吐量引擎更为合适,该引擎旨在以非常高的速度处理大量数据(特别是科学数据)。
Xeon Phi设备的使用方式通常有以下两种:

  • 当使用OpenCL语言时,它“几乎”可以被当作GPU来使用。在这种操作模式下,Xeon Phi将被视为CPU的外部设备,即一个通过I/O总线(在我们的例子中是PCI Express)连接到CPU的设备。
  • 当使用自己的编译器icc时,它“几乎”可以被当作CPU来使用。编译完成后,你可以远程连接到mic0(即连接到Xeon Phi中轻量级操作系统),然后在mic0中运行代码。在这种操作模式下,Xeon Phi仍然是一种拥有自己的操作系统的设备,因此必须将数据从CPU传输到Xeon的工作区。该传输使用Unix命令完成,scp(安全复制)命令将数据从主机传输到Xeon Phi。

以下是在Xeon Phi上编译和执行imflipPM.c以获得表3-6中的性能数据的命令:
image.png

imflipPM.c程序在Xeon Phi 5110P上运行的性能结果如表3-6所示。虽然从几个线程到多达16或32个线程,性能都有较好的提升,但性能提升的上限为32个线程。启动64个线程不会提供额外的性能改进。主要是因为我们的imflipPM.c程序中的线程太“胖”,以致无法充分利用每个核心中的多个线程。

image.png

3.10 GPU是怎样的

现在我们已经了解了CPU并行编程的故事,GPU又是怎样的呢?我可以保证我们在CPU世界中学到的所有东西都适用于GPU世界。现在,想象你有一个可以运行1000个或更多核心/线程的CPU。这就是最简单化的GPU故事。但是,如你所见,继续增加线程数并不容易,因为性能最终会在某个点之后停止提升。所以,GPU不仅仅是一个拥有数千核心的CPU。GPU内部必须进行重大的架构改进,以消除本章刚刚讨论的各种核心和内存瓶颈问题。此外,即使在架构改进之后,GPU程序员也需要承担更多的责任以确保程序不会遇到这些瓶颈。
本书的第一部分致力于理解什么是“并行思维”,实际上这还不够。你必须开始思考“大规模并行”。当我们在前面所示的例子中有2个、4个或8个线程运行时,调整执行的顺序以便每个线程都能发挥作用并不是一件难事。但是,在GPU世界中,你将处理数千个线程。要理解如何在疯狂的并行世界中思考问题应该首先学习如何合理地调度2个线程!这就是为什么讲解CPU环境非常适合用来对学习并行性进行热身的原因,也是本书的理念。当你完成本书的第一部分时,你不仅学会了CPU的并行,而且也完全准备好去接受本书第二部分中介绍的GPU大规模并行。
如果你仍然没有信服,那么我可以告诉你:GPU实际上可以支持数十万个线程,而不仅仅是数千个线程!相不相信?就好比IBM这样拥有数十万名员工的公司可以像只有1或2名员工的公司一样运行,而且IBM能够从中获益。但是,大规模并行程序需要极端严格的纪律和系统论方法。这就是GPU编程的全部内容。如果你已迫不及待地想去第二部分学习GPU编程,那么现在你就可以开始尝试。但是,除非你已经理解了第一部分介绍的概念,否则总会错过某些东西。
GPU的存在给我们提供了强大的计算能力。比同类CPU程序速度快10倍的GPU程序要比仅快5倍的GPU程序好。如果有人可以重写这个GPU程序,并把速度提高20倍,那么这个人就是国王(或女王)。编写GPU程序的目标就是高速,否则没有任何意义。GPU程序中有三件事很重要:速度,速度,还是速度!所以,本书的目标是让你成为编写超快GPU代码的GPU程序员。实现这个目标很难,除非我们系统地学习每一个重要的概念,这样当我们在程序中遇到一些奇怪的瓶颈问题时,可以解释并解决这些瓶颈问题。否则,如果你打算编写较慢的GPU代码,那么不妨花时间学习更好的CPU多线程技术,因为除非你的代码希望竭尽所能地提高速度,否则使用GPU没有任何意义。这就是为什么在整本书中我们都会统计代码运行的时间,并且找到让GPU代码更快的方法。


3.11 本章小结

现在我们基本了解了如何编写高效的多线程代码。下一步该做什么呢?首先,我们需要能够量化本章中讨论的所有内容:什么是内存带宽?核心如何真正运行?核心如何从内存中获取数据?线程如何共享数据?如果不了解这些问题,我们只能猜测为什么速度会有提升。
现在是量化所有这些问题并全面了解架构的时候了。这就是第4章将要介绍的内容。同样,尽管会有明显的不同,我们学到的所有CPU内容都可以很容易地应用到GPU世界,我将在不同之处出现时加以说明。

Leave a Reply

Your email address will not be published. Required fields are marked *