使用 Python 进行并行与高性能编程——并行编程导论

在本书的第一章中,我们将通过介绍所有相关的基本概念来讲解并行编程的概念,这些基本概念是充分理解其特性和用途所必需的。我们首先会讲述使新型计算机能够实现并行执行的硬件组件,如CPU和核心,然后介绍操作系统中的实体,它们是并行性的真正执行者:进程和线程。随后,我们会详细说明并行编程模型,介绍诸如并发、同步和异步等基本概念。

在介绍了这些通用概念之后,我们将探讨Python在这方面的特殊性,特别是关于线程的问题,讲解全局解释器锁(GIL)及其带来的问题。我们还会提及Python标准库中的线程(threading)和多进程(multiprocessing)模块,这些内容将在后续章节中更深入地讲解。最后,本章将以讨论并行程序的评估方法收尾,如加速比和扩展性,并探讨并行编程可能带来的问题(竞争条件、死锁等)。

读完本章后,你将理解并行编程背后的所有基础概念和术语。在你脑海中将构建一个总体框架,展现所有并行执行的参与者及其如何协同工作以实现并行执行。然后,你就准备好迎接后续章节中涉及的实际编程部分。

章节结构

本章将讨论以下主题:

  • CPU和核心
  • 进程和线程
  • 并行与并发编程
  • Python中的GIL和线程
  • 加速比和扩展性

并行编程

如果你正在阅读这本书,肯定是因为你已经意识到需要提升代码的潜力,发现了传统模型的局限性------这些模型由于历史原因(旧计算机的限制)依然采用串行方式。

新硬件技术的出现,使我们能够在计算机上同时运行多个程序。实际上,即便是最简单的计算机,也配备了多核系统,可以实现程序的并行运行。那么,为什么不利用这种架构优势呢?

你可能经常发现自己开发一个Python程序来执行一系列操作。尤其是在科学领域,常常需要实现一系列算法来完成非常繁重的计算。但当你在计算机上运行程序时,会失望地发现速度并不像预期的那样快,且随着问题规模的增大,执行时间变得过长。但这不仅仅是速度问题。如今,我们越来越多地需要处理庞大的数据量及其相关计算,程序需要越来越多的内存资源,而我们的计算机尽管性能强大,依然难以应对。

并行编程允许你同时执行程序中代码的多个部分,大幅提升性能。因此,并行编程意味着减少程序的执行时间,更高效地利用资源,并能够执行以往难以完成的复杂操作。

计算机技术演进与并行性

如今,对于许多程序员来说,并行编程仍然是一项较为陌生的技术,因为它仍属于相对较新的方法。实际上,就在几年前,开发者所能使用的所有计算机都只配备了单个算术逻辑单元(ALU),串行编程是唯一可想象的方式。程序指令按顺序一次执行一条(见图1.1):

事实上,你们许多人可能还记得计算机通常以处理器频率(赫兹,Hz)来表示其特性,这个频率表示每秒可以执行的指令数。计算机的性能主要以其计算频率来衡量,频率越高,程序运行得越快。

并行性的概念是随着计算机内部硬件的发展逐步产生的。直到1980年代,计算机非常有限:它们一次只能运行一个程序,一条指令接着一条指令,严格按照顺序执行。在这样一种技术环境下,并行性的概念甚至都难以被想象。

随着Intel 80386处理器的出现,计算机开始具备了中断一个程序的执行,去处理另一个程序的能力。因此,抢占式编程和时间片轮转等概念应运而生。这一技术进步带来了伪并行的效果,因为用户能够看到多个程序似乎在同时运行。随后,随着Intel 80486处理器的推出,情况得到了进一步改善,它引入了基于程序拆分为子任务的流水线系统。这些子任务能够独立执行,在多个程序之间交替进行。此外,内部架构首次支持将多个不同指令(甚至来自不同程序的指令)组合起来,一起执行(虽然不是完全同时执行)。这正是并发编程真正发展起来的地方。不同子任务的指令部分被完成,以便尽快执行(见图1.2):

这种情况持续了十多年,期间发布了越来越强大的处理器型号,能够以比以前更高的频率工作。但这种局面很快因一系列问题和物理限制而陷入困境。提高执行频率意味着同时增加热量产生和随之而来的能耗。显然,频率提升很快会遇到瓶颈。

