1.1 基本的计算机系统
计算机的底层部件可简化为3个基本部分:计算单元、存储单元以及它们之间的连接。这些单元有各种不同的属性,可帮助我们认识它们。计算单元的一个属性是每秒能执行多少次计算;存储单元的属性包括可存储多少数据以及读写数据的速度;连接的一个属性是,通过它能够以多快的速度将数据从一个地方移到另一个地方。
通过这些基本单元,我们可以在多个复杂级别上讨论标准工作站。例如,可认为标准工作站有一个充当计算单元的中央处理器(Central Processing Unit,CPU),它连接到两个独立的存储单元——随机存取存储器(Random Access Memory,RAM)和硬盘(它们的容量和读写速度不同),而所有这些单元都是通过一条总线连接起来的。然而,也可以更详细地了解CPU本身的几个存储单元——L1、L2乃至L3和L4缓存,它们的容量很小(从几KB到十多MB不等),但速度非常快。另外,新型计算机架构通常采用了新配置,例如,Intel SkyLake CPU使用的是快速信道互联(Ultra Path Interconnect),而不是前端总线,还重构了众多其他的连接。最后,在这两种有关工作站的近似描述中,都省略了网络连接;网络连接的速度很慢,可用于连接到众多其他的计算单元和存储单元。
为帮助你理清头绪,下面简要地描述一下这些基本单元。
1.1.1 计算单元
计算单元是计算机的核心,它能够对数据进行转换,还能改变当前进程的状态。最常见的计算单元是CPU,但图形处理单元(Graphic Processing Unit,GPU)正越来越多地用作辅助计算单元。GPU最初用于提高计算机图形的处理速度,但凭借其内在的并行特性,它们越来越适合用于数值应用程序;所谓并行,指的是支持同时执行众多计算。无论是哪种类型的计算单元,它们都接收一系列位(如表示数字的位),并输出另一组位(如表示数字之和的位)。计算单元可对整数和实数执行算术运算、对二进制数执行按位运算,有些计算单元还可执行特殊的运算,如加法和乘法混合运算:接收3个数字A、B和C,并返回A*B+C的值。
计算单元的主要属性有两个:一个周期内可执行多少次计算;每秒可完成多少个周期。其中第一个属性的度量指标为指令数/周期(Instruction Per Cycle,IPC)[1],而第二个属性的度量指标为时钟速度。设计新的计算单元时,这两个指标会相互制约。例如,Intel Core系列的IPC很高,但时钟速度较低,而Pentium 4芯片则相反。GPU的IPC和时钟速度都很高,但它们存在其他问题,如通信速度慢,这将在1.1.3节讨论。
[1] 第9章将讨论的进程间通信(InterProcess Communication)的缩略语也是IPC,请不要将它们混为一谈。
另外,尽管通过提高时钟速度几乎可以立即提高计算单元上运行的所有程序的速度(因为这让程序能够在单位时间内执行更多的计算),但提高IPC也可改善向量化水平,从而极大地影响计算。向量化(vectorization)发生在一次给CPU提供多项数据,并且它能够同时对这些数据执行运算的时候。这种CPU指令被称为单指令多数据(Single Instruction Multiple Data,SIMD)。
在过去的10年,计算单元的发展速度极其缓慢,如图1-1所示。时钟速度和IPC都停滞不前,这是因为晶体管的尺寸已接近物理极限。有鉴于此,芯片制造商已转而采用其他方法来提高速度,包括多线程技术(同时执行多个线程)、更巧妙的乱序执行以及多核架构。
图1-1 CPU时钟频率变化趋势(摘自CPU DB)
超线程技术让主机操作系统(Operating System,OS)以为有另一个CPU;更巧妙的硬件逻辑试图在单个CPU中交替地执行两个指令线程。如果成功,在单线程上最多可以将速度提高30%。通常,如果两个线程的工作分布在不同的执行单元上(如一个执行浮点数运算,另一个执行整数运算),这样做的效果非常好。
乱序执行让编译器找出程序中彼此独立的部分,进而以任何顺序或同时执行它们。只要能在正确的时间提供中间结果,程序就能往下正确地执行,哪怕没有按程序指定的顺序提供这些结果。这使得在有些指令被阻塞(如等待内存访问)时,可执行其他指令,从而提高可用资源的利用率。
最后是多核架构的普及,这对高级程序员来说也是最重要的一点。这种架构包含多个CPU,无须突破单CPU的速度壁垒就能提高总体计算能力。这就是当前很难找到少于双核的计算机的原因所在(所谓双核计算机,就是有两个彼此相连的计算单元)。这虽然增加了每秒可执行的运算数,但也增加了代码编写工作的难度。
根据阿姆达尔定律,给CPU增加核心并不一定能缩短程序的执行时间。简单地说,阿姆达尔定律是这样的:对于在多核上运行的程序,如果其中有些子程序必须在同一个核上运行,将对速度提升带来限制,使得速度提升到一定程度后,即便再增加核心,也无法进一步提高速度。
例如,假设有项调查,需要调查100人,而调查每个人需要1分钟才能完成。假设只有一位调查员,则完成这项调查任务将需要100分钟(这位调查员向第1位被调查者提问并等待回答,再转向第2位被调查者)。这种由一位调查员提问并等待回答的方法类似于串行处理;在串行处理中,每次只能执行一项操作,每项操作都需要等待前一项操作执行完毕后再执行。
如果有两位调查员,就可以同时进行调查,整个调查工作只需50分钟就能完成。这是因为每位调查员都无须了解另一位调查员的调查情况,因此可将任务分成两个彼此独立的部分。
增加调查员可进一步提高速度。当调查员数量增加到100时,整个调查过程只需1分钟就能完成,这是被调查者回答问题所需的时间。如果此时再增加调查员,并不能进一步缩短调查时间,因为新增的调查员将无所事事——所有的被调查者都在接受调查。在这种情况下,要缩短整个调查时间,唯一的办法是缩短调查单人所需的时间,即完成问题的串行部分所需的时间。同样,对于CPU,可在必要时增加执行计算的核心,但增加到一定程度后将出现瓶颈,这个瓶颈就是特定核心完成其任务所需的时间。换言之,在并行计算中,瓶颈是由必须串行执行的子任务决定的。
另外,在Python中利用多核将面临的一个主要障碍是,Python使用了全局解释器锁(Global Interpreter Lock,GIL)。GIL确保Python进程每次只运行一条指令,而不管当前使用了多少个核心。这意味着虽然有些Python代码能够同时访问多个核心,但在任何时点,都只有一个核心在运行Python指令。如果以前面的调查为例,这意味着即便有100名调查员,任何给定时点也只有一位调查员在提问并等待回答,这相当于让多位调查员带来的好处消失殆尽。这看起来是个巨大的障碍,如果考虑到当前的趋势是使用多个计算单元而不是单个速度更快的计算单元,这个问题就更严重了。采用如下方法可避免这个问题:使用诸如multiprocessing等标准库工具(第9章);使用诸如numpy、numexpr等技术(第6章);使用Cython(第7章);使用分布式计算模型(第10章)。
注意:Python 3.2对GIL做了重大修改,使其更灵活,从而缓解了众多与单线程性能相关的问题。虽然GIL现在依然对Python进行限制,使其每次只能运行一条指令,但在指令切换方面它做得更好,且开销更低。
1.1.2 存储单元
计算机的存储单元用于存储位,这些位表示的可能是程序中的变量,也可能是图像像素。存储单元包括主板上的寄存器、RAM和硬盘,这些存储单元的主要不同之处在于读写数据的速度。读写速度严重依赖于读写方式。
例如,对大多数存储单元来说,读取一大块数据时,性能要比读取大量小块数据高得多,这两种读取方式分别称为顺序读取和随机读取。如果将数据视为书本中的书页,这意味着对大多数存储单元来说,连续翻页的速度要比随机翻页快。所有存储单元的顺序读取速度都快于随机读取,但在不同的存储单元中,这两种读取方式的速度有天壤之别。
除了读写速度,存储单元的另一个属性是延迟,这可用查找数据所需的时间来表征。旋转式硬盘的延迟可能很长,因为需要让磁盘旋转起来,并将读写头移到合适的位置;而RAM的延迟可能很短,因为一切都是固态的。下面按读写速度从低到高的顺序简要地介绍一下标准工作站中常见的存储单元[2]。
[2] 本节的速度数据摘自O’Reilly网站。
1)旋转硬盘
旋转硬盘属于永久性存储,即便计算机关机也不会丢失。它的读写速度通常较慢,因为必须旋转磁盘并移动读写头。随机存取时性能将下降,但容量非常大(数十TB)。
2)固态硬盘
固态硬盘类似于旋转硬盘,但读写速度更快,容量更小(数TB)。
3)RAM
RAM用于存储应用程序的代码和数据(如应用程序中所有的变量)。读写速度较快,随机存取的性能也不错,但通常容量有限(64 GB左右)。
4)L1/L2缓存
读写速度非常快。进入CPU的数据必须经过这些缓存。容量很小(数MB)。
图1-2说明了这些存储单元的特征。
一个显而易见的规律是,读写速度和容量成反比:速度越快,容量越小。有鉴于此,很多系统都实现了分层存储:所有数据都存储在硬盘中,部分数据进入RAM,更少的部分数据进入L1/L2缓存。这种分层方法让程序能够根据存取速度需求将数据放在不同的地方。优化程序的数据存储模式时,优化的是数据的存储位置和布局(旨在增加顺序读取的次数)以及在不同存储单元之间移动数据的次数。另外,异步I/O和抢占式缓存(preemptive caching)可确保数据位于合适的地方,从而避免浪费计算时间,这是因为这些过程是独立于计算的。
图1-2 各种存储单元的特征值(2014年2月的数据)
1.1.3 通信层
最后,我们来看看这些基本部件是如何相互通信的。通信模式有很多,但都是总线的变种。
例如,前端总线是RAM和L1/L2缓存之间的连接,它将准备就绪的数据移到集结场所,供处理器进行转换,并将计算结果移出。还有其他的总线,如外部总线,它是硬件设备(如硬盘和网卡)到CPU和系统内存的主要通道。外部总线的速度通常比前端总线慢。
实际上,L1/L2缓存带来的很多好处都要归功于速度更快的总线。正是因为能够在慢速总线(RAM到缓存的总线)以大块的方式将计算所需的数据排队,再通过从缓存到CPU的总线以非常快的速度提供数据,才让CPU无须等待太长时间,从而能够执行更多的计算。
同样,使用GPU存在的众多缺点也是由其连接的总线导致的:GPU通常是外部设备,通过PCI总线进行通信,而这种总线的速度比前端总线慢得多,因此将数据移入和移出GPU可能是一项开销高昂的操作。有鉴于此,异构计算(前端总线连接CPU和GPU)应运而生,它旨在降低数据传输开销,让GPU计算变得可行,即便在需要传输大量数据的情况下亦如此。
除了计算机内部的通信部件,另一种通信部件是网络设备,它比前面讨论的通信部件更灵活,可连接到存储设备,如网络连接存储(Network Attached Storage,NAS)设备,还可连接到其他计算部件,如集群中的计算节点。网络通信的速度通常比前述其他通信方式慢得多:前端总线每秒可传输几十吉比特,而网络每秒只能传输几十兆比特。
显然,总线的主要属性是速度:在给定时间内可传输多少数据。这个属性由两个数值表征:一次能传输多少数据(总线宽度);每秒能传输多少次(总线频率)。需要指出的是,一次传输的数据总是串行的:从存储单元中读取一个数据块,并将其移到另一个地方。之所以用两个数值来表征总线速度,是因为它们影响的计算方面不同:总线宽度很大时,可一次性传输所有相关的数据,这对向量化代码(以及从存储单元中顺序读取数据的代码)大有裨益,而对于需要从存储单元中随机读取数据的代码来说,低总线宽度和高频率大有裨益。有趣的是,为改变这些属性,计算机设计人员采用的方法之一是调整主板的物理布局:芯片离得越近,用于连接它们的导线就越短,而这有助于提高传输速度。另外,导线数量越多,总线宽度越大(从物理上说,总线就越宽)。
鉴于可根据应用程序的性能要求调整接口,因此接口类型成百上千。图1-3显示了一些常见接口的速度。需要注意的是,该图根本没有提及连接的延迟——对数据请求做出响应的时间。虽然延迟随计算机而异,但每种接口类型都存在固有的基本延迟。
图1-3 各种常见接口的连接速度[3]
[3] 这里的数据摘自O’Reilly网站。