并行算法设计与性能优化 刘文志 第1章 绪论

各厂商纷纷通过各种方式提升计算能力,如提高指令级并行能力,在一个时钟周期内执行更多指令、向量指令、多核和超线程技术等。从长远来看,最有可能引领未来的是向量化和多核技术:向量化是指使用同一条指令同时操作多个数据;多核技术是采用在同一个芯片上集成多个核心的办法。

高端的服务器版本则会集成多个多核处理器,这称为多路。

通过将几百,几千核心集成在一块硅片上以满足图形图像及视频对性能的需求,这称为众核。

1.1 并行和向量化的作用

并行和向量化的首要作用是尽量发挥硬件提供的全部计算能力,以减少延迟(更快地完成计算任务)或提高吞吐量(在相同的时间内完成更多的任务)。

为了利用多核向量处理器提供的全部计算能力,必须采用向量化和并行化的思维方式编写软件代码,而现有的串行软件必须修改才能利用多核向量处理器。

并行/并发的另一个作用是实现功能和同时满足多个用户的请求。

在科学计算领域,物理模拟需要长时间的运行获得更精确的结果,并行化和向量化主要提供下列两个功能:

让程序运算的更快,以节约时间。

让程序能够计算更大规模的体系。

1.2 为什么要并行或向量化

除了现实世界中的硬件已经完全是并行硬件外,还有几个原因要求我们必须使用并行或/向量化:现有的编译器不能很好地自动向量化或并行串行软件。

20世纪80年代中期,基于依赖分析的自动向量化工具已经基本可用。现代的GCC能够向量化和并行一些非常简单的代码。

实际上,自动向量化或并行的主要难点在于编译器没有办法收集/分析向量化或并行所需的数据相关性和控制相关性等消息,必须需要程序开发人员干预。

现在研究重点转向基于编程语言的策略研究,即从开发人员那里获得更多有关逻辑控制和数据相关性的描述,同时利用自动向量化或并行技术来减轻程序设计的负担。

1.3 为什么向量化或并行难

向量化或并行编程方式要求开发人员显示地编码以处理多核向量代码中向量内的多个元素,多个控制流之间的依赖关系。

另外,向量化或并行编译器的低能已经向量化或并行的调试工具的匮乏也增加了并行化与向量化编程的难度。

笔者认为向量化或并行化难的主要技术原因有以下几个:

        没有很好的设计哲学:向量化与并行的本质在某种意义上核现行的面向对象程序设计方法学冲突;

        遗留代码:过去几十年积累的代码是企业的巨大财富,没有人会放弃;

        可扩展性;如果代码能发挥4核,16核?是否能够线性扩展到成百上千的核心?

        可维护性:向量化或并行代码的可读性通常不入标量串行代码,如何在原来的开发人员离岗后,接管的开发人员也能够维护;

        任务/数据划分:并行意味着多个控制流同时执行,而向量化意味着同时操作多个数据,并行需要在各个控制流之间划分任务和数据并去除依赖,向量化则需要处理向量内要处理的数据的依赖关系。数据/任务的划分方式不但决定了编程时的难易程度,而且划分带来的负载均衡和通信问题往往也会对程序的最终性能产生决定性的影响;

        并发访问控制:多个控制流需要访问不同的或相同的资源,如何协调对这些资源的访问就变得非常重要,这也成为并行编程的一大难点。

        资源划分:资源划分不但关系到编程的难易,还关系到最终的性能;

        与硬件的交互:为了最好地发挥性能,软件开发人员通常会应用硬件的特性。

        对软件人员的过高要求,开发工具不够智能,且市场不愿付出相应的薪水;

1. 并行程序设计方法学

        面向对象设计方法学完全没有考虑到向量化或必须必须考虑取出的数据和控制的依赖关系,而对象之间的通信本质来说是一种依赖关系。

        对于向量化或并行来说,最合适的设计方法应当是过程化的设计方法加上以数据为中心。

        通过过程化的设计方法来设计程序流程,以数据为中心来向量化或并行已经是主流的设计方法。

        如果程序的热点很集中,那么只需要采用过程化的方法加上以数据为中心的方法并行化热点即可;

        如果程序的热点很分散,那么很可能需要重新设计软件。

2. 遗留代码

        一些大的软件项目拥有成千上万,甚至百万行代码,而通常大项目对性能的要求又更为急迫。如何向量化或并行这些代码却比较难,因为向量化或并行的难度和代码的长度成线性关系,而且当前维护这些代码的人员通常并非是原始的开发人员。

        对于遗留代码来说,基于编译制导的编译器(OpenMP和OpenACC)会是一个比较安全的选择。

        编译制导方法基于原来的串行代码,加入指导向量化和并行的伪指令语句。

