Python高性能编程第3版1了解高性能Python-1基本计算机系统


1 了解高性能Python

主要内容:

  • 计算机体系结构的要素有哪些?
  • 有哪些常见的计算机体系结构?
  • Python如何抽象底层计算机体系结构?
  • 编写高性能 Python 代码有哪些障碍?
  • 如何成为高性能的程序员?

计算机编程可以看作是移动数据位并以特殊方式转换数据位以实现特定结果。然而,这些操作都需要时间成本。因此,高性能编程可以被认为是通过减少开销(即编写更高效的代码)或改变我们进行这些操作的方式(即找到更合适的算法)来尽量减少这些操作的行为。
让我们把重点放在减少代码开销上,以便更深入地了解我们移动这些比特的实际硬件。这看似徒劳无功,因为 Python 非常努力地抽象出与硬件的直接交互。但是,通过了解比特在实际硬件中移动的最佳方式,以及 Python 的抽象比特移动的方式,您就能在用 Python 编写高性能程序方面取得进展。

1.1 基本计算机系统

计算机的基本组件可简化为三个基本部分:计算单元、存储单元以及它们之间的连接。此外,每个单元都有不同的属性,我们可以利用这些属性来理解它们。计算单元具有每秒可进行多少次计算的属性,内存单元具有可容纳多少数据以及读写速度的属性,最后,连接单元具有将数据从一个地方移动到另一个地方的速度的属性。

利用这些组件,我们可以将标准工作站分为多个复杂级别。例如,我们可以认为标准工作站有一个中央处理器(CPU)作为计算单元,与随机存取存储器(RAM)和硬盘驱动器这两个独立的存储单元相连(每个单元的容量和读写速度各不相同),最后还有一条总线提供所有这些部件之间的连接。不过,我们还可以更详细地看到,CPU 本身也有几个内存单元:L1、L2,有时甚至是 L3 和 L4 高速缓存,它们的容量很小,但速度非常快(从几千字节到十几兆字节不等)。此外,新的计算机架构一般都会有新的配置(例如,英特尔的 Skylake CPU 用英特尔 Ultra Path 互连取代了前端总线,并重组了许多连接;最近,AMD 的 Infinity Fabric 为更快的 CPU-CPU 或 CPU-GPU 通信创建了专用互连)。最后,在上述两种工作站近似模型中,我们都忽略了网络连接,而网络连接实际上是与潜在的许多其他计算和内存单元之间的非常缓慢的连接!

为了解开这些错综复杂的问题,让我们来简要介绍一下这些基本模块。

1.1.1 计算单元(Computing Units)

计算机的计算单元是计算机功能的核心–它能够将接收到的任何比特转化为其他比特,或改变当前进程的状态。中央处理器是最常用的计算单元;然而,图形处理单元(GPU graphics processing units )以及TPU 、IPU 和 FPGA 等众多其他选项作为辅助计算单元正日益受到欢迎。它们最初用于加快计算机图形处理速度,但现在越来越适用于数值应用,而且由于其本质上的并行性,可以同时进行许多计算,因此非常有用。无论其类型如何,计算单元都是接收一系列比特(例如,代表数字的比特)并输出另一组比特(例如,代表这些数字之和的比)。除了对整数和实数进行基本算术运算以及对二进制数进行位运算外,一些计算单元还提供非常专业的运算,例如 “融合乘加 ”运算,它输入三个数 A、B 和 C,并返回值 A * B + C。

  • CPU: Central Processing Unit 中央处理单元

CPU 是计算机的“大脑”,负责执行各种通用计算任务。它擅长处理串行指令,管理操作系统,运行各种应用程序,并执行逻辑和算术运算。几乎所有的电子设备中都有 CPU,它是通用计算的基石。

  • GPU: Graphics Processing Unit

GPU,即图形处理单元,最初是为了加速图像和视频的渲染而设计的。它拥有大量的并行处理核心,能够同时处理多个计算任务,这与 CPU(中央处理单元)擅长串行处理的特点形成对比。由于其强大的并行处理能力,GPU 逐渐超越了单纯的图形渲染领域,被广泛应用于各种需要大量并行计算的场景。

  • NPU: Neural Processing Unit 神经网络处理单元