于是,处理器实现了技术上的飞跃,引入了核心(Core)概念。这些核心,也称为逻辑处理器,能够在单个CPU内部模拟多个处理器的存在,从而形成多核CPU。实际上,这意味着可以拥有一台多处理器计算机,能够同时并行执行来自不同程序的指令。正是在21世纪初,随着这一技术的发展,并行编程得以兴起,开发者开始能够同时执行同一个程序的不同部分。

CPU、核心、线程与进程

要理解本书将要讲述的概念,首先必须清楚线程和进程是什么,以及它们与CPU和核心的执行方式密切相关。

这些不是抽象的概念,而是操作系统中真实存在的实体。为了熟悉它们,我们可以直接查看操作系统中的相关信息。例如,如果你使用的是Windows系统,可以打开任务管理器,切换到"性能"标签页。

你将看到一个与图1.3中类似的窗口,能够实时监控各种资源的使用情况,如CPU、内存和Wi-Fi网络等:

此外,还会显示诸如进程数量和当前正在运行的线程数量等各种信息。在右侧,会列出我们所使用系统的一些特征,比如核心数量。

如果你使用的是Linux系统(如Ubuntu),可以通过在终端输入以下命令来查看类似的信息:

css 复制代码
$ top

这时会出现一个与图1.4所示非常相似的界面:

如我们所见,界面顶部显示了所有正在使用的资源及其实时更新的数值。在下方,则列出了操作系统中所有活跃进程的列表。每个进程都由一个唯一的编号标识,即进程标识号(PID)。

由于Linux系统更为灵活和强大,尤其得益于众多的Shell命令,我们还可以监控与每条命令相关的所有线程。为此,我们将使用一个更具体的命令来监控进程:pid

r 复制代码
$ pid -T <PID>

其中,-T选项表示显示该进程所拥有的线程。你需要将想要详细监控的进程的PID传递给该命令。比如我选择进程PID为2176的进程,执行命令后会得到如图1.5所示的结果,显示了该进程下所有线程的标识号,称为SPID:

中央处理单元(CPU)是我们计算机的真正"大脑",基本上它就是代码被处理的地方。CPU的特点是以"周期"为单位,即CPU执行一次操作所用的时间单位。我们通常用每秒周期数的频率来表示CPU的性能(参见图1.3中2.87GHz的速度值)。

CPU内部可以有一个核心(单核CPU)或者多个核心(多核CPU)。核心是CPU内部的数据执行单元。每个核心都能运行多个进程。进程本质上是运行在计算机上的程序,并为其分配一块内存空间。此外,每个进程还可以启动其他进程(子进程),或者在自身内部运行一个(主线程MainThread)或多个线程。相关示意图见图1.6:

线程可以被看作是在单个处理器内并发运行的子进程。与进程类似,线程也拥有一系列管理其同步、数据交换和状态转换(就绪、运行、阻塞)机制。

这是我们理解计算机中进程和线程如何运作的总体框架,有助于我们更好地建模并行编程,实现最佳效果。

并发与并行编程

并发(concurrency)和并行(parallelism)常常被混淆,且两者被交替使用的情况也很常见,但这其实是不正确的。虽然这两个概念密切相关,但在并行编程的语境下,它们是不同的,理解其区别非常重要。

先来看两者的共通点:无论是并发还是并行,都是指程序需要同时执行多个任务。但这正是并发的含义。

并发意味着管理(而非执行)多个任务的同时存在,但它们不一定同时运行。

因此,一个需要同时执行多个任务的程序,即使一次只处理一个任务,也能实现并发。当程序完成了某个任务或其子任务的执行指令后,就会转到下一个任务,如此往复。任务一个接一个地交替执行,最终完成整个程序的工作。如果方便理解,可以将任务想象成相互竞争执行权的多个"竞争者"。

所以,即使我们的计算机是单核CPU,这样的竞争程序也可以轻松运行(见图1.7):

从外部来看,用户会觉得多个任务是在同时执行,但实际上,CPU内部一次只执行一个任务。

但并发编程的应用也扩展到了多核CPU或多处理器计算机。在这种情况下,竞争的情况可能如下:

如图1.8所示,情况变得更加复杂。由于存在多个处理单元(多个核心),子任务可以被分配到每个核心,并同时执行。因此,我们出现了并行现象。

并行性意味着多个任务真正同时同时地执行。

