大部分转载自:laixintao, 并对部分内容做了修改。
对于初次接触并行计算的程序员来说,了解电脑的系统架构和程序的设计模型十分重要,本文将从以上两个方面来讨论计算机的并行设计。Python是一门易扩展和上手的编程语言,后续代码以Python为例。
所以,对我们来说充分利用计算资源就显得至关重要,例如并行计算的程序、技术和工具等。
根据指令的同时执行和数据的同时执行,计算机系统可以分成以下四类:
单处理器,单数据 (SISD)
单处理器,多数据 (SIMD)
多处理器,单数据 (MISD)
多处理器,多数据 (MIMD)
单处理器单数据就是“单CPU的机器”,它在单一的数据流上执行指令。在SISD中,指令被顺序地执行。
对于每一个“CPU时钟”,CPU按照下面的顺序执行:
Fetch: CPU 从一片内存区域中(寄存器)获得数据和指令
Decode: CPU对指令进行解码
Execute: 该执行在数据上执行,将结果保存在另一个寄存器中
当Execute阶段完成之后,CPU回到步骤1准备执行下一个时钟循环。
运行在这些计算机上的算法是顺序执行的(连续的),不存在任何并行。只有一个CPU的硬件系统就是SISD的例子。
这种架构(冯·诺依曼体系)的主要元素有以下:
中心内存单元:存储指令和数据
CPU:用于从内存单元获得指令/数据,对指令解码并顺序执行它们
I/O系统:程序的输入和输出流
传统的单处理器计算机都是经典的SISD系统。
这种模型中,有n个处理器,每一个都有自己的控制单元,共享同一个内存单元。在每一个CPU时钟中,从内存获得的数据会被所有的处理器同时处理,每一个处理器按照自己的控制单元发送的指令处理。在这种情况下,并行实际上是指令层面的并行,多个指令在相同的数据上操作。能够合理利用这种架构的问题模型比较特殊,例如数据加密等。因此,MISD在现实中并没有很多用武之地,更多的是作为一个抽象模型的存在。
SIMD计算机包括多个独立的处理器,每一个都有自己的局部内存,可以用来存储数据。所有的处理器都在单一指令流下工作;具体说,就是有n个数据流,每个处理器处理一个。所有的处理器同时处理每一步,在不同的数据上执行相同的指令。这是一个数据并行的例子。SIMD架构比MISD架构要实用的多。很多问题都可以用SIMD计算机的架构来解决。这种架构另一个有趣的特性是,这种架构的算法非常好设计,分析和实现。限制是,只有可以被分解成很多个小问题(小问题之间要独立,可以不分先后顺序被相同的指令执行)的问题才可以用这种架构解决。很多超级计算机就是使用这架构设计出来的。例如Connection Machine(1985年的 Thinking Machine)和MPP(NASA-1983).我们在第六章 GPU Python编程中会接触到高级的现代图形处理器(GPU),这种处理器就是内置了很多个SIMD处理单元,使这种架构在今天应用非常广泛。
这种计算机是最广泛使用、也是最强大的一个种类。这种架构有n个处理器,n个指令流,n个数据流。每一个处理器都有自己的控制单元和局部内存,让MIMD架构比SIMD架构的计算能力更强。每一个处理器都在独立的控制单元分配的指令流下工作;因此,处理器可以在不同的数据上运行不同的程序,这样可以解决完全不同的子问题甚至是单一的大问题。在MIMD中,架构是通过线程或进程层面的并行来实现的,这也意味着处理器一般是异步工作的。这种类型的计算机通常用来解决那些没有统一结构、无法用SIMD来解决的问题。如今,很多计算机都应用了这中间架构,例如超级计算机,计算机网络等。然而,有一个问题不得不考虑:异步的算法非常难设计、分析和实现。
内存管理是并行架构需要考虑的另一方面,确切来说是获得数据的方式。无论处理单元多快,如果内存提供指令和数据的速度跟不上,系统性能也不会得到提升。制约内存达到处理器速度级别的响应时间的主要因素是内存存取周期。所谓存取周期就是连续启动两次读或写操作所需间隔的最小时间。处理器的周期通常比内存周期短得多。当处理器传送数据到内存或从内存中获取数据时,内存依旧在一个周期中,其他任何设备(I/O控制器,处理器)都不能使用内存,因为内存必须先对上一个请求作出响应。
为了解决 MIMD 架构访问内存的问题,业界提出了两种内存管理系统。第一种就是人们所熟知的共享内存系统,共享内存系统有大量的虚拟内存空间,而且各个处理器对内存中的数据和指令拥有平等的访问权限。另外一种类型是分布式内存模型,在这种内存模型中,每个处理器都有自己专属的内存,其他处理器都不能访问。共享内存和分布式内存的区别以处理器的角度来说就是内存和虚拟内存体系的不同。每个系统的内存都会分为能独立访问的不同部分。共享内存系统和分布式内存系统的处理单元管理内存访问的方式也不相同。 load R0,i
指令意味着将 i
内存单元的内容加载进R0
寄存器,但内存管理方式的不同,处理器的处理方式也不尽相同。在共享内存的系统中,i
代表的是内存的全局地址,对系统中的所有处理器来说都指向同一块内存空间。如果两个处理器想同时执行该内存中的指令,它们会向 R0
寄存器载入相同的内容。在分布式内存系统中,i
是局部地址,如果两个处理器同时执行向 R0
载入内容的语句,执行结束之后,不同处理器 R0
寄存器中的值一般情况下是不一样的,因为每个处理器对应的内存单元中的i
代表的全局地址不一样。对于程序员来说,必须准确的区分共享内存和分布式内存,因为在并行编程中需要考量内存管理方式来决定进程或线程间通讯的方式。对于共享内存系统来说,共享内存能够在内存中构建数据结构并在子进程间通过引用直接访问该数据结构。而对于分布式内存系统来说,必须在每个局部内存保存共享数据的副本。一个处理器会向其他处理器发送含有共享数据的消息从而创建数据副本。这使得分布式内存管理有一个显而易见的缺点,那就是,如果要发送的消息太大,发送过程会耗费相对较长的时间。
下图展示了共享内存多处理器系统的架构,这里只展示了各部件之间简单的物理连接。总线结构允许任意数量的设备共享一个通道。总线协议最初设计是让单处理器,一个或多个磁盘和磁带控制器通过共享内存进行通讯。可以注意到处理器拥有各自的Cache,Cache中保存着局部内存中有可能被处理器使用的指令或数据。可以想象一下,当一个处理器修改了内存中的数据,同时另外一个处理器正在使用这个数据时,就会出现问题。已修改的值会从处理器的Cache传递到共享内存中,接着,新值会传递到其他处理器的Cache中,其它处理器就不可以使用旧值进行计算。这就是人们所熟知的Cache一致性问题,是内存一致性问题的一种特殊情况,要解决这个问题需要硬件能像多进程编程一样实现处理并发问题 和同步控制 。
在使用分布式内存的系统中,各个处理器都有其各自的内存,而且每个处理器只能处理属于自己的内存。某些学者把这类系统称为“多计算机系统”,这个名字很真实地反映了组成这类系统的元素能够独立作为一个具有内存和处理器的微型系统,如下图所示:
这种内存管理方式有几个好处。第一,总线和开关级别的的通讯不会发生冲突。每个处理器都可以无视其他处理器的干扰而充分利用局部内存的带宽;第二,没有通用总线意味着没有处理器数量的限制,系统的规模只局限于连接处理器的网络带宽;第三,没有Cache一致性问题的困扰。每个处理器只需要处理属于自己的数据而无须关心上传数据副本的问题。但最大的缺点是,很难实现处理器之间的通讯。如果一个处理器需要其他处理器的数据,这两个处理器必须要通过消息传递协议来交换消息。这样进行通讯会导致速度降低,原因有两个,首先,从一个处理器创建和发送消息到另外一个处理器需要时间;其次,任何处理器都需要停止工作,处理来自其他处理器的消息。面向分布式内存机器的程序必须按照尽量相互独立的任务来组织,任务之间通过消息进行通讯。
MPP 机器由上百个处理器 (在一些机器中达到成千上万个) 通过通讯网络连接而成。世界上最快的计算机几乎都基于这种架构,采用这种架构的计算机系统有:Earth Simulator, Blue Gene, ASCI White, ASCI Red, ASCI Purple 以及 Red Storm 等。
工作站集群是指将传统的计算机通过通讯网络连接起来。在集群架构中,一个节点就是集群中的一个计算单元。对于用户来说,集群是完全透明的,掩盖了软硬件的复杂性,使得数据以及应用仿佛从一个节点中得到的。
在这里,会定义三种集群:
在同构的超级计算机中采用GPU加速器改变了之前超级计算机的使用规则。即使GPU能够提供高性能计算,但是不能把它看作一个独立的处理单元,因为GPU必须在CPU的配合下才能顺利完成工作。因此,异构计算的程序设计方法很简单,首先CPU通过多种方式计算和控制任务,将计算密集型和具有高并行性的任务分配给图形加速卡执行。CPU和GPU之间不仅可以通过高速总线通讯,也可以通过共享一块虚拟内存或物理内存通讯。事实上,在这类设备上GPU和CPU都没有独立的内存区域,一般是通过由各种编程框架(如CUDA,OpenCL)提供的库来操作内存。这类架构被称之为异构架构,在这种架构中,应用程序可以在单一的地址空间中创建数据结构,然后将任务分配给合适的硬件执行。通过原子性操作,多个任务可以安全地操控同一个内存区域同时避免数据一致性问题。所以,尽管CPU和GPU看起来不能高效联合工作,但通过新的架构可以优化它们之间的交互和提高并行程序的性能。
并行编程模型是作为对硬件和内存架构的抽象而存在的。事实上,这些模式不是特定的,而且和机器的类型或内存的架构无关。他们在理论上能在任何类型的机器上实现。相对于前面的架构细分,这些编程模型会在更高的层面上建立,用于表示软件执行并行计算时必须实现的方式。为了访问内存和分解任务,每一个模型都以它独自的方式和其他处理器共享信息。
在这节中,会描述这些编程模型的概览。在下一章会更加准确的描述这些编程模型,并会介绍Python中实现这些模型的相应模块。
在这个编程模型中所有任务都共享一个内存空间,对共享资源的读写是 异步的。系统提供一些机制,如锁和信号量,来让程序员控制共享内存的访问权限。使用这个编程模型的优点是,程序员不需要清楚任务之间通讯的细节。但性能方面的一个重要缺点是,了解和管理数据区域变得更加困难;将数据保存在处理器本地才可以节省内存访问,缓存刷新以及多处理器使用相同数据时发生的总线流量。
在这个模型中,单个处理器可以有多个执行流程,例如,创建了一个顺序执行任务之后,会创建一系列可以并行执行的任务。通常情况下,这类模型会应用在共享内存架构中。由于多个线程会对共享内存进行操作,所以进行线程间的同步控制是很重要的,作为程序员必须防止多个线程同时修改相同的内存单元。现代的CPU可以在软件和硬件上实现多线程。POSIX 线程就是典型的在软件层面上实现多线程的例子。Intel 的超线程 (Hyper-threading) 技术则在硬件层面上实现多线程,超线程技术是通过当一个线程在停止或等待I/O状态时切换到另外一个线程实现的。使用这个模型即使是非线性的数据对齐也能实现并行性。
消息传递模型通常在分布式内存系统(每一个处理器都有独立的内存空间)中应用。更多的任务可以驻留在一台或多台物理机器上。程序员需要确定并行和通过消息产生的数据交换。实现这个数据模型需要在代码中调用特定的库。于是便出现了大量消息传递模型的实现,最早的实现可以追溯到20世纪80年代,但直到90年代中期才有标准化的模型——实现了名为MPI (the Message Passing Interface, 消息传递接口)的事实标准。MPI 模型是专门为分布式内存设计的,但作为一个并行编程模型,也可以在共享内存机器上跨平台使用。
在这个模型中,有多个任务需要操作同一个数据结构,但每一个任务操作的是数据的不同部分。在共享内存架构中,所有任务都通过共享内存来访问数据;在分布式内存架构中则会将数据分割并且保存到每个任务的局部内存中。为了实现这个模型,程序员必须指定数据的分配方式和对齐方式。现代的GPU在数据已对齐的情况下运行的效率非常高。
并行算法的设计是基于一系列操作的,在编程的过程中必须执行这些操作来准确地完成工作而不会产生部分结果或错误结果。并行算法地大致操作如下:
第一阶段,将软件程序分解为可以在不同处理器执行的多个任务或一系列指令以实现并行性。下面展示了两个方法来实现程序分解:
在这个步骤中,并行程序将任务分配给各种处理器的机制是确定的。这个阶段非常重要,因为在这阶段会向各个处理器之间分配工作。负载均衡是这个阶段的关键,所有处理器都应该保持工作状态,避免长时间的空闲。为了实现这个效果,程序员必须考虑异构系统的可能性,异构系统会尝试将任务分配给相对更适合的处理器。最后,为了让并行程序有更高的效率,必须尽量减少处理器之间的通讯,因为处理器之间的通讯通常是程序变慢和资源消耗的源头。
聚合,就是为了提升性能将小任务合并成大任务的过程。如果设计过程的前两个阶段是将分解问题得到的任务数量大大超过处理器可接受的程度,或者计算机不是专门设计用于处理大量小任务 (如GPU的架构就非常适合处理数百万甚至上亿任务),那么过分解会导致严重的效率下降。一般情况下,这是因为任务需要跟处理它的处理器或线程进行通讯。大多数的通讯的消耗不仅包括跟传输数据量相称的部分,还包括进行通讯的固定部分 (如建立 TCP 连接的延迟)。如果分解的任务过小,固定消耗可能比数据量还大,可以说这样的设计是低效的。
在并行算法设计的映射阶段,会指定任务由哪个处理器处理。这阶段的目标是减少总体的执行时间。在这里需要经常做取舍,因为下面两个相互矛盾的策略:
这就是所谓的映射问题,也是一个NP完全问题——一般情况下不能再多项式时间内解决的问题。在大小相等和通讯模式容易定义的任务中,映射很直接 (在这里也可以执行聚合的步骤来合并映射到相同处理器的任务)。但是如果任务的通讯模式难以预测或者每个任务的工作量都不一样,设计一个高效的映射和聚合架构就会变得有难度。由于存在这些问题,负载均衡算法会在运行时识别聚合和映射策略。最难的问题是在程序执行期间通信量或任务数量改变的问题。针对这些问题,可以使用在执行过程中周期性运行的动态负载均衡算法。
无论是全局还是局部,对于不同的问题都有不同的负载均衡算法。全局算法需要对即将指向的计算有全局的认识,这样通常会增加很多开销。局部算法只需要依靠正在解决的问题的局部信息,对比全局算法能够减少很多开销,但在寻找最佳聚合和映射的能力较差。然而,即使映射的结果较差,节省的开销一般还是能减少执行时间。如果任务除了执行开始和结束几乎不和其它任务进行通讯,那么可以使用任务调度算法简单地把任务分配给空转的处理器。在任务调度算法中,会维护一个任务池,任务池中包含了待执行的任务,工作单元 (一般是处理器) 会从中取出任务执行。
接下来会解释这个模型中的三个通用方法。
这是一个基础的动态映射架构,在这个架构中工作单元会连接到一个中央管理单元中。管理单元不停发送任务给工作单元并收集运算结果。这个策略在处理器数量相对较小的情况下表现最好。
这是拥有半分布式布局的管理单元/工作单元的变种;工作单元会按组划分,每一组都有器管理单元。当工作单元从组管理单元获取任务时,组管理单元会和中央管理单元通讯 (或者组管理单元之间直接通讯)。通过提前获取任务可以提升这个基础策略的性能,这就导致了通讯和计算重迭进行。这样就可以在多个管理单元之间传播负载,如果所有工作单元都向同一个管理单元请求任务,这种策略本身就可以应付大量的处理器。
在这个架构中,所有东西都是去中心化的。每个处理器都维护着自己的任务池并且直接和其它处理器通讯请求任务。处理器有很多种方式选择处理器请求任务,选择哪种方式有待解决的问题决定。
并行编程的发展产生了对性能指标和并行程序评估软件的需求,通过评估性能才能确定该算法是否方便快捷。实际上,并行计算的重点是在相对较短的时间内解决体量较大的问题。为了能够达到这个目的,需要考虑的因素有:使用的硬件类型,问题的可并行程度和采用的编程模型等。为了加速算法评估过程,引进了基本概念分析,也就是将并行算法和原始的顺序执行做对比。通过分析和确定线程数量和/或使用的处理器数量来确定性能。
为了进行分析,在这里介绍几个性能指标:加速比,效率和扩展性。
阿姆德尔定律 (Ahmdal’s law) 引入了并行计算的极限,来评估串行算法并行化的效率。古斯塔夫森定律 (Gustafson’s law) 也做了相似的评估。
加速比用于衡量使用并行方式解决问题的收益。假设使用单个处理单元解决这个问题需要的时间为 T_s ,使用 p 个相同的处理单元解决这个问题的时间为 T_p ,那么加速比 S=T_s/T_p 。如果 S=p ,加速比为线性,也就是说执行速度随着处理器数量的增加而加快。当然,这只是一个理想状态。当 T_s 为最佳串行算法的执行时间,加速比是绝对的,而当 T_s 为并行算法在单个处理器上的执行时间,那么加速比是相对的。
下面概括了上述的情况:
S=p 为线性加速比,也是理想加速比。
S
S>p 为超线性加速比