NPU 是一种专门为人工智能(AI)和机器学习(ML)任务设计的处理器。它擅长处理神经网络算法所需的并行计算,尤其是在执行推理任务(如图像识别、语音处理、自然语言理解)方面表现出色。与 CPU 和 GPU 相比,NPU 在处理 AI 工作负载时通常具有更高的能效和更快的速度,因此在智能手机、笔记本电脑和边缘计算设备中越来越常见。

  • TPU(Tensor Processing Unit)张量处理单元

这是由 Google 开发的一种人工智能加速器,专门为神经网络机器学习(特别是使用 TensorFlow 软件)而设计。TPU 擅长处理大规模、低精度的计算,在深度学习模型的训练和推理方面表现出色。

  • IPU

Infrastructure Processing Unit (基础设施处理单元):例如 Intel 开发的 IPU,它是一种可编程网络设备,旨在卸载数据中心基础设施任务(如网络虚拟化、存储虚拟化、安全隔离)以提高 CPU 利用率和性能。它常被视为 SmartNIC 的演进。

Intelligence Processing Unit (智能处理单元):Graphcore 等公司将他们的 AI 处理器命名为 IPU,这类处理器专门针对图相关的应用和AI/机器学习工作负载进行优化。

  • FPGA:Field-Programmable Gate Array (现场可编程门阵列)

计算单元的主要特性是一个周期内可执行的操作数和一秒钟内可执行的周期数。前者用每周期指令数(IPC: instructions per cycl)衡量,后者用时钟速度衡量。在制造新的计算单元时,这两个指标总是相互竞争。例如,英特尔酷睿系列具有极高的 IPC,但时钟速度较低,而奔腾 4 芯片则相反。另一方面,GPU 具有极高的 IPC 和时钟速度,但却存在其他问题,例如我们在 “通信层 ”中讨论的通信速度慢的问题。
此外,虽然提高时钟速度几乎可以立即加快在该计算单元上运行的所有程序(因为它们每秒可以进行更多计算),但更高的 IPC 也会改变可能的矢量化水平,从而对计算产生巨大影响。当 CPU 一次获得多个数据块,并能同时对所有数据块进行操作时,就会发生矢量化。这种 CPU 指令被称为单指令多数据 (SIMD: single instruction, multiple data)。

总体而言,计算单元在过去十年的发展相当缓慢。由于晶体管越来越小的物理限制,时钟速度和 IPC 一直停滞不前。因此,芯片制造商一直依赖其他方法来提高速度,包括同步多线程(simultaneous multithreading:多个线程可同时运行)、更巧妙的无序执行(out-of-order execution)和多核架构(multicore architectures)。

超线程技术为主机操作系统(OS)提供了一个虚拟的第二 CPU,巧妙的硬件逻辑试图在单个 CPU 的执行单元中穿插两个指令线程。如果成功,可比单线程提高多达 30%。通常情况下,当两个线程的工作单元使用不同类型的执行单元时,这种方法就能很好地发挥作用–例如,一个线程执行浮点运算,另一个线程执行整数运算。
无序执行能让编译器发现线性程序序列的某些部分并不依赖于前一个工作的结果,因此这些工作可以以任何顺序或同时进行。只要顺序结果在正确的时间出现,程序就能继续正确执行,即使计算的工作片段与编程顺序不符。这样,当其他指令可能受阻(如等待内存访问)时,某些指令也能执行,从而提高了可用资源的总体利用率。

最后,对高级程序员来说最重要的是多核架构的普及。这些架构在同一芯片中包含多个 CPU,从而提高了总性能,但并不妨碍提高每个单元的速度。这就是为什么目前很难找到少于两个内核的机器–在这种情况下,计算机有两个相互连接的物理计算单元。虽然这增加了每秒可完成的总运算量,但却增加了编写代码的难度!

为 CPU 增加内核并不总能加快程序的执行时间。这就是所谓的阿姆达尔定律。在我们的语境中,阿姆达尔定律可以解释为:如果一个程序设计为在多个内核上运行,但其中一些子程序必须在一个内核上运行,那么这将是分配更多内核所能达到的最大加速限制。

例如,如果我们想让 100 人填写一份调查问卷,而完成该问卷需要 1 分钟时间,那么如果由一个人提问,我们就可以在 100 分钟内完成这项任务(即这个人向参与者 1 提问,等待回答,然后转到参与者 2)。这种由一个人提问并等待回答的方法类似于串行流程。在串行流程中,我们一次完成一个操作,每个操作都在等待前一个操作完成。