因此,并行是并发编程的一种特殊情况。

当程序将每个任务分配给不同的CPU核心,使它们能够同时处理,即并行执行时,就发生了并行,如图1.9所示:

因此,并行性需要具备多个处理单元的硬件,基本上就是多核CPU。而在单核CPU上,可以模拟并发,但无法实现真正的并行。

Python中的线程与进程:并发与并行模型

既然我们已经理解了并发编程和并行编程的区别,接下来进一步探讨。在许多编程语言中,通常将线程与并发关联,将进程与并行关联。实际上,操作系统中的这两种实体分别体现了并发和并行这两种不同的功能。

但是,针对Python,最好将这两种情况划分为两个截然不同的编程模型。实际上,Python中的线程行为并不像操作系统中的线程那样完美。Python线程无法真正并发执行,因此也无法并行工作。用Python的线程就像在单核CPU上工作,尽管实际情况并非如此。

Python线程问题:全局解释器锁(GIL)

Python中的线程无法像其他编程语言那样在两个不同核心上同时执行,这一现象与Python解释器本身密切相关。Python代码一直运行在CPython解释器上,而在其实现过程中,发现它并不是完全线程安全的。也就是说,多个线程尝试访问某个共享对象(线程之间共享内存)时,经常会遇到状态不一致的问题,这就是竞态条件(race condition)现象。为避免这一严重问题,解释器中引入了全局解释器锁(GIL)。Python设计者因此决定,在一个进程内一次只能执行一个线程,消除了这类实体的并行性(即不支持多线程并行执行)。

GIL一次只允许一个线程持有,其他线程都处于等待状态。当该线程完成任务后,GIL被释放,由下一个线程获得。因此,实际上是实现了真正的并发执行。相较于并行程序,并发程序通常在资源消耗上更低,因为创建新进程的开销远大于创建线程的开销。不过需要注意的是,锁的获取与释放操作会影响程序整体的执行速度,带来一定的性能损耗。

但情况并非那么糟糕。后续我们将看到如何在并行编程模型中适应Python线程的这一特性。此外,许多外部库并不依赖GIL,因为它们是用其他语言(如C和Fortran)实现的,因此能够利用多线程的内部机制。其中一个典型例子就是NumPy,这是Python中用于数值计算的基础库。

消除GIL以实现多线程

关于从Python解释器中移除GIL的可能性,这一直是一个热门话题。然而,随着时间推移,这一可能性变得越来越难以实现,因为移除GIL会导致许多官方及第三方包和模块无法使用。

另一种可能是使用除CPython以外的其他Python实现。其中最广泛使用的是PyPy,因其性能较好而著称,但遗憾的是,PyPy也实现了与CPython非常相似的GIL。而Jython(基于Java实现的Python版本)和IronPython(基于.NET实现的版本)则没有GIL,能够利用多线程,从而充分利用多核或多处理器的优势。

Python中的线程与进程

总结来说,线程和进程分别是Python提供给我们用来实现并发和并行程序的工具。

在表1.1中,可以看到这两者的一些特性对比,这些特性在编程时必须予以考虑。

特性 线程 进程
内存共享(进程内) 有共享 无共享
资源消耗 资源消耗较轻 需要较多资源
创建速度与负载 快速创建,负载小 创建较慢,负载较重
需要同步机制 需要同步机制 不需要同步机制

表1.1:Python中线程与进程的对比

Python中的并发与并行

因此,考虑到Python中线程的行为,我们可以修正之前对并发的定义,排除并行的可能性。

并发意味着同时管理多个任务,但它们不一定同时运行。

所以,我们可以想象在Python中,线程各自独立执行任务,并相互竞争执行权。它们会在整体执行流程中交替进行,直到完成任务,如图1.10所示:

而对于Python中的并行编程,进程非常适合同时执行任务,即并行执行。每个进程会被分配一个任务,所有进程能够同时执行其内部的指令,直到程序完成,如图1.11所示:

因此,很明显,对于其他编程语言来说,术语"并发"和"并行"可能会造成混淆,但在Python中,并发和并行由于不共享"同时性"这一共同属性,是两个完全不同的概念。

轻量级并发与Greenlets

如前所述,竞争机制中,线程是实现编程模型的有效工具。