3. 可扩展性

        Amdal定律告诉我们,在计算规模的一定条件下,只需有不能并行的步伐,程序是不太可能获得完全线性加速的。

        可扩展性的问题基本上没有解决方法,因为开发人员不能完全正确的预测在目前还不存在的硬件上的问题。通常的缓解办法是要求在开发项目时,留下足够的设计文档,使源码有足够的、准确的注释。

4. 可维护性

        一些项目同时维护一个标量串行版本和一个向量化并行版本,问题在于如何使得两者保持一致。

5. 任务/数据划分

        任务/数据需要十分小心,划分方式不但影响编程的难易,还影响程序的最终的性能。比如,不均匀的划分会导致负载不均衡。(负载均衡用于在控制流之间重新分配任务/数据,以获得更好的性能)。另外,划分可能会导致某些全局处理变得复杂,此时可能需要同步,以安全地处理某些全局数据。

        根据任务和数据划分方式地不同,可将并行编程划分为不同的编程模式。

        通常划分后各个控制流之间需要一些通信(易并行可能无需通信)。由于通信会引起开销,不成熟的划分方式可能会使得通信的开销过大而导致性能的极端下降。

        基于CPU的并行编程中,控制流的数量必须加以控制,因为每个控制流都会占用一些资源,比如缓存、虚拟存储器。如果过多的控制流同时在一个处理器核心上执行,那么每个控制流使用的资源数量就会减少,这可能会引起缓存命中率过低,从而降低性能。另一方面,大量的线程可能会带来大量的冗余计算和IO操作。

        最后,并行会大量增加程序的状态空间,导致人脑难以理解,降低生产率。

6. 并发访问控制

        并行程序的多个控制流需要协调对某个资源的访问。

        基于消息传递的编程模式允许各控制流拥有自己的存储器内容,此时数据的交流通过传递消息实现。需要注意由于资源访问导致的死锁,活锁,饿死等问题。

        基于共享存储器的编程模式只有一个存储器空间,这样各个控制流访问同一存储其地址时就有可能产生冲突,常见的有读后写,写后读和写后写等问题。这类问题通常互斥资源的访问解决。

        并行编程中,最常见的并发访问控制是文件,如果多个控制流同时读一个文件,那么就有可能读到错误的数据,常见的解决方法有:

                由一个控制流读取文件,然后分发数据;(编程简单,分发数据操作完全串行,导致过大性能开销,另一种则相反)

                将文件分为多个子文件,每个控制流读取字节的文件。

        对于并发资源的访问,比较明智的做法是将访问分为读和写。

7. 资源划分

        如何在不同的控制流之间分配计算资源一直是并行的难题,这往往和负载均衡关联,如果分配给某个控制流的资源多,就可能让其他的控制流等待它计算完成。在X86机器上,这往往只涉及内存、共享文件的划分。在GPU上,这个问题往往更加复杂。

        资源划分与并发访问控制、通信密切相关,好的资源划分方式能够即减少通信又保证资源访问的局部性,这通常意味着优秀的性能和可扩展性。

        资源划分通常依赖于应用,数值计算频繁的矩阵,列或子矩阵进行划分。

        资源划分是常见的向量化和并行化方式。

8. 与硬件交互

        向量化或并行编程要求软件开发人员对机器的配置比较了解,只有这样才能避开硬件的缺陷,编写出高效的代码。

        涉及新的硬件特性时,经常需要直接与这些硬件打交道,当需要榨取系统的最后一点性能时,通常需要直接访问硬件。

        由于不同的硬件的设计方法,发挥硬件性能的编程方式及硬件设计上容易造成性能瓶颈的地方都不相同,这些因素可能会导致在某一硬件上性能很好的算法,在另外的硬件上性能却很差。

        基于多机系统编程时,网络的拓扑结构和网线的传输速度非常重要;基于多核编程时,核心和缓存之间的组织比较重要(这个方面经常出现的时伪共享问题);基于GPU编程时,GPU硬件的组织更为重要,如核心之间缓存的组织,DRAM的组织、核心的组织以及程序如何映射到硬件上执行。