但是,如果有两个人提问,我们就可以并行执行调查,这样就可以在 50 分钟内完成整个过程。之所以能做到这一点,是因为每个提问者都不需要知道另一个提问者的任何信息。因此,这项任务可以很容易地拆分开来,提问者之间没有任何依赖关系。

提问人数越多,速度就越快,直到有 100 人提问为止。此时,整个过程将耗时 1 分钟,而且仅仅受限于参与者回答问题的时间。增加提问人数不会进一步加快速度,因为这些额外的人没有任务要执行–所有参与者都已经在被提问了!此时,减少运行调查的总体时间的唯一方法就是减少单个调查(问题的串行部分)完成所需的时间。同样,对于中央处理器,我们可以根据需要增加更多的内核来执行不同的计算部分,直到我们达到一个瓶颈点,即特定内核完成其任务所需的时间。换句话说,任何并行计算的瓶颈始终是被分散的较小串行任务。此外还有物理限制,随着我们将越来越多的内核装入越来越小的空间,热量就会成为一个问题,热节流就成为一个重要问题!

  • Thermal throttling(热节流)

热节流是一种保护机制,当电子设备(如电脑、智能手机、游戏机等)的处理器(CPU、GPU 等)或其他组件达到过高的温度时,会自动降低其运行频率或功耗。这样做的目的是为了防止硬件因过热而损坏,确保设备的稳定性和寿命。

然而,在 Python 中使用多核的一个主要障碍是 Python 使用的全局解释器锁(GIL:global interpreter lock)。GIL 确保 Python 进程一次只能运行一条指令,而与当前使用的内核数量无关。这意味着,即使某些 Python 代码可以同时访问多个内核,在任何时候也只有一个内核在运行 Python 指令。以之前的调查为例,这意味着即使我们有 100 个提问者,每次也只能有一个人提问并听取回答。这实际上消除了多个提问者的任何好处!虽然这似乎是一个相当大的障碍,尤其是如果当前的计算趋势是拥有多个计算单元而不是更快的计算单元,但这个问题可以通过使用其他标准库工具来避免,如多进程(第 10 章);numpy 或 numexpr(第 6 章)、Cython 或 Numba(第 8 章)等技术;或分布式计算模型(第 11 章)。

Python 3.2 对 GIL 进行了重大重写,使系统变得更加灵活,减轻了人们对系统单线程性能的担忧。此外,还有人建议让 GIL 本身成为可选项,虽然 GIL 仍然会将 Python 锁定为一次只能运行一条指令,但它现在能更好地在这些指令之间切换,而且开销更少。

参考资料

1.1.2 内存单元

计算机中的内存单元用于存储比特。这些比特可以代表程序中的变量,也可以代表图像的像素。因此,内存单元的抽象概念适用于主板中的寄存器以及内存和硬盘。所有这些类型的内存单元之间的主要区别在于它们读/写数据的速度。更复杂的是,读/写速度在很大程度上取决于读取数据的方式。

例如,大多数内存单元在读取一大块数据时比读取许多小块数据时性能要好得多(这被称为顺序读取与随机数据)。如果把这些内存单元中的数据看作是一本大书中的页数,这就意味着大多数内存单元在逐页翻阅这本书而不是不断地从一页随机翻到另一页时,读/写速度会更好。虽然这一事实在所有内存单元中普遍适用,但对每种内存单元的影响程度却大不相同。