除了线程,Python还提供了另一种可能的替代方案:greenlets。从竞争的角度来看,使用greenlets或线程是等效的,因为在Python中线程从未真正并行执行,因此两者在Python中都能很好地用于并发编程。但greenlets的创建和管理所需资源远少于线程。这就是为什么它们的使用被称为轻量级并发。因此,当需要管理大量简单的I/O操作时,比如在Web服务器中,greenlets经常被使用。我们将在后续章节通过几个简单示例演示如何创建和管理greenlets。

Python中的并行编程

理解了线程和进程在Python中的角色后,我们可以深入探讨与Python语言紧密相关的并行编程。

在Python中,并行编程完全由进程来实现。一个程序被拆分成多个可并行化的子任务,每个子任务被分配给不同的进程。在每个进程中,我们可以选择以同步或异步的方式执行各个步骤。

同步与异步编程

在本书以及许多关于并行编程的在线文档中,经常会提到"同步(synchronous)"和"异步(asynchronous)",有时简称为sync和async。这两个术语指的是两种不同的编程模型。

当我们实现多进程或多线程之间的并行或竞争程序时,往往会不自觉地采用同步结构。这是因为我们通常都来自串行编程背景,习惯这样思考。也就是说,在两个或多个进程(也可以是线程,或者程序中的简单函数)并存的情况下,一个进程(图1.12中的进程1)继续执行,直到某个时刻发起外部调用,将执行权交给另一个进程以获取服务、计算或其他操作。另一个进程(图1.12中的进程2)执行完任务后,将结果返回给在此期间处于等待状态的初始进程。一旦获得所需结果,初始进程恢复执行:

但实际上,异步编程模型比同步模型更高效,无论是在计算资源的利用效率上,还是在程序运行时间上。让我们一起来看看前面的案例,这次以异步方式呈现,如图1.13所示:

与同步情况类似,初始进程(图1.13中的进程1)会继续执行,直到发起启动第二个进程(图1.13中的进程2)的调用。但这一次,初始进程不会中断执行去等待第二个进程完成,而是会继续向前执行,不管何时或如何获得第二个进程的结果。

正如我们所料,异步编程使我们能够在许多情况下节省大量等待外部响应或长时间执行操作的时间。因此,如果想充分利用并行编程的潜力,了解这两种模型非常重要。

至于实际实现,虽然对我们来说尚未完全直观,但完全是可行的。所有编程语言都有内部机制支持其实现。我们将在第6章《使用CUDA进行GPU编程以最大化性能》中深入讲解异步编程。

Map 和 Reduce

并行编程中广泛使用的一种方案是 Map-Reduce,主要基于两个阶段:

  • 映射(Mapping)
  • 归约(Reducing)

第一阶段映射是将程序需要执行的任务拆分为多个部分(子任务),然后将它们分配给不同的进程,这些进程会同时执行这些任务,也就是并行执行。通常,每个进程执行完后会得到一个结果。接下来会有一个阶段,这个阶段与并行执行紧密相关,即将所有结果重新合并的归约阶段。图1.14展示了一个图示,帮助你更好地理解上述内容:

CPU密集型与I/O密集型操作

在设计并行程序阶段,需要特别关注各个任务,评估其中是否存在某些执行时间过长的任务。如果存在这种情况,性能将大幅下降,因为其他所有进程都需要等待该长时间任务完成,才能完成映射阶段。实际上,进入归约阶段时,必须收集每个进程的所有结果。

我们来看一个类似图1.15所示的案例,其中一个并行进程的执行时间远长于其他进程。在这种情况下,所有其他进程都必须等待,才能继续执行并将结果传递给归约阶段。此时,并行编程的性能优势已经不复存在:

因此,在这些情况下,我们必须考虑每个进程(任务)中执行的各种操作。这些任务可能包括内部操作,比如读取文件或调用外部的网络服务。在这种情况下,进程必须等待外部设备的响应,因此执行时间可能无法预测。这类操作称为I/O密集型(I/O bound)。而仅涉及内部CPU计算的操作则称为CPU密集型(CPU bound)。

在并行编程中,当处理子任务或CPU密集型操作时,使用多个进程并行执行指令能够提高程序效率。但在面对I/O密集型操作时,我们需要采用不同的方式。

这种情况下,最合适的编程方式是并发编程,这时线程就发挥作用了。在一个进程内部,我们可以创建多个线程。一个线程继续处理CPU密集型操作,而其他线程负责处理各种I/O密集型操作。当其中一个包含I/O操作的线程在等待外部数据或响应时,其他线程可以继续执行它们的任务。