9. 对软件开发人员的要求

        目前编译器及开发环境对向量化或并行的支持能力比较差,主要包括以下三个方面:

                只能自动向量化一些简单的代码,即使能自动向量化代码,通常也不是最优的。

                只能自动并行化简单代码,编译器在自动并行化方面做的通常比自动向量化要差;

                不能找出并行冲突的地方;

                不能协调资源的访问;

        软件开发人员需要自己发掘应用的并行性,并且处理共享资源的访问冲突。另外由于不同的并行化方法可能利用了硬件/软件不同的特性,因此其性能更难以把握。

        目前调试器对向量化或并行的支持非常差且不可靠,软件开发人员缺乏工具导致生产率上不去,这就导致了雇主不愿使用并行开发。

1.4 并行的替代方法

        也有一些方法像并行一样发挥多核处理器性能:

                运行同一程序的多个实例;

                利用已有的并行库;

                优化串行程序;

1. 运行同一程序的多个实例

        如果需要计算同一条件下在不同数据集的效果,或者要计算同一数据集在不同的配置条件下的运行结果。

        多个不同的实例就可以同时占用多个核心。

2. 利用已有的并行库

        目前已经有许多函数库实现了并行,如Intel的MKL,IPP,TBB以及NVIDIA开发的基于其CUDA计算环境的CUDNN,NPP,CUFFT,CUBLAS等。

        使用已有的并行库通常比自行编写并行代码要便利,但是要避免由于使用不当,导致性能反而不如标量串行代码的情形。

3. 优化串行程序

        优化标量串行代码程序通常应当在向量化或并行之前进行,结果通常会更吸引人。

        相比于向量化和并行,优化串行代码在操作上通常更容易实现,代码的可扩展性和可维护性通常也更好。

        算法的改进获得的性能提升可能是指数级,而向量化或并行带来的性能提升通常和向量的长度及核心的数量成正比。

1.5 进程、线程与处理器

        现代处理器和进程、线程两个概念紧密关联。进程的概念简化了程序设计、存储器管理等,并且提供了一种大粒度并行的方法。线程存在进程之中,进程中的所有线程共享进程的资源并且独享某些资源,因此更易于通信。

        在实践中,进程可调度到一台机器中的一个或多个处理器核心上执行,而线程会调度到一个核心上执行,向量化的代码则会映射到一个核心内的向量单元上执行。由于操作系统调度策略的不同,并不能保证进程和线程会一直在相同的核心上执行。

        由于分布式计算的各节点有其独立的存储器,因此基于进程的消息传递通信更适合,而多核等由于共享存储器,所以基于线程的共享存储器更易于通信。

1. 进程

        进程是对操作系统正在运行的程序的一种抽象。多个进程可以并发运行在一个核心上,通过时间片轮转,就好像进程一直在使用核心。系统内的多个进程通过时间片轮转并发执行,这就要求有一种机制能够在时间片到期时保存正在运行的进程的状态,并运行另一个等待运行的进程。这种保存一个进程的状态切换到另一个进程的过程称为“上下文切换”。

        上下文是指保存进程运行所需要的寄存器、缓存和DRAM等资源。在任何一个无限精度的时刻,一个处理器核心最多只能运行一个进程或一个线程,当操作系统需要在某个核心上运行另一个进程时,就会进行上下文切换。

        通过上下文切换进程获得了并发的特性。而运行在多个核心上的多个进程又获得了并行的特征,由于进程的上下文切换和通信相比比较耗时,因此基于进程的并发往往只适合大粒度的任务并行。

        进程和程序有关系也有不同,程序是静态指令的集合,进程是程序正在运行的状态。进程是资源拥有的独立单位,不同的进程拥有不同的虚拟地址空间,不能够直接访问其他进程的上下文资源。

2. 线程

        进程之中可以有许多线程,这些线程共享进程的上下文,如虚拟地址空间和文件,但是独立执行并且可通过存储器进行通信。当进程终止时,进程内的所有线程也会同时终止。另外线程也有其私有逻辑寄存器、栈和指令指针OC。

        由于线程共享进程资源,因此线程的建立、销毁比进程的建立,销毁更高效,在多核处理器上逐渐比进程更引人注目。

        由于进程的存储器资源时独立的,而线程的存储器资源是共享的,因此通常基于进程的并行编程更简单,但是基于线程的并行在多核处理器上通常更高效。

        在节点间使用进程级并行,在节点内的多核上使用线程级并行,这称为混合或超级并行。

        目前基于GPU的并行编程也使用基于线程的开发环境,这是一种“硬件多线程”,其线程的创建、调度和销毁开销接近0.

        多线程程序在多核和单核上执行时具有明显的差别。由于在单核上多线程通过分时共享执行,这使得一些长延迟的操作如锁,IO访问不会导致核心空闲。