除了读/写速度外,内存单元还有延迟(latency),即设备找到正在使用的数据所需的时间。对于旋转硬盘来说,这种延迟可能会很高,因为磁盘需要物理加速旋转,读取头必须移动到正确的位置。另一方面,对于内存来说,由于所有部件都是固态的,因此这种延迟可能非常小。以下是标准工作站中常见的各种内存单元的简短说明,按读写速度排序:

  • 硬盘驱动器(HDD:Hard disk drive)
    长期存储,即使计算机关机也能继续使用。读/写速度一般较慢,因为它使用的物理磁盘必须加速旋转,读取头必须移动到正确位置才能找到数据。随机存取模式下性能下降,但容量非常大(20T字节级别)。

  • 固态硬盘(SSD:Solid-state drive)
    与旋转硬盘类似,读/写速度更快,但容量较小(T1字节级别)。

  • 内存(RAM:RAM 是 Random Access Memory:随机存取存储器

用于存储应用程序代码和数据(如正在使用的任何变量)。具有快速读/写特性,在随机存取模式下性能良好,但容量通常有限(64 G字节级别)。

  • L1/L2 高速缓存
    读/写速度极快。进入 CPU 的数据必须经过此处。容量很小(几十兆字节)。

一个明显的趋势是,读/写速度和容量成反比–当我们试图提高速度时,容量就会减少。正因为如此,许多系统对内存采用了分层方法:数据从硬盘的完整状态开始,部分数据移至 RAM,然后更小的子集移至 L1/L2 高速缓存。这种分层方法能让程序根据访问速度的要求,将内存保存在不同的位置。当试图优化程序的内存模式时,我们只需优化哪些数据被放置在哪里、如何布局(以增加顺序读取的次数)以及在不同位置之间移动的次数。此外,异步 I/O 和抢占式缓存等方法还能确保数据始终在需要的位置,而不必浪费计算时间等待 I/O 完成–这些过程大多可以在执行其他计算时独立进行!我们将在第 9 章讨论这些方法。

1.1.3 通信层

最后,让我们看看所有这些基本模块是如何相互通信的。通信方式有很多种,但都是总线的变种。例如,前端总线是 RAM 与 L1/L2 高速缓存之间的连接。它将准备好由处理器转换的数据传送到中转站,为计算做好准备,并将完成的计算传送出去。此外还有其他总线,如外部总线,它是硬件设备(如硬盘和网卡)连接 CPU 和系统内存的主要通道。这种外部总线通常比正面总线慢。

事实上,L1/L2 高速缓存的许多优势都归功于更快的总线。通过慢速总线(从 RAM 到高速缓存)将计算所需的大块数据排成队列,然后通过高速缓存线(从高速缓存到 CPU)以极快的速度提供给 CPU,这样 CPU 就能进行更多的计算,而无需等待如此长的时间。

同样,使用 GPU 的许多弊端也来自于它所连接的总线:由于 GPU 通常是外围设备,它通过 PCI 总线进行通信,而 PCI 总线要比前端总线慢得多。因此,将数据输入和输出 GPU 可能是一项相当耗费精力的操作。异构计算的出现,或在前端总线上同时拥有 CPU 和 GPU 或与 Infinity Fabric 连接的计算模块的出现,旨在降低数据传输成本,并使 GPU 计算成为更多的选择,即使在必须传输大量数据的情况下也是如此。如今,一些芯片甚至将 CPU 和 GPU 封装在同一个封装中。最近,NPU(或神经处理单元)甚至被纳入其中,以帮助加快特定人工智能应用的速度。

除了计算机内的通信模块外,网络可以被视为另一个通信模块。网络设备可以连接到存储设备,如网络附加存储设备(NAS network attached storage),也可以连接到另一个计算模块,如集群中的计算节点。不过,网络通信通常比前面提到的其他通信类型要慢得多。前端总线每秒可传输数十千兆比特,而网络则仅限于数十兆比特。

由此可见,总线的主要特性是速度–在一定时间内可以传输多少数据。这一特性由两个量组合而成:一次传输可传输多少数据(总线宽度)和总线每秒可传输多少次(总线频率)。值得注意的是,一次传输中移动的数据总是顺序的:从内存中读取一大块数据,然后移动到不同的地方。

因此,总线的速度被分为这两个量,因为它们各自会影响计算的不同方面。总线宽度大,可以在一次传输中移动所有相关数据,从而有助于矢量化代码(或任何顺序读取内存的代码)。另一方面,总线宽度较小但传输频率很高,可以帮助那些必须从内存随机部分进行多次读取的代码。有趣的是,计算机设计师改变这些特性的方法之一是通过主板的物理布局:当芯片彼此靠近时,连接它们的物理电线长度就会变小,从而提高传输速度。此外,印刷电路板本身的迹线数量也决定了总线的宽度(赋予了总线一词真正的物理意义!)。

由于可以对接口进行调整,使其具有适合特定应用的性能,因此有数百种类型也就不足为奇了。下图显示了一些常见接口的比特率。需要注意的是,这完全没有涉及连接的延迟,而延迟决定了响应数据请求所需的时间(虽然延迟与计算机的关系很大,但所使用的接口也有一些固有的基本限制)。