通过线程的并发执行,我们节省了执行时间:

如图1.16所示,除了主线程(MainThread,进程中始终存在)之外,创建一个额外的线程,可以单独管理(异步或同步)I/O密集型操作,从而允许主线程在此期间继续进行数据处理。

并行编程中的额外注意事项

在处理并发编程时,使用线程尤其需要特别注意共享数据的管理。正如图1.17所示,进程中的线程既有各自独立的内存(其他线程无法访问),也有一个共享的内存空间,里面存放着所有线程都可以访问的对象:

尽管Python解释器中存在GIL,且使用多线程时同一进程内一次只能执行一个线程,但如果不想陷入数据不一致的问题,仍然必须对共享内存空间中的全局对象进行加锁。那么既然GIL保证了同一时刻只有一个线程在执行,为什么还需要加锁呢?

事实是,解释器只负责Python内部对象的线程安全管理,而对于程序执行过程中我们自定义和创建的对象,解释器不会进行控制或加锁,这部分需要我们自己来管理。我们必须负责管理所创建的全局对象的锁,确保不会出现意料之外的结果。

关于这点,正如我们稍后会看到的,Python标准库中除了实现进程和线程之外,还提供了一系列工具模块,帮助我们管理对全局对象的加锁。这里先简单介绍一下。

Threading 和 Multiprocessing 模块

实际编程中,我们可以利用线程和进程实现并发与并行编程,Python标准库中提供了两个模块来支持这一点:threadingmultiprocessing。这两个模块封装了与操作系统交互的函数,帮助我们在Python中创建、执行和管理线程与进程。

注意:Python中没有一个专门叫做"multithreading"的模块,因为Python实际上并不支持多线程并行执行,只能一次执行一个线程。

threading 模块是对底层 _thread 模块的抽象封装,后者提供了多线程的基本原语。threading 模块还提供了大量工具,帮助程序员管理线程并发的复杂工作,比如锁(lock)、条件变量(condition)和信号量(semaphore)。这些功能和工具将在第2章《构建多线程程序》中深入讲解,并配有一系列示例代码,帮助你理解如何以及何时使用它们。

multiprocessing 模块则提供了基于进程的并行实现的有效API。除了进程的创建和管理,该模块还包含大量功能,帮助管理程序中多个进程的共存。例如,QueuePipe 对象允许不同进程之间进行信息(对象)交换;进程池(pool)简化了同时管理多个进程的复杂度。multiprocessing 模块及其功能将在第3章《使用Multiprocessing和mpi4py库》中进行详细讨论。

内存组织与通信

到目前为止,我们讨论的并行编程主要集中在执行层面,特别是当面对支持并行执行的硬件系统时,还需分析内存是如何组织的。

事实上,即使拥有非常强大且高速的CPU,以及多个处理单元(如核心),程序的性能仍然在很大程度上依赖于内存的组织方式。

所有涉及数据传输到内存的操作,通常速度都不如CPU内部操作快。在这些操作过程中,内存会被占用直到内存周期结束,期间其他组件无法使用该内存。

与内存组织密切相关的一个概念是程序内部各组成部分之间的通信。通信形式会严格依赖于特定的内存组织方式,因此并行程序的良好运作必须妥善管理诸如进程和线程等执行实体之间的信息传递,而这一切都通过内存完成。根据内存的组织方式,必须采用不同的机制来同步各程序对象间的信息传递,避免数据不一致、死锁或错误行为等风险。

因此,内存在并行编程性能中扮演极其重要的角色,我们在设计项目时必须重视其表现。

进程内部的内存组织

对于Python程序,主要有三种不同的内存组织模型:

  • 极易并行(Embarrassingly Parallel)
  • 共享内存(Shared Memory)
  • 消息传递(Message Passing)

这三种模型解释如下:

第一种模型"极易并行",指的是程序中的实体(无论是线程还是进程)不需要任何信息交换,能够独立执行到结束,最终将各自结果合并。实际上,有一些特定算法正好符合这种行为,称为"极易并行算法"。

共享内存是Python中线程典型的内存组织模型。一个进程中的线程通过该进程提供的共享内存相互通信。针对这种模型,有多种可能的通信机制,其中一些有效,另一些则不然,我们将在本书中逐步介绍。

消息传递则是进程的内存组织模型。进程之间没有共享内存,因此它们唯一的通信方式是通过消息交换。为此存在多种解决方案,通常以名为消息传递接口(Message Passing Interface,MPI)的成品包形式提供。Python标准库中也包含一个专门用于该任务的MPI模块。

多处理器间的内存组织

到目前为止,我们讨论的都是单台多核CPU的机器。但实际上,并行编程的应用自然扩展到了多CPU的使用。这些CPU可能存在于同一台机器上,也可能分布在通过某种方式互联的多台机器上。

显然,在这里内存的组织同样对并行计算的良好运作起着关键作用。

之前介绍的两种模型------共享内存和消息传递,也在多处理器环境中以以下两种形式扩展:

  • 共享内存
  • 分布式内存

在遵循共享内存模型的系统中,所有存在的处理器都可以访问某一特定内存区域,以共享数据和传递信息。这类系统通常基于一个物理总线,将多个物理上分离的处理器连接起来(这些处理器可以位于同一台机器或不同机器上),如图1.18所示:

每个处理器都有自己的本地内存,通常表现为高速缓存(cache),性能较高,因为CPU与这部分内存之间的数据交换非常频繁。但本地缓存容量有限,而且多个处理器常常需要共享数据来协同工作,而这只能通过连接在总线上的共享内存来完成。

这时情况就变得比较复杂。程序员必须小心管理多个处理器同时使用数据时的同步问题。

例如,一个CPU会从共享内存中取出数据值,复制到其缓存中进行处理。与此同时,另一个CPU也需要同样的数据值,也会从共享内存复制一份到自己的缓存中。过了一段时间,第一个CPU完成了处理,并将结果写回共享内存,更新了该数据的值。但此时,第二个CPU仍在处理一个已经不再有效的旧值,由此导致了数据一致性(coherency)丢失。

因此,显然需要实现类似于单进程内线程的并发管理和同步机制(可以通过硬件实现,也可以通过程序实现)。

那么,为什么还要使用这种模型呢?主要原因是共享内存系统非常快速,因为它们大量依赖硬件而非软件。实际上,许多对共享内存资源访问的控制和同步机制可以通过硬件解决。

另一种模型是广泛应用的分布式内存模型。与前述模型不同的是,它不是通过物理总线将多个CPU连接,而是通过网络互联。事实上,这种模型主要用于位于物理上分离且相距较远的多台机器上的CPU。这类系统的示意图见图1.19:

在这种模型中,每个CPU除了拥有自己的专用缓存外,还有一块本地内存,通常可以全负载使用。当需要与其他CPU共享数据时,数据会通过网络进行传输。这样一来,就不存在数据一致性的问题,因为每个处理器负责管理自己的数据。另一个优势是,由于不再依赖物理总线而只是通过网络连接,因此理论上系统中可添加的CPU数量是无限的。

然而,这种模型的缺点在于网络连接速度不如物理总线,且不同CPU之间需要通过消息传递机制进行通信和数据交换。消息的创建、发送和接收虽然消除了数据一致性问题,但显著降低了程序的执行效率。

分布式编程

分布式内存等复杂模型的出现推动了并行编程的进一步发展,形成了所谓的分布式编程。实际上,程序可以由运行在通过网络连接的不同机器上的不同进程并行执行。如今,这种系统非常普遍并广泛应用,因而有许多Python包提供了相关解决方案。

本书也将涉及并行编程的这一扩展内容,介绍一些网络上开源可用的包,并专门用一章------第5章《利用分布式系统实现并行性》------来讲解它们。

并行编程的性能评估

与并行编程密切相关的一个方面是需要开发一整套方法来评估其性能。这种评估在程序开发过程中非常关键,用以判断所做的设计选择是否合理,或者是否需要考虑其他方案。

当你决定使用并行时,通常是因为你希望在最短时间内解决更大的问题。然而,实现这一目标需要考虑许多因素,比如并行度、硬件条件,尤其是编程模型。因此,必须进行性能分析,以评估在程序开发过程中所做选择的有效性。

有一系列性能指标可以通过计算其数值来衡量程序性能。这些指标本质上是通过适当计算获得的数值,能够帮助我们系统且精确地比较不同程序或算法的表现。其中最著名且最常用的指标是加速比(speedup)。

加速比(Speedup)

加速比是一个数值,用来表达两个系统在执行同一问题时性能的差异。在我们的例子中,加速比 <math xmlns="http://www.w3.org/1998/Math/MathML"> S S </math>S 可以被看作串行程序执行时间 <math xmlns="http://www.w3.org/1998/Math/MathML"> t s t_s </math>ts 与并行程序执行时间 <math xmlns="http://www.w3.org/1998/Math/MathML"> t p t_p </math>tp 的比值。执行时间 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t 是处理单元数量 <math xmlns="http://www.w3.org/1998/Math/MathML"> N N </math>N 的函数,这些处理单元可以是CPU、核心或GPU,但通常用"处理器数量"来称呼 <math xmlns="http://www.w3.org/1998/Math/MathML"> N N </math>N。

因此,并行系统的执行时间可以表示为 <math xmlns="http://www.w3.org/1998/Math/MathML"> t ( N ) t(N) </math>t(N),而串行系统的执行时间则表示为 <math xmlns="http://www.w3.org/1998/Math/MathML"> t ( 1 ) t(1) </math>t(1),因为串行系统相当于只有一个处理器的系统:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> S = t s t p = t ( 1 ) t ( N ) S = \frac{t_s}{t_p} = \frac{t(1)}{t(N)} </math>S=tpts=t(N)t(1)

换句话说,加速比告诉我们采用并行方案相比于串行方案所带来的性能提升情况。此外,如果将处理器数量 <math xmlns="http://www.w3.org/1998/Math/MathML"> N N </math>N 与加速比指数 <math xmlns="http://www.w3.org/1998/Math/MathML"> S S </math>S 进行比较,我们可以进一步对算法或程序进行分类:

  • 如果 <math xmlns="http://www.w3.org/1998/Math/MathML"> S = N S = N </math>S=N,则加速比是线性的或理想的
  • 如果 <math xmlns="http://www.w3.org/1998/Math/MathML"> S < N S < N </math>S<N,则加速比是实际的
  • 如果 <math xmlns="http://www.w3.org/1998/Math/MathML"> S > N S > N </math>S>N,则加速比是超线性的