3. 超线程

        Intel的一些高端机器支持称为“超线程(Hyper-Threading)”的技术,超线程通过双倍增加一些资源(PC和寄存器)来减少线程的切换代价,但是只有一份执行单元,因此其峰值计算能力并没有提高。

        超线程技术将一个物理处理器模拟成两个逻辑核,可并行执行两个线程,能够在单个时钟周期内在两个线程间切换,让单核都能使用线程级并行计算,减少了CPU的闲置时间,提高CPU的运行效率。

4. 阻塞和同步

        在并行编程中,进程或线程的阻塞和非阻塞,同步或异步是非常常见的名词。

        具体地说,阻塞是相对于进程或线程本身而言地,如果一个操作并不阻碍进程或线程,接着会执行代码,称这个操作为“非阻塞”,反之则为“阻塞”。

        非阻塞要求开发人员手动保证操作地完成,这可能会带来数据一致性问题。

        同步或异步则是针对通信地多个进程或线程,如果一个进程或线程与其他线程与线程通信时,不需要其他线程做好准备,称之为“异步”,反之则为“同步”。

        阻塞和同步地具体含义可能会依据不同地编程环境、语言等有微小地不同。

1.6 并行硬件平台

1. 机群

        通过使用网线依据某种拓扑方式将多台微机互联以获取更大地计算能力,这种系统通常称为集群,而每台微机称为节点。

        通常机群通过TCP/IP协议通信,使用物理网络互联,为了提高通信效率,一些超级计算机也会使用特有地协议和网络硬件。

        目前常用于机群通信的网线主要有万兆以太网和InfiniBand网卡。传输速度远远低于处理器处理速度,极易在计算时形成瓶颈。

        将多台机器连接在一起的方式称为网络互联,目前流行的互连方式有星形,环形、树形、网络和超立方体。

        使用MPI在机群系统上编程时,需要提前处理好输入数据和输出数据在节点间的分布,以利用多个节点能提供的带宽。

2. x86多核向量处理器

        多核向量处理器指将两个或更多独立单核向量处理器封装在一个集成电路芯片中。多核向量处理器即可以执行向量运算,又可以执行线程级并行处理。

(1) X86多核

        通常第一级缓存时多核处理器的一个核心独享的,而最后一级缓存是多核处理器的所有核心共享的,大多数多核处理器的中间各层也是独享的。

        每个核心具有独立的一极数据缓存和指令缓存、统一的二级缓存,并且所有的核心共享统一的三级缓存。

        由于共享了LLC(Last Level Cache),因此多线程或多进程程序在多核处理器上运行时,平均每个线程或进程占用LLC缓存比使用单线程时要小,这使得某些LLC或内存闲置的应用的可扩展性看起来没那么好。

        由于多核处理器的每个核心都有独立的一极缓存,有时还有独立的二级缓存,使用多线程/多进程程序时可利用这些每个核心独享的缓存,这是超线性加速的原因之一。

        硬件生产商还将多个多个多核芯片封装在一起,称之为多路,多路之间以一种介于共享和独享之间的方式访问内存。由于多路之间缺乏缓存,因此其通信代价通常不比DRAM低。一些多核也将内存控制器封装进多核之中,直接和内存相连接,以提供更高的带宽。

        多路上还有两个和内存呢访问相关的概念:UMA(Unity Memory Access)和NUMA(Non Unity Memory Access)。UMA是指多个核心访问内存中任何一个位置的延迟是一样的。NUMA与UMA相对,核心访问离其近(指访问时要经过的中间节点数量少)的内存,其延迟要低。如果程序的局部性很好,应当开启硬件的NUMA支持。

        发挥多核处理器多个核心的编程方式通常时使用OpenMP和pthread等线程级并行工具,容易产生的性能问题主要是伪共享和负载均衡。

(2) X86指令

        SSE是X86多核向量处理器支持的向量指令,具有16个长度为128位的向量寄存器,处理器能够同时操作向量寄存器的中的16个字节,因此具有更高的带宽和计算性能。AVX将SSE的向量长度延长到256位,并支持浮点乘法。在不久的将来,Intel会将向量长度增加到512位。由于采用显示的SIMD编程模型,SSE/AVX的使用比较困难,范围比较有限,使用其编程时一个痛苦的事情。

        MIC时Intel的众核架构,它大约拥有60个X86核,每个核心包括向量单元核标量单元。向量单元包括32个长度位512位的向量寄存器,支持16个32位或8个64位的同时运算。目前MIC的核是按序的,因此其性能优化方法核基于乱序执行的X86处理器核心有很大不同。

        为了减少使用SIMD指令的复杂度,Intel寄希望于编译器,实际上Intel的编译器向量化能力非常不错,但是通常手工写的向量代码性能会更好。在MIC编程时,软件开发人员的工作由显示的使用向量化指令转化为写C代码核增加编译器制导语句以让编译器产生更好的向量指令。

        要发挥X86向量处理器的向量计算能力,可以使用三种编程方式:

                1) 使用串行C语言,让编译器产生向量指令,或使用编译器制导语句(如OpenMP4.0).这种方式最为简单,代码的可移植性通常也是最好的,但是给予软件开发人员的控制力最差,通常能够发挥的性能也最差。

                2) 使用Intel规定的内置函数。使用这种方式需要软件开发人员显示地,使用C函数来指定如何向量化操作,使用向量指令来加载数据。使用内置函数时,需要注意哪些内置函数被处理器支持,那些不被支持(尤其在开发机和线上机架构不同地情况下)。

                3) 使用汇编语言。当编译器生成了不够优化或不需要地指令时,就需要使用汇编语言来榨取系统地最后一点语言。使用汇编语言编程相当不便,代码也难以调试,故应当作为不得已地选择。

(3) GPU+CPU

        GPU自面对SIMD,且数据处理地运算量远远大于数据调度和传输地需要时,GPU是一个很好地选择。

        GPU地编程能力还不够强,因此必须对GPU特点有详细地了解,知道那些能做,那些不能做,才不会出现在项目开发中发觉一个功能无法实现或实现后性能很差,从而导致显目中止的情况。

        由于GPU将更大比例的晶体管用于计算,相对来说用于缓存的比例就比CPU小,因此通常局部性满足CPU要求而不满足GPU要求的应用不适合GPU。由于GPU通过大量线程的并行来隐藏访问延迟,一些数据局部性非常差的应用反而能够在GPU上获得很好的收益。另外一些计算访存比的应用在GPU上很难获得非常高的性能收益,但是这并不意味着在GPU实现会比在CPU上实现差。

        那些需要在GPU和CPU上进行大量、频繁数据交互的解决方案可能不适合在GPU上实现。

(4) 移动设备ARM

        现在移动设备主要使用基于ARM的处理器。arm支持的向量指令集称为NEON。NEON具有16个长度为128的向量寄存器(这些寄存器以q开头,也可以表示为32个64位寄存器,以d开头)。

1.7 向量化和多核技术不是万能的

        由发热和热量消耗带来的问题,使得硬件生产商采用向量化和多核技术,并且宣称向量化和多核技术延长了摩尔定律。但是该解决方案带来了一个棘手的软件问题,如何编程以发挥向量多核处理器的计算能力。

        人脑很难将算法划分为几百,几千个控制流,因为人脑很难维护这些控制流的状态空间。

        抽象是软件开发的有效技术。但是,编写并行程序的时候,抽象就不太好用。抽象要求隐藏程序处理的数据对象,而并行必须要处理数据的依赖关系,两者之间,存在天然的冲突。

1.8 本章小结

        并行和性能优化的两大方面:

                1. 发挥硬件的计算性能,提升程序的吞吐量,增加计算规模或减少计算时间;

                2. 为了满足某些功能性需求;

        为什么需要向量化和并行:

                1. 依靠硬件厂商提升性能的免费时代已经结束;

                2. 自动向量化工具以及可用,但只能向量化简单代码,并且性能往往并不理想;

                3. 应用对性能的需求依旧在提高,软件开发人员没有选择;

        并行和代码性能优化面临的现实困难:

                1. 没有很好的向量化和并行设计方法学;

                2. 向量化和并行遗留代码并不容易;

                3. 向量化或并行代码的可扩展性和可维护性差;

                4. 任务、数据划分及并发访问控制难;

                5. 对开发人员过高的要求;

        一些不修改代码也能利用多核处理器的性能的几种替代方法:

                1. 同时运行程序的多个实例;

                2. 利用已有的并行库;

                3. 优化串行代码;

        目前主流的向量和多核计算平台:X86 CPU、GPU和ARM。

        

        

你可能感兴趣的:(并行算法设计与性能优化,性能优化,并行计算,c++)