加速比指数还与阿姆达尔定律(Amdahl's Law)密切相关,后者在并行计算中被广泛应用。该定律用于预测在拥有无限处理器时程序所能达到的最大加速比。它描述了程序中串行代码所占百分比如何决定最大可达到的加速比值:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> S ≤ 1 1 − α S \leq \frac{1}{1 - \alpha} </math>S≤1−α1

其中, <math xmlns="http://www.w3.org/1998/Math/MathML"> S S </math>S 是加速比指数, <math xmlns="http://www.w3.org/1998/Math/MathML"> α \alpha </math>α 是程序中以并行方式执行部分所占的时间比例。

因此,如果一个程序并行执行的部分耗时90分钟,串行执行的部分耗时10分钟,总共100分钟,那么 <math xmlns="http://www.w3.org/1998/Math/MathML"> α = 0.9 \alpha = 0.9 </math>α=0.9。在这种情况下,最大可获得的加速比为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> S ≤ 1 1 − 0.9 = 1 0.1 = 10 S \leq \frac{1}{1 - 0.9} = \frac{1}{0.1} = 10 </math>S≤1−0.91=0.11=10

我们的程序最大可获得的加速比为10。随着不断增加执行时的核心或处理器数量,程序的性能会逐渐提升,直到加速比接近10为止。一旦达到这个值,即使继续增加更多的处理器或核心进行并行,也不会带来进一步的性能提升。详见图1.20所示的曲线:

因此,如果我们想不断提高程序或算法的加速比,就必须尽可能减少串行代码部分。只有这样,通过增加处理器(或核心)数量,才能获得更接近线性的性能提升。

此外,一旦代码中可并行的部分达到最大化,下一步任务就是找到一个合适的平衡点,使加速比尽可能高,同时使用恰当数量的处理器(见图1.21)。这样可以避免因添加过多处理器而带来的不必要的负载和过度并行。

扩展性(Scaling)

扩展性是指系统通过增加额外硬件(如处理器数量)来提升计算效率的能力。在并行计算的上下文中,扩展性指的是并行效率,即随着处理器数量增加,实际加速比与理想加速比的比值。

扩展性可分为两种类型:

  • 强扩展性(Strong scaling)
  • 弱扩展性(Weak scaling)

强扩展性指在处理器数量增加的同时,问题规模保持不变。在理想情况下,这将导致每个处理器的工作量逐渐减少。

弱扩展性指处理器数量与问题规模同时增加,在这种情况下,每个处理器的工作量应保持不变。

我们已经通过应用阿姆达尔定律(Amdahl's law)看到强扩展性的衡量方法,即在保持问题规模不变的情况下增加处理器数量来计算加速比。在这种情况下,该定律告诉我们由于无法将代码100%并行化,加速比存在最大限制。此外,还有许多其他因素使得随着处理器数量增加,保持良好的强扩展性越来越困难。例如,处理器数量增加时,需要进行的通信工作也随之增加。

而弱扩展性则没有上限,因此理论上可以无限增长。这一点得到了古斯塔夫森定律(Gustafson's law)的支持,该定律对加速比的计算方式不同于阿姆达尔定律:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> S = ( 1 − α ) + α × N S = (1 - \alpha) + \alpha \times N </math>S=(1−α)+α×N

实际上,古斯塔夫森认为随着问题规模的增大,只有并行部分 <math xmlns="http://www.w3.org/1998/Math/MathML"> α \alpha </math>α 随处理器数量增加而增加,而串行部分 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( 1 − α ) (1-\alpha) </math>(1−α) 保持不变。图1.22显示了加速比随处理器数量增加而线性增长的趋势,因此理论上不会达到上限。

得益于强扩展性和弱扩展性的概念,以及阿姆达尔定律和古斯塔夫森定律,我们可以得出一些有益的结论:对于小规模问题,使用小型系统更为合适;对于大规模问题,使用大型系统则更为有效。

Python中的性能基准测试(Benchmarking)

在不同条件下系统地测试性能的行为称为基准测试。到目前为止,我们从理论角度了解了程序性能的评估。那么实际操作层面呢?Python中有一系列工具,可以帮助我们测量程序或代码片段的性能。

在后续章节中,我们将通过一些实用示例展示如何进行这些测量。例如,为了计算某段代码的执行时间(也有助于计算加速比),我们将使用Python标准库中的time模块。该模块提供多种时钟类型的访问接口,调用time()方法可以获取真实的秒表读数。通过计算两次调用的时间差,就可以得到代码段的执行时间:

lua 复制代码
import time

started = time.time()
# 这里写代码
elapsed = time.time()
print("Elapsed time =", elapsed - started)

性能分析(Profiling)

分析程序中哪些部分影响性能,并识别潜在瓶颈的过程称为性能分析(profiling)。

在Python中,目前有多种工具可用于性能分析,每种工具都有其独特的优势。针对内存资源的消耗,可以使用一个强大的工具:memory_profiler包。该模块允许监控Python中不同进程或任务的内存使用情况。此外,它还能对代码进行逐行分析,评估资源消耗,因此也可以作为行级性能分析器使用。

总结

本章详细讨论了并行编程背后的大部分核心概念。并行编程随着可用技术的发展不断演进,其相关的概念和实体也逐步成熟。操作系统中运行的进程和线程,在并行编程中通过进程和线程对象进行对应,这些对象可以使用Python标准库中的threadingmultiprocessing模块来实现。

在接下来的两章中,我们将学习如何使用这两个模块进行并行编程,并充分发挥它们提供的全部功能。

相关推荐
淦暴尼4 分钟前
银行客户流失预测分析
python·深度学习·算法
那雨倾城37 分钟前
PiscCode使用OpenCV实现漂浮方块特效
python·opencv
awonw38 分钟前
[python][flask]Flask-Principal 使用详解
开发语言·python·flask
广东小640 分钟前
昇思学习营-【模型推理和性能优化】学习心得_20250730
学习·性能优化
赵英英俊2 小时前
Python day26
开发语言·python
你怎么知道我是队长2 小时前
python---eval函数
开发语言·javascript·python
Rockson2 小时前
期货实时行情接口接入教程
python·api
awonw4 小时前
[python][基础]Flask 技术栈
开发语言·python·flask
bright_colo4 小时前
Python-初学openCV——图像预处理(四)——滤波器
python·opencv·计算机视觉
Nandeska4 小时前
一、Python环境、Jupyter与Pycharm
python·jupyter·pycharm