Python-高性能指南第二版-全-
Python 高性能指南第二版(全)
原文:
zh.annas-archive.org/md5/0cbd3a9c4ec1a970019c9e46e31fbbc7译者:飞龙
前言
近年来,Python 编程语言因其直观、有趣的语法以及丰富的顶级第三方库而受到极大的欢迎。Python 已成为许多入门和高级大学课程以及科学和工程等数值密集型领域的首选语言。其主要应用还在于机器学习、系统脚本和 Web 应用程序。
参考 Python 解释器 CPython,与底层语言(如 C、C++和 Fortran)相比,通常被认为效率不高。CPython 性能不佳的原因在于程序指令是由解释器处理的,而不是编译成高效的机器代码。虽然使用解释器有几个优点,例如可移植性和额外的编译步骤,但它确实在程序和机器之间引入了一个额外的间接层,这导致了效率降低的执行。
多年来,已经开发了许多策略来克服 CPython 的性能不足。本书旨在填补这一空白,并将教你如何始终如一地实现 Python 程序的良好性能。
本书将吸引广泛的读者,因为它涵盖了数值和科学代码的优化策略,以及提高 Web 服务和应用程序响应时间的策略。
本书可以从头到尾阅读;然而,章节被设计成是自包含的,这样如果你已经熟悉了前面的主题,你也可以跳到感兴趣的章节。
本书涵盖的内容
第一章,基准和性能分析,将教你如何评估 Python 程序的性能,以及如何识别和隔离代码中缓慢部分的实用策略。
第二章,纯 Python 优化,讨论了如何通过使用 Python 标准库和纯 Python 第三方模块中可用的有效数据结构和算法来提高你的运行时间。
第三章,使用 NumPy 和 Pandas 进行快速数组操作,是关于 NumPy 和 Pandas 包的指南。掌握这些包将允许你使用表达性强、简洁的接口实现快速数值算法。
第四章,使用 Cython 进行 C 性能优化,是关于 Cython 语言的教程,它使用与 Python 兼容的语法生成高效的 C 代码。
第五章,探索编译器,涵盖了可以用来将 Python 编译成高效机器代码的工具。本章将教你如何使用 Numba,一个针对 Python 函数的优化编译器,以及 PyPy,一个可以在运行时执行和优化 Python 程序的替代解释器。
第六章,实现并发,是异步和响应式编程的指南。我们将学习关键术语和概念,并演示如何使用 asyncio 和 RxPy 框架编写干净、并发的代码。
第七章,并行处理,是关于在多核处理器和 GPU 上实现并行编程的介绍。在本章中,您将学习如何使用 multiprocessing 模块以及通过使用 Theano 和 Tensorflow 表达您的代码来实现并行性。
第八章,分布式处理,通过专注于在分布式系统上运行并行算法来解决大规模问题和大数据,扩展了前一章的内容。本章将涵盖 Dask、PySpark 和 mpi4py 库。
第九章,针对高性能的设计,讨论了开发、测试和部署高性能 Python 应用程序的一般优化策略和最佳实践。
您需要为此书准备的内容
本书中的软件在 Python 3.5 和 Ubuntu 16.04 版本上进行了测试。然而,大多数示例也可以在 Windows 和 Mac OS X 操作系统上运行。
推荐的安装 Python 及其相关库的方式是通过 Anaconda 发行版,可以从www.continuum.io/downloads下载,适用于 Linux、Windows 和 Mac OS X。
本书面向的对象
本书旨在帮助 Python 开发者提高其应用程序的性能;预期读者具备 Python 的基本知识。
习惯用法
在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“总结来说,我们将实现一个名为ParticleSimulator.evolve_numpy的方法,并将其与纯 Python 版本(重命名为ParticleSimulator.evolve_python)进行基准测试。”
代码块设置如下:
def square(x):
return x * x
inputs = [0, 1, 2, 3, 4]
outputs = pool.map(square, inputs)
当我们希望引起您对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
def square(x):
return x * x
inputs = [0, 1, 2, 3, 4]
outputs = pool.map(square, inputs)
任何命令行输入或输出都如下所示:
$ time python -c 'import pi; pi.pi_serial()'
real 0m0.734s
user 0m0.731s
sys 0m0.004s
新术语和重要词汇以粗体显示。屏幕上看到的词汇,例如在菜单或对话框中,在文本中如下显示:“在右侧,点击 Callee Map 标签将显示函数成本的图表。”
警告或重要提示以如下框的形式出现。
小贴士和技巧以如下形式出现。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。
要向我们发送一般反馈,只需发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。
如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的 SUPPORT 标签上。
-
点击代码下载与错误清单。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
文件下载完成后,请确保使用最新版本的软件解压或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Python-High-Performance-Second-Edition。我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/PythonHighPerformanceSecondEdition_ColorImages.pdf下载此文件。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误表部分。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误表部分。
侵权
互联网上版权材料的侵权是一个持续存在的问题,跨越所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过copyright@packtpub.com与我们联系,并提供疑似侵权材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以联系questions@packtpub.com,我们将尽力解决问题。
第一章:基准测试和分析
识别程序中的慢速部分是加快代码速度时最重要的任务。幸运的是,在大多数情况下,导致应用程序变慢的代码只是程序的一小部分。通过定位这些关键部分,你可以专注于需要改进的部分,而无需在微优化上浪费时间。
分析是允许我们定位应用程序中最资源密集的部分的技术。分析器是一个运行应用程序并监控每个函数执行时间的程序,从而检测应用程序花费最多时间的函数。
Python 提供了几个工具来帮助我们找到这些瓶颈并测量重要的性能指标。在本章中,我们将学习如何使用标准的 cProfile 模块和第三方包 line_profiler。我们还将学习如何通过 memory_profiler 工具分析应用程序的内存消耗。我们还将介绍另一个有用的工具 KCachegrind,它可以用来图形化显示各种分析器产生的数据。
基准测试是用于评估应用程序总执行时间的脚本。我们将学习如何编写基准测试以及如何准确测量程序的时间。
本章我们将涵盖的主题列表如下:
-
高性能编程的一般原则
-
编写测试和基准测试
-
Unix 的
time命令 -
Python 的
timeit模块 -
使用
pytest进行测试和基准测试 -
分析你的应用程序
-
cProfile标准工具 -
使用 KCachegrind 解释分析结果
-
line_profiler和memory_profiler工具 -
通过
dis模块反汇编 Python 代码
设计你的应用程序
当设计一个性能密集型程序时,第一步是编写你的代码,不要担心小的优化:
“过早优化是万恶之源。”
- 唐纳德·克努特
在早期开发阶段,程序的设计可能会迅速变化,可能需要大量重写和组织代码库。通过在无需优化的负担下测试不同的原型,你可以自由地投入时间和精力来确保程序产生正确的结果,并且设计是灵活的。毕竟,谁需要运行速度快但给出错误答案的应用程序?
当优化代码时你应该记住的咒语如下:
-
让它运行:我们必须让软件处于工作状态,并确保它产生正确的结果。这个探索阶段有助于更好地理解应用程序并在早期阶段发现主要的设计问题。
-
正确地做:我们希望确保程序的设计是稳固的。在尝试任何性能优化之前应该进行重构。这实际上有助于将应用程序分解成独立且易于维护的单元。
-
使其快速:一旦我们的程序运行良好且结构合理,我们就可以专注于性能优化。如果内存使用成为问题,我们可能还想优化内存使用。
在本节中,我们将编写并分析一个 粒子模拟器 测试应用程序。模拟器是一个程序,它接受一些粒子,并根据我们施加的一组定律模拟它们随时间的变化。这些粒子可以是抽象实体,也可以对应于物理对象,例如在桌面上移动的台球、气体中的分子、在空间中移动的恒星、烟雾粒子、室内的流体等等。
计算机模拟在物理学、化学、天文学和其他许多学科领域都很有用。用于模拟系统的应用程序特别注重性能,科学家和工程师花费大量时间优化这些代码。为了研究现实系统,通常需要模拟大量的物体,并且任何小的性能提升都至关重要。
在我们的第一个示例中,我们将模拟一个包含粒子围绕中心点以不同速度不断旋转的系统,就像时钟的指针一样。
运行我们的模拟所需的信息将是粒子的起始位置、速度和旋转方向。从这些元素中,我们必须计算出粒子在下一时刻的位置。以下图示了一个示例系统。系统的原点是 (0, 0) 点,位置由 x、y 向量表示,速度由 vx、vy 向量表示:

圆周运动的基本特征是粒子始终沿着连接粒子和中心的连线垂直移动。要移动粒子,我们只需沿着运动方向采取一系列非常小的步骤(这相当于在很短的时间间隔内推进系统)来改变位置,如下图所示:

我们将首先以面向对象的方式设计应用程序。根据我们的要求,自然有一个通用的 Particle 类来存储粒子的位置 x 和 y 以及它们的角速度 ang_vel:
class Particle:
def __init__(self, x, y, ang_vel):
self.x = x
self.y = y
self.ang_vel = ang_vel
注意,我们接受所有参数的正负数(ang_vel 的符号将简单地确定旋转方向)。
另一个类,称为 ParticleSimulator,将封装运动定律,并负责随时间改变粒子的位置。__init__ 方法将存储 Particle 实例的列表,而 evolve 方法将根据我们的定律改变粒子位置。
我们希望粒子围绕对应于 x=0 和 y=0 坐标的点以恒定速度旋转。粒子的方向始终垂直于从中心的方向(参考本章第一图)。为了找到沿 x 和 y 轴的运动方向(对应于 Python 的 v_x 和 v_y 变量),可以使用以下公式:
v_x = -y / (x**2 + y**2)**0.5
v_y = x / (x**2 + y**2)**0.5
如果我们让我们的一个粒子运动,经过一定的时间 t,它将沿着圆形路径到达另一个位置。我们可以通过将时间间隔,t,划分为微小的时步,dt,来近似圆形轨迹,其中粒子沿着圆的切线方向直线运动。最终结果只是圆形运动的近似。为了避免强烈的发散,例如以下图中所示,必须采取非常小的时间步长:

以更简化的方式,我们必须执行以下步骤来计算时间 t 时的粒子位置:
-
计算运动方向(
v_x和v_y)。 -
计算位移(
d_x和d_y),这是时间步长、角速度和运动方向的乘积。 -
重复步骤 1 和 2,直到覆盖总时间 t。
以下代码显示了完整的 ParticleSimulator 实现:
class ParticleSimulator:
def __init__(self, particles):
self.particles = particles
def evolve(self, dt):
timestep = 0.00001
nsteps = int(dt/timestep)
for i in range(nsteps):
for p in self.particles:
# 1\. calculate the direction
norm = (p.x**2 + p.y**2)**0.5
v_x = -p.y/norm
v_y = p.x/norm
# 2\. calculate the displacement
d_x = timestep * p.ang_vel * v_x
d_y = timestep * p.ang_vel * v_y
p.x += d_x
p.y += d_y
# 3\. repeat for all the time steps
我们可以使用 matplotlib 库来可视化我们的粒子。这个库不包括在 Python 标准库中,并且可以使用 pip install matplotlib 命令轻松安装。
或者,您可以使用包含 matplotlib 和本书中使用的其他大多数第三方包的 Anaconda Python 发行版([store.continuum.io/cshop/anaconda/](https://store.continuum.io/cshop/anaconda/))。Anaconda 是免费的,并且适用于 Linux、Windows 和 Mac。
为了制作交互式可视化,我们将使用 matplotlib.pyplot.plot 函数显示粒子作为点,并使用 matplotlib.animation.FuncAnimation 类来动画化粒子随时间的变化。
visualize 函数接受一个 ParticleSimulator 实例作为参数,并在动画图中显示轨迹。使用 matplotlib 工具显示粒子轨迹的必要步骤如下:
-
设置坐标轴并使用
plot函数显示粒子。plot函数接受一个 x 和 y 坐标列表。 -
编写一个初始化函数,
init,和一个函数,animate,使用line.set_data方法更新 x 和 y 坐标。 -
通过传递
init和animate函数以及interval参数(指定更新间隔)和blit(提高图像更新率)来创建一个FuncAnimation实例。 -
使用
plt.show()运行动画:
from matplotlib import pyplot as plt
from matplotlib import animation
def visualize(simulator):
X = [p.x for p in simulator.particles]
Y = [p.y for p in simulator.particles]
fig = plt.figure()
ax = plt.subplot(111, aspect='equal')
line, = ax.plot(X, Y, 'ro')
# Axis limits
plt.xlim(-1, 1)
plt.ylim(-1, 1)
# It will be run when the animation starts
def init():
line.set_data([], [])
return line, # The comma is important!
def animate(i):
# We let the particle evolve for 0.01 time units
simulator.evolve(0.01)
X = [p.x for p in simulator.particles]
Y = [p.y for p in simulator.particles]
line.set_data(X, Y)
return line,
# Call the animate function each 10 ms
anim = animation.FuncAnimation(fig,
animate,
init_func=init,
blit=True,
interval=10)
plt.show()
为了测试,我们定义了一个小函数test_visualize,它使一个由三个粒子组成的系统以不同的方向旋转。请注意,第三个粒子的旋转速度比其他粒子快三倍:
def test_visualize():
particles = [Particle(0.3, 0.5, 1),
Particle(0.0, -0.5, -1),
Particle(-0.1, -0.4, 3)]
simulator = ParticleSimulator(particles)
visualize(simulator)
if __name__ == '__main__':
test_visualize()
test_visualize 函数有助于图形化地理解系统时间演变。在下一节中,我们将编写更多的测试函数,以正确验证程序正确性和测量性能。
编写测试和基准测试
现在我们有一个可工作的模拟器,我们可以开始测量我们的性能并调整我们的代码,以便模拟器可以处理尽可能多的粒子。作为第一步,我们将编写一个测试和一个基准测试。
我们需要一个测试来检查模拟产生的结果是否正确。优化程序通常需要采用多种策略;随着我们多次重写代码,错误可能很容易被引入。一个稳固的测试套件确保在每次迭代中实现都是正确的,这样我们就可以自由地尝试不同的事情,并充满信心地认为,如果测试套件通过,代码仍然会按预期工作。
我们的测试将使用三个粒子,模拟 0.1 时间单位,并将结果与参考实现的结果进行比较。组织测试的一个好方法是为应用程序的每个不同方面(或单元)使用一个单独的函数。由于我们当前的功能包含在evolve方法中,我们的函数将命名为test_evolve。以下代码显示了test_evolve的实现。请注意,在这种情况下,我们通过fequal函数比较浮点数,直到一定的精度:
def test_evolve():
particles = [Particle( 0.3, 0.5, +1),
Particle( 0.0, -0.5, -1),
Particle(-0.1, -0.4, +3)]
simulator = ParticleSimulator(particles)
simulator.evolve(0.1)
p0, p1, p2 = particles
def fequal(a, b, eps=1e-5):
return abs(a - b) < eps
assert fequal(p0.x, 0.210269)
assert fequal(p0.y, 0.543863)
assert fequal(p1.x, -0.099334)
assert fequal(p1.y, -0.490034)
assert fequal(p2.x, 0.191358)
assert fequal(p2.y, -0.365227)
if __name__ == '__main__':
test_evolve()
测试确保了我们的功能正确性,但关于其运行时间提供的信息很少。基准测试是一个简单且具有代表性的用例,可以运行以评估应用程序的运行时间。基准测试对于跟踪我们程序每个新版本的运行速度非常有用。
我们可以通过实例化具有随机坐标和角速度的千个Particle对象,并将它们提供给ParticleSimulator类来编写一个代表性的基准测试。然后,我们让系统演变 0.1 时间单位:
from random import uniform
def benchmark():
particles = [Particle(uniform(-1.0, 1.0),
uniform(-1.0, 1.0),
uniform(-1.0, 1.0))
for i in range(1000)]
simulator = ParticleSimulator(particles)
simulator.evolve(0.1)
if __name__ == '__main__':
benchmark()
测量基准测试的时间
通过 Unix time 命令可以非常简单地测量基准测试的时间。使用time命令,如下所示,你可以轻松地测量任意进程的执行时间:
$ time python simul.py
real 0m1.051s
user 0m1.022s
sys 0m0.028s
time命令在 Windows 上不可用。要在 Windows 上安装 Unix 工具,如time,您可以使用从官方网站下载的cygwin shell(www.cygwin.com/)。或者,您可以使用类似的 PowerShell 命令,如Measure-Command(msdn.microsoft.com/en-us/powershell/reference/5.1/microsoft.powershell.utility/measure-command),来测量执行时间。
默认情况下,time显示三个指标:
-
real: 从开始到结束运行进程的实际时间,就像用秒表测量的一样 -
user: 所有 CPU 在计算过程中花费的累积时间 -
sys: 所有 CPU 在系统相关任务(如内存分配)上花费的累积时间
注意,有时user + sys可能大于real,因为多个处理器可能并行工作。
time还提供了更丰富的格式化选项。要了解概述,你可以查看其手册(使用man time命令)。如果你想要所有可用指标的摘要,可以使用-v选项。
Unix 的time命令是衡量程序性能最简单和最直接的方法之一。为了进行准确的测量,基准测试应该设计得足够长,执行时间(以秒为单位)足够长,这样与应用程序执行时间相比,进程的设置和拆除时间就很小。user指标适合作为 CPU 性能的监控指标,而real指标还包括在等待 I/O 操作时花费在其他进程上的时间。
另一种方便计时 Python 脚本的方法是timeit模块。该模块将代码片段在循环中运行n次,并测量总执行时间。然后,它重复相同的操作r次(默认情况下,r的值为3)并记录最佳运行时间。由于这种计时方案,timeit是准确计时独立小语句的合适工具。
timeit模块可以用作 Python 包,从命令行或从IPython使用。
IPython 是一种改进 Python 解释器交互性的 Python shell 设计。它增强了 tab 补全和许多用于计时、分析和调试代码的实用工具。我们将使用这个 shell 在整本书中尝试代码片段。IPython shell 接受魔法命令--以%符号开始的语句,这些语句增强了 shell 的特殊行为。以%%开始的命令称为单元魔法,它可以应用于多行代码片段(称为单元)。
IPython 在大多数 Linux 发行版中通过pip提供,并包含在 Anaconda 中。
你可以使用 IPython 作为常规 Python shell(ipython),但它也提供基于 Qt 的版本(ipython qtconsole)和强大的基于浏览器的界面(jupyter notebook)。
在 IPython 和命令行界面中,可以使用-n和-r选项指定循环或重复的次数。如果没有指定,它们将由timeit自动推断。从命令行调用timeit时,你也可以通过-s选项传递一些设置代码,这将执行基准测试之前。在以下代码片段中,演示了 IPython 命令行和 Python 模块版本的timeit:
# IPython Interface
$ ipython
In [1]: from simul import benchmark
In [2]: %timeit benchmark()
1 loops, best of 3: 782 ms per loop
# Command Line Interface
$ python -m timeit -s 'from simul import benchmark' 'benchmark()'
10 loops, best of 3: 826 msec per loop
# Python Interface
# put this function into the simul.py script
import timeit
result = timeit.timeit('benchmark()',
setup='from __main__ import benchmark',
number=10)
# result is the time (in seconds) to run the whole loop
result = timeit.repeat('benchmark()',
setup='from __main__ import benchmark',
number=10,
repeat=3)
# result is a list containing the time of each repetition (repeat=3 in this case)
注意,虽然命令行和 IPython 接口会自动推断一个合理的循环次数n,但 Python 接口需要你通过number参数显式指定一个值。
使用 pytest-benchmark 进行更好的测试和基准测试
Unix 的time命令是一个多功能的工具,可以用来评估各种平台上小型程序的运行时间。对于更大的 Python 应用程序和库,一个更全面的解决方案,它同时处理测试和基准测试的是pytest,结合其pytest-benchmark插件。
在本节中,我们将使用pytest测试框架为我们的应用程序编写一个简单的基准测试。对于感兴趣的读者,可以在doc.pytest.org/en/latest/找到的pytest文档是了解框架及其用途的最佳资源。
您可以使用pip install pytest命令从控制台安装pytest。同样,可以通过发出pip install pytest-benchmark命令来安装基准测试插件。
测试框架是一组工具,它简化了编写、执行和调试测试的过程,并提供丰富的测试结果报告和总结。当使用pytest框架时,建议将测试代码与应用程序代码分开。在下面的示例中,我们创建了test_simul.py文件,其中包含test_evolve函数:
from simul import Particle, ParticleSimulator
def test_evolve():
particles = [Particle( 0.3, 0.5, +1),
Particle( 0.0, -0.5, -1),
Particle(-0.1, -0.4, +3)]
simulator = ParticleSimulator(particles)
simulator.evolve(0.1)
p0, p1, p2 = particles
def fequal(a, b, eps=1e-5):
return abs(a - b) < eps
assert fequal(p0.x, 0.210269)
assert fequal(p0.y, 0.543863)
assert fequal(p1.x, -0.099334)
assert fequal(p1.y, -0.490034)
assert fequal(p2.x, 0.191358)
assert fequal(p2.y, -0.365227)
pytest可执行文件可以从命令行使用,以发现和运行包含在 Python 模块中的测试。要执行特定的测试,我们可以使用pytest path/to/module.py::function_name语法。要执行test_evolve,我们可以在控制台中输入以下命令以获得简单但信息丰富的输出:
$ pytest test_simul.py::test_evolve
platform linux -- Python 3.5.2, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: /home/gabriele/workspace/hiperf/chapter1, inifile: plugins:
collected 2 items
test_simul.py .
=========================== 1 passed in 0.43 seconds ===========================
一旦我们有了测试,就可以使用pytest-benchmark插件将测试作为基准来执行。如果我们修改test函数,使其接受一个名为benchmark的参数,pytest框架将自动将benchmark资源作为参数传递(在pytest术语中,这些资源被称为fixtures)。可以通过传递我们打算基准测试的函数作为第一个参数,然后是额外的参数来调用基准资源。在下面的代码片段中,我们展示了基准测试ParticleSimulator.evolve函数所需的编辑:
from simul import Particle, ParticleSimulator
def test_evolve(benchmark):
# ... previous code
benchmark(simulator.evolve, 0.1)
要运行基准测试,只需重新运行pytest test_simul.py::test_evolve命令即可。生成的输出将包含有关test_evolve函数的详细计时信息,如下所示:

对于每个收集到的测试,pytest-benchmark将多次执行基准函数,并提供其运行时间的统计总结。前面显示的输出非常有趣,因为它显示了运行时间在不同运行之间的变化。
在这个例子中,test_evolve中的基准测试运行了34次(Rounds列),其时间在29到41毫秒(Min和Max)之间,Average和Median时间大约在30毫秒,这实际上非常接近获得的最佳时间。这个例子展示了运行之间可能会有很大的性能变化,并且当使用time等单次工具进行计时的时候,多次运行程序并记录一个代表性值,如最小值或中位数,是一个好主意。
pytest-benchmark有许多更多功能和选项,可用于进行准确的计时和分析结果。有关更多信息,请参阅pytest-benchmark.readthedocs.io/en/stable/usage.html上的文档。
使用 cProfile 查找瓶颈
在评估程序的正确性和计时执行时间后,我们就可以确定需要调整性能的代码部分。这些部分通常与程序的大小相比非常小。
Python 标准库中提供了两个分析模块:
-
profile模块:这个模块是用纯 Python 编写的,会给程序执行增加显著的开销。它在标准库中的存在是因为它具有广泛的平台支持,并且更容易扩展。 -
cProfile模块:这是主要的分析模块,其接口与profile相当。它用 C 语言编写,开销小,适合作为通用分析器。
cProfile模块可以用三种不同的方式使用:
-
从命令行
-
作为 Python 模块
-
使用 IPython
cProfile不需要对源代码进行任何修改,可以直接在现有的 Python 脚本或函数上执行。您可以从命令行这样使用cProfile:
$ python -m cProfile simul.py
这将打印出包含应用程序中所有调用函数的多个分析指标的冗长输出。您可以使用-s选项按特定指标排序输出。在下面的代码片段中,输出按tottime指标排序,这里将对其进行描述:
$ python -m cProfile **-s tottime** simul.py
可以通过传递-o选项将cProfile生成数据保存到输出文件。cProfile使用的格式可由stats模块和其他工具读取。-o选项的使用方法如下:
$ python -m cProfile **-o prof.out** simul.py
将cProfile作为 Python 模块使用需要以以下方式调用cProfile.run函数:
from simul import benchmark
import cProfile
cProfile.run("benchmark()")
您也可以在cProfile.Profile对象的方法调用之间包裹一段代码,如下所示:
from simul import benchmark
import cProfile
pr = cProfile.Profile()
pr.enable()
benchmark()
pr.disable()
pr.print_stats()
cProfile也可以与 IPython 交互式使用。%prun魔法命令允许您分析单个函数调用,如下所示:

cProfile输出分为五个列:
-
ncalls:函数被调用的次数。 -
tottime:函数中不考虑对其他函数的调用所花费的总时间。 -
cumtime:函数中包括其他函数调用所花费的时间。 -
percall:函数单次调用所花费的时间——可以通过将总时间或累积时间除以调用次数来获得。 -
filename:lineno:文件名和相应的行号。当调用 C 扩展模块时,此信息不可用。
最重要的指标是tottime,即不包括子调用的函数体实际花费的时间,这告诉我们瓶颈的确切位置。
毫不奇怪,大部分时间都花在了evolve函数上。我们可以想象,循环是代码中需要性能调优的部分。
cProfile只提供函数级别的信息,并不告诉我们哪些具体的语句是瓶颈所在。幸运的是,正如我们将在下一节中看到的,line_profiler工具能够提供函数中逐行花费的时间信息。
对于具有大量调用和子调用的程序,分析cProfile文本输出可能会令人望而却步。一些可视化工具通过改进交互式图形界面来辅助任务。
KCachegrind 是一个用于分析cProfile生成的分析输出的图形用户界面(GUI)。
KCachegrind 可在 Ubuntu 16.04 官方仓库中找到。Qt 端口,QCacheGrind,可以从sourceforge.net/projects/qcachegrindwin/下载到 Windows 上。Mac 用户可以通过遵循博客文章中的说明来使用 Mac Ports 编译 QCacheGrind(www.macports.org/),该博客文章位于blogs.perl.org/users/rurban/2013/04/install-kachegrind-on-macosx-with-ports.html。
KCachegrind 不能直接读取cProfile生成的输出文件。幸运的是,第三方 Python 模块pyprof2calltree能够将cProfile输出文件转换为 KCachegrind 可读的格式。
您可以使用命令pip install pyprof2calltree从 Python 包索引安装pyprof2calltree。
为了最好地展示 KCachegrind 的功能,我们将使用一个具有更复杂结构的另一个示例。我们定义了一个recursive函数,名为factorial,以及两个使用factorial的其他函数,分别命名为taylor_exp和taylor_sin。它们代表了exp(x)和sin(x)的泰勒近似的多项式系数:
def factorial(n):
if n == 0:
return 1.0
else:
return n * factorial(n-1)
def taylor_exp(n):
return [1.0/factorial(i) for i in range(n)]
def taylor_sin(n):
res = []
for i in range(n):
if i % 2 == 1:
res.append((-1)**((i-1)/2)/float(factorial(i)))
else:
res.append(0.0)
return res
def benchmark():
taylor_exp(500)
taylor_sin(500)
if __name__ == '__main__':
benchmark()
要访问配置文件信息,我们首先需要生成cProfile输出文件:
$ python -m cProfile -o prof.out taylor.py
然后,我们可以使用pyprof2calltree转换输出文件并启动 KCachegrind:
$ pyprof2calltree -i prof.out -o prof.calltree
$ kcachegrind prof.calltree # or qcachegrind prof.calltree
下面的截图显示了输出:

上一张截图显示了 KCachegrind 用户界面。在左侧,我们有一个与cProfile相当输出的输出。实际的列名略有不同:Incl.对应于cProfile模块的cumtime,Self 对应于tottime。通过在菜单栏上点击相对按钮,以百分比的形式给出值。通过点击列标题,您可以按相应的属性排序。
在右上角,点击调用图标签将显示函数成本的图表。在图表中,函数花费的时间百分比与矩形的面积成正比。矩形可以包含表示对其他函数子调用的子矩形。在这种情况下,我们可以很容易地看到有两个矩形表示factorial函数。左边的对应于taylor_exp的调用,右边的对应于taylor_sin的调用。
在右下角,您可以通过点击调用图标签显示另一个图表,即调用图。调用图是函数之间调用关系的图形表示;每个方块代表一个函数,箭头表示调用关系。例如,taylor_exp调用factorial 500 次,而taylor_sin调用factorial 250 次。KCachegrind 还检测递归调用:factorial调用自身 187250 次。
您可以通过双击矩形导航到调用图或调用者图标签;界面将相应更新,显示时间属性相对于所选函数。例如,双击taylor_exp将导致图表更改,仅显示taylor_exp对总成本的贡献。
Gprof2Dot (github.com/jrfonseca/gprof2dot)是另一个流行的工具,用于生成调用图。从支持的剖析器产生的输出文件开始,它将生成一个表示调用图的.dot图表。
使用 line_profiler 逐行分析
现在我们知道了要优化的函数,我们可以使用line_profiler模块,该模块以逐行的方式提供关于时间花费的信息。这在难以确定哪些语句成本高昂的情况下非常有用。line_profiler模块是一个第三方模块,可在 Python 包索引上找到,并可通过遵循github.com/rkern/line_profiler上的说明进行安装。
为了使用line_profiler,我们需要将@profile装饰器应用到我们打算监控的函数上。请注意,您不需要从另一个模块导入profile函数,因为它在运行kernprof.py分析脚本时被注入到全局命名空间中。为了为我们程序生成分析输出,我们需要将@profile装饰器添加到evolve函数上:
@profile
def evolve(self, dt):
# code
kernprof.py 脚本将生成一个输出文件,并将分析结果打印到标准输出。我们应该使用以下两个选项来运行脚本:
-
-l用于使用line_profiler函数 -
-v用于立即在屏幕上打印结果
kernprof.py 的使用在以下代码行中说明:
$ kernprof.py -l -v simul.py
还可以在 IPython shell 中运行分析器以进行交互式编辑。首先,你需要加载 line_profiler 扩展,它将提供 lprun 魔法命令。使用该命令,你可以避免添加 @profile 装饰器:

输出相当直观,分为六个列:
-
行号:运行的行号 -
击中次数:该行被运行的次数 -
时间:该行的执行时间,以微秒为单位(时间) -
每次击中:时间/击中次数 -
% 时间:执行该行所花费的总时间的比例 -
行内容:该行的内容
通过查看百分比列,我们可以很好地了解时间花费在哪里。在这种情况下,for 循环体中有几个语句,每个语句的成本约为 10-20%。
优化我们的代码
现在我们已经确定了应用程序大部分时间花在了哪里,我们可以进行一些更改并评估性能的变化。
有多种方法可以调整我们的纯 Python 代码。产生最显著结果的方法是改进所使用的算法。在这种情况下,我们不再计算速度并添加小步骤,而是更有效(并且正确,因为它不是近似)用半径 r 和角度 alpha(而不是 x 和 y)来表示运动方程,然后使用以下方程计算圆上的点:
x = r * cos(alpha)
y = r * sin(alpha)
另一种方法是通过最小化指令的数量。例如,我们可以预先计算 timestep * p.ang_vel 因子,该因子不随时间变化。我们可以交换循环顺序(首先迭代粒子,然后迭代时间步),并将因子的计算放在循环外的粒子上进行。
行内分析还显示,即使是简单的赋值操作也可能花费相当多的时间。例如,以下语句占用了超过 10% 的总时间:
v_x = (-p.y)/norm
我们可以通过减少执行的操作数来提高循环的性能。为此,我们可以通过将表达式重写为单个稍微复杂一些的语句来避免中间变量(注意,右侧在赋值给变量之前会被完全评估):
p.x, p.y = p.x - t_x_ang*p.y/norm, p.y + t_x_ang * p.x/norm
这导致以下代码:
def evolve_fast(self, dt):
timestep = 0.00001
nsteps = int(dt/timestep)
# Loop order is changed
for p in self.particles:
t_x_ang = timestep * p.ang_vel
for i in range(nsteps):
norm = (p.x**2 + p.y**2)**0.5
p.x, p.y = (p.x - t_x_ang * p.y/norm,
p.y + t_x_ang * p.x/norm)
应用更改后,我们应该通过运行我们的测试来验证结果是否仍然相同。然后我们可以使用我们的基准来比较执行时间:
$ time python simul.py # Performance Tuned
real 0m0.756s
user 0m0.714s
sys 0m0.036s
$ time python simul.py # Original
real 0m0.863s
user 0m0.831s
sys 0m0.028s
如您所见,我们通过进行纯 Python 微优化只获得了速度的适度提升。
dis 模块
有时很难估计 Python 语句将执行多少操作。在本节中,我们将深入研究 Python 内部机制以估计单个语句的性能。在 CPython 解释器中,Python 代码首先被转换为中间表示形式,即字节码,然后由 Python 解释器执行。
要检查代码如何转换为字节码,我们可以使用dis Python 模块(dis代表反汇编)。它的使用非常简单;所需做的就是调用dis.dis函数对ParticleSimulator.evolve方法进行操作:
import dis
from simul import ParticleSimulator
dis.dis(ParticleSimulator.evolve)
这将打印出函数中每一行的字节码指令列表。例如,v_x = (-p.y)/norm语句在以下指令集中展开:
29 85 LOAD_FAST 5 (p)
88 LOAD_ATTR 4 (y)
91 UNARY_NEGATIVE
92 LOAD_FAST 6 (norm)
95 BINARY_TRUE_DIVIDE
96 STORE_FAST 7 (v_x)
LOAD_FAST将p变量的引用加载到栈上,LOAD_ATTR将栈顶元素的y属性加载到栈上。其他指令,如UNARY_NEGATIVE和BINARY_TRUE_DIVIDE,在栈顶元素上执行算术运算。最后,结果存储在v_x中(STORE_FAST)。
通过分析dis输出,我们可以看到第一个版本的循环产生了51条字节码指令,而第二个版本被转换为35条指令。
dis模块有助于发现语句是如何转换的,主要用作 Python 字节码表示的探索和学习工具。
为了进一步提高我们的性能,我们可以继续尝试找出其他方法来减少指令的数量。然而,很明显,这种方法最终受限于 Python 解释器的速度,可能不是完成这项工作的正确工具。在接下来的章节中,我们将看到如何通过执行用较低级别语言(如 C 或 Fortran)编写的快速专用版本来加快解释器受限的计算速度。
使用memory_profiler分析内存使用
在某些情况下,高内存使用量构成一个问题。例如,如果我们想要处理大量的粒子,由于创建了大量的Particle实例,我们将产生内存开销。
memory_profiler模块以一种类似于line_profiler的方式总结了进程的内存使用情况。
memory_profiler包也可在 Python 包索引中找到。您还应该安装psutil模块(github.com/giampaolo/psutil),作为可选依赖项,这将使memory_profiler运行得更快。
就像line_profiler一样,memory_profiler也要求通过在我们要监控的函数上放置@profile装饰器来对源代码进行仪器化。在我们的例子中,我们想要分析benchmark函数。
我们可以稍微修改benchmark,实例化大量的Particle实例(例如100000个),并减少模拟时间:
def benchmark_memory():
particles = [Particle(uniform(-1.0, 1.0),
uniform(-1.0, 1.0),
uniform(-1.0, 1.0))
for i in range(100000)]
simulator = ParticleSimulator(particles)
simulator.evolve(0.001)
我们可以通过以下截图所示的%mprun魔法命令从 IPython shell 中使用memory_profiler:

在添加了@profile装饰器后,可以使用mprof run命令从 shell 中运行memory_profiler。
从Increment列中,我们可以看到 100,000 个Particle对象占用23.7 MiB的内存。
1 MiB(兆字节)相当于 1,048,576 字节。它与 1 MB(兆字节)不同,1 MB 相当于 1,000,000 字节。
我们可以在Particle类上使用__slots__来减少其内存占用。这个特性通过避免在内部字典中存储实例变量来节省一些内存。然而,这种策略有一个缺点——它阻止了添加__slots__中未指定的属性:
class Particle:
__slots__ = ('x', 'y', 'ang_vel')
def __init__(self, x, y, ang_vel):
self.x = x
self.y = y
self.ang_vel = ang_vel
我们现在可以重新运行我们的基准测试来评估内存消耗的变化,结果如下截图所示:

通过使用__slots__重写Particle类,我们可以节省大约10 MiB的内存。
摘要
在本章中,我们介绍了优化的基本原理,并将这些原理应用于一个测试应用。在优化时,首先要做的是测试并确定应用中的瓶颈。我们看到了如何使用time Unix 命令、Python 的timeit模块以及完整的pytest-benchmark包来编写和计时基准测试。我们学习了如何使用cProfile、line_profiler和memory_profiler来分析我们的应用,以及如何使用 KCachegrind 图形化地分析和导航分析数据。
在下一章中,我们将探讨如何使用 Python 标准库中可用的算法和数据结构来提高性能。我们将涵盖扩展、几个数据结构的示例用法,并学习诸如缓存和记忆化等技术。
第二章:纯 Python 优化
如上章所述,提高应用程序性能的最有效方法之一是通过使用更好的算法和数据结构。Python 标准库提供了一系列可直接集成到您应用程序中的现成算法和数据结构。通过本章学到的工具,您将能够使用适合任务的正确算法并实现巨大的速度提升。
尽管许多算法已经存在了很长时间,但它们在当今世界尤其相关,因为我们不断地生产、消费和分析越来越多的数据。购买更大的服务器或进行微优化可能暂时有效,但通过算法改进实现更好的扩展可以一劳永逸地解决问题。
在本章中,我们将了解如何使用标准算法和数据结构实现更好的扩展。通过利用第三方库,我们还将涵盖更高级的使用案例。我们还将学习有关实现缓存的工具,这是一种通过在内存或磁盘上牺牲一些空间来提高响应时间的技巧。
本章将涵盖的主题列表如下:
-
计算复杂性简介
-
列表和双端队列
-
字典
-
如何使用字典构建倒排索引
-
集合
-
堆和优先队列
-
使用字典实现自动补全
-
缓存简介
-
使用
functools.lru_cache装饰器的内存缓存 -
使用
joblib.Memory的磁盘缓存 -
使用生成器和推导式实现快速且内存高效的循环
有用的算法和数据结构
算法改进在提高性能方面特别有效,因为它们通常允许应用程序在输入越来越大时更好地扩展。
算法运行时间可以根据其计算复杂度进行分类,这是对执行任务所需资源的描述。这种分类通过大 O 符号表示,它是执行任务所需操作的上限,这通常取决于输入大小。
例如,可以使用 for 循环实现列表中每个元素的递增,如下所示:
input = list(range(10))
for i, _ in enumerate(input):
input[i] += 1
如果操作不依赖于输入的大小(例如,访问列表的第一个元素),则该算法被认为是常数时间,或 O(1) 时间。这意味着,无论我们有多少数据,运行算法的时间始终是相同的。
在这个简单的算法中,input[i] += 1 操作将重复 10 次,这是输入的大小。如果我们加倍输入数组的大小,操作的数量将成比例增加。由于操作的数量与输入大小成正比,因此该算法被认为是 O(N) 时间,其中 N 是输入数组的大小。
在某些情况下,运行时间可能取决于输入的结构(例如,如果集合已排序或包含许多重复项)。在这些情况下,算法可能具有不同的最佳、平均和最坏情况运行时间。除非另有说明,本章中提供的运行时间被认为是平均运行时间。
在本节中,我们将检查 Python 标准库中实现的主要算法和数据结构的运行时间,并了解提高运行时间如何带来巨大的收益,并使我们能够以优雅的方式解决大规模问题。
您可以在 Algorithms.ipynb 笔记本中找到本章中使用的基准测试代码,该笔记本可以使用 Jupyter 打开。
列表和双端队列
Python 列表是有序元素集合,在 Python 中实现为可调整大小的数组。数组是一种基本的数据结构,由一系列连续的内存位置组成,每个位置包含对 Python 对象的引用。
列表在访问、修改和追加元素方面表现出色。访问或修改一个元素涉及从底层数组的适当位置获取对象引用,其复杂度为 O(1)。追加一个元素也非常快。当创建一个空列表时,会分配一个固定大小的数组,并且随着我们插入元素,数组中的槽位逐渐被填满。一旦所有槽位都被占用,列表需要增加其底层数组的大小,从而触发可能需要 O(N) 时间的内存重新分配。尽管如此,这些内存分配并不频繁,追加操作的复杂度被称为摊销的 O(1) 时间。
可能存在效率问题的列表操作是在列表的开始(或中间某处)添加或删除元素。当从列表的开始插入或删除一个项目时,数组中所有后续元素都需要移动一个位置,因此需要 O(N) 的时间。
在以下表中,展示了大小为 10,000 的列表上不同操作的计时;您可以看到,如果是在列表的开始或末尾执行插入和删除操作,性能会有相当大的差异:
| 代码 | N=10000 (µs) | N=20000 (µs) | N=30000 (µs) | 时间 |
|---|---|---|---|---|
list.pop() |
0.50 | 0.59 | 0.58 | O(1) |
list.pop(0) |
4.20 | 8.36 | 12.09 | O(N) |
list.append(1) |
0.43 | 0.45 | 0.46 | O(1) |
list.insert(0, 1) |
6.20 | 11.97 | 17.41 | O(N) |
在某些情况下,需要在集合的开始和结束处有效地执行元素的插入或删除。Python 在 collections.deque 类中提供了一个具有这些属性的数据结构。单词 deque 代表双端队列,因为这种数据结构被设计成在集合的开始和结束处高效地添加和删除元素,就像队列一样。在 Python 中,双端队列被实现为双链表。
除了 pop 和 append,双端队列还公开了 popleft 和 appendleft 方法,这些方法具有 O(1) 运行时间:
| 代码 | N=10000 (µs) | N=20000 (µs) | N=30000 (µs) | 时间 |
|---|---|---|---|---|
deque.pop() |
0.41 | 0.47 | 0.51 | O(1) |
deque.popleft() |
0.39 | 0.51 | 0.47 | O(1) |
deque.append(1) |
0.42 | 0.48 | 0.50 | O(1) |
deque.appendleft(1) |
0.38 | 0.47 | 0.51 | O(1) |
尽管有这些优点,但在大多数情况下不应使用双端队列来替换常规列表。appendleft 和 popleft 操作获得的效率是以代价为代价的:在双端队列中间访问一个元素是一个 O(N) 操作,如下表所示:
| 代码 | N=10000 (µs) | N=20000 (µs) | N=30000 (µs) | 时间 |
|---|---|---|---|---|
deque[0] |
0.37 | 0.41 | 0.45 | O(1) |
deque[N - 1] |
0.37 | 0.42 | 0.43 | O(1) |
deque[int(N / 2)] |
1.14 | 1.71 | 2.48 | O(N) |
在列表中搜索一个项目通常是一个 O(N) 操作,并且使用 list.index 方法执行。加快列表中搜索的一种简单方法是将数组排序并使用 bisect 模块进行二分搜索。
bisect 模块允许在排序数组上进行快速搜索。bisect.bisect 函数可以用于排序列表,以找到放置元素的位置,同时保持数组排序。在以下示例中,我们可以看到,如果我们想在保持 collection 排序顺序的情况下将 3 元素插入数组,我们应该将 3 放在第三个位置(对应索引 2):
insert bisect
collection = [1, 2, 4, 5, 6]
bisect.bisect(collection, 3)
# Result: 2
此函数使用具有 O(log(N)) 运行时间的二分搜索算法。这样的运行时间非常快,基本上意味着每次你 加倍 输入大小时,运行时间将增加一个常数。这意味着,例如,如果你的程序在大小为 1000 的输入上运行需要 1 秒,那么处理大小为 2000 的输入将需要 2 秒,处理大小为 4000 的输入将需要 3 秒,依此类推。如果你有 100 秒,理论上可以处理大小为 10³³ 的输入,这比你体内的原子数量还要大!
如果我们试图插入的值已经在列表中存在,bisect.bisect 函数将返回已存在值之后的定位。因此,我们可以使用 bisect.bisect_left 变体,它以以下方式返回正确的索引(摘自模块文档docs.python.org/3.5/library/bisect.html):
def index_bisect(a, x):
'Locate the leftmost value exactly equal to x'
i = bisect.bisect_left(a, x)
if i != len(a) and a[i] == x:
return i
raise ValueError
在下面的表格中,你可以看到 bisect 解决方案的运行时间在这些输入大小下几乎不受影响,使其成为在非常大型集合中搜索时的合适解决方案:
| 代码 | N=10000 (****µs) | N=20000 (****µs) | N=30000 (****µs) | 时间 |
|---|---|---|---|---|
list.index(a) |
87.55 | 171.06 | 263.17 | O(N) |
index_bisect(list, a) |
3.16 | 3.20 | 4.71 | O(log(N)) |
字典
字典在 Python 语言中非常灵活,并且被广泛使用。字典作为哈希表实现,非常擅长元素插入、删除和访问;所有这些操作的平均时间复杂度都是 O(1)。
在 Python 3.5 版本之前,字典是无序集合。从 Python 3.6 版本开始,字典能够根据插入顺序维护其元素。
哈希表是一种将一组键值对关联起来的数据结构。哈希表背后的原理是为每个键分配一个特定的索引,以便其关联的值可以存储在数组中。索引可以通过使用 hash 函数获得;Python 为几种数据类型实现了哈希函数。作为演示,获取哈希码的通用函数是 hash。在以下示例中,我们展示了如何为 "hello" 字符串获取哈希码:
hash("hello")
# Result: -1182655621190490452
# To restrict the number to be a certain range you can use
# the modulo (%) operator
hash("hello") % 10
# Result: 8
哈希表的实现可能有些棘手,因为它们需要处理两个不同对象具有相同哈希码时发生的冲突。然而,所有复杂性都被优雅地隐藏在实现背后,并且默认的冲突解决机制在大多数实际场景中工作得很好。
字典中一个元素的访问、插入和删除操作的时间复杂度与字典的大小成 O(1) 比例。然而,请注意,哈希函数的计算仍然需要发生,对于字符串,计算复杂度与字符串长度成正比。由于字符串键通常相对较小,这在实际中并不构成问题。
字典可以用来高效地计算列表中唯一元素的数量。在这个例子中,我们定义了 counter_dict 函数,它接受一个列表并返回一个包含列表中每个值出现次数的字典:
def counter_dict(items):
counter = {}
for item in items:
if item not in counter:
counter[item] = 0
else:
counter[item] += 1
return counter
使用 collections.defaultdict 可以在一定程度上简化代码,该工具可以用来生成字典,其中每个新的键都会自动分配一个默认值。在下面的代码中,defaultdict(int) 调用生成一个字典,其中每个新元素都会自动分配一个零值,并可用于简化计数:
from collections import defaultdict
def counter_defaultdict(items):
counter = defaultdict(int)
for item in items:
counter[item] += 1
return counter
collections模块还包括一个Counter类,可以单行代码实现相同的目的:
from collections import Counter
counter = Counter(items)
在速度方面,所有这些计数方法都具有相同的时间复杂度,但Counter实现是最有效的,如下表所示:
| 代码 | N=1000 (****µs) | N=2000 (****µs) | N=3000 (****µs) | 时间 |
|---|---|---|---|---|
Counter(items) |
51.48 | 96.63 | 140.26 | O(N) |
counter_dict(items) |
111.96 | 197.13 | 282.79 | O(N) |
counter_defaultdict(items) |
120.90 | 238.27 | 359.60 | O(N) |
使用哈希表构建内存搜索索引
字典可以用来快速在文档列表中搜索一个单词,类似于搜索引擎。在本小节中,我们将学习如何根据列表字典构建倒排索引。假设我们有一个包含四个文档的集合:
docs = ["the cat is under the table",
"the dog is under the table",
"cats and dogs smell roses",
"Carla eats an apple"]
获取与查询匹配的所有文档的一个简单方法是扫描每个文档并测试单词的存在。例如,如果我们想查找包含单词table的文档,我们可以使用以下过滤操作:
matches = [doc for doc in docs if "table" in doc]
这种方法简单且在处理一次性查询时效果良好;然而,如果我们需要频繁查询集合,优化查询时间可能是有益的。由于线性扫描的每次查询成本为O(N),你可以想象更好的扩展性将使我们能够处理更大的文档集合。
一个更好的策略是花些时间预处理文档,以便在查询时更容易找到。我们可以构建一个称为倒排索引的结构,将我们集合中的每个单词与包含该单词的文档列表关联起来。在我们之前的例子中,单词"table"将与"the cat is under the table"和"the dog is under the table"文档相关联;它们对应于索引0和1。
这种映射可以通过遍历我们的文档集合,并在字典中存储该术语出现的文档索引来实现。实现方式与counter_dict函数类似,但不同之处在于,我们不是累积计数器,而是在匹配当前术语的文档列表中增长:
# Building an index
index = {}
for i, doc in enumerate(docs):
# We iterate over each term in the document
for word in doc.split():
# We build a list containing the indices
# where the term appears
if word not in index:
index[word] = [i]
else:
index[word].append(i)
一旦我们构建了索引,查询就涉及简单的字典查找。例如,如果我们想返回包含术语table的所有文档,我们只需查询索引,并检索相应的文档:
results = index["table"]
result_documents = [docs[i] for i in results]
由于查询我们的集合只需要字典访问,该索引可以处理时间复杂度为O(1)的查询!多亏了倒排索引,我们现在能够以恒定时间查询任意数量的文档(只要它们适合内存)。不用说,索引是一种广泛用于快速检索数据的技术,不仅用于搜索引擎,还用于数据库和任何需要快速搜索的系统。
注意,构建倒排索引是一个昂贵的操作,需要你编码每个可能的查询。这是一个重大的缺点,但好处很大,可能值得为了减少灵活性而付出代价。
集合
集合是无序的元素集合,还有一个额外的限制,即元素必须是唯一的。集合是很好的选择的主要用例包括成员测试(测试元素是否存在于集合中)以及,不出所料,并集、差集和交集等集合操作。
在 Python 中,集合使用与字典相同的哈希算法实现;因此,添加、删除和测试成员资格的时间复杂度与集合的大小成 O(1) 比例。
集合只包含唯一元素。集合的一个直接用途是从集合中删除重复项,可以通过简单地通过 set 构造函数传递集合来实现,如下所示:
# create a list that contains duplicates
x = list(range(1000)) + list(range(500))
# the set *x_unique* will contain only
# the unique elements in x
x_unique = set(x)
删除重复项的时间复杂度为 O(N),因为它需要读取输入并将每个元素放入集合中。
集合暴露出许多操作,如并集、交集和差集。两个集合的并集是一个新集合,包含两个集合的所有元素;交集是一个新集合,只包含两个集合共有的元素,差集是一个新集合,包含第一个集合中不包含在第二个集合中的元素。以下表格显示了这些操作的时间复杂度。请注意,由于我们有两个不同的输入大小,我们将使用字母 S 来表示第一个集合的大小(称为 s),使用 T 来表示第二个集合的大小(称为 t):
| 代码 | 时间 |
|---|---|
s.union(t) |
O(S + T) |
s.intersection(t) |
O(min(S, T)) |
s.difference(t) |
O(S) |
集合操作的一个应用示例是布尔查询。回到前一小节中倒排索引的例子,我们可能希望支持包含多个术语的查询。例如,我们可能希望搜索包含单词 cat 和 table 的所有文档。这种查询可以通过计算包含 cat 的文档集合与包含 table 的文档集合的交集来有效地计算。
为了有效地支持这些操作,我们可以更改我们的索引代码,使得每个术语都与一组文档相关联(而不是列表)。在应用此更改后,计算更高级的查询只需应用正确的集合操作。在以下代码中,我们展示了基于集合的倒排索引和使用了集合操作的查询:
# Building an index using sets
index = {}
for i, doc in enumerate(docs):
# We iterate over each term in the document
for word in doc.split():
# We build a set containing the indices
# where the term appears
if word not in index:
index[word] = {i}
else:
index[word].add(i)
# Querying the documents containing both "cat" and "table"
index['cat'].intersection(index['table'])
堆
堆是一种数据结构,旨在快速查找和提取集合中的最大(或最小)值。堆的一个典型用途是按最大优先级顺序处理一系列传入的任务。
理论上,可以使用bisect模块中的工具来使用有序列表;然而,虽然提取最大值将花费O(1)时间(使用list.pop),插入操作仍然需要O(N)时间(记住,即使找到插入点需要O(log(N))时间,但在列表中间插入一个元素仍然是一个O(N)操作)。堆是一种更有效的数据结构,它允许以O(log(N))时间复杂度插入和提取最大值。
在 Python 中,堆是通过在底层列表上使用heapq模块中的过程构建的。例如,如果我们有一个包含 10 个元素的列表,我们可以使用heapq.heapify函数将其重新组织成堆:
import heapq
collection = [10, 3, 3, 4, 5, 6]
heapq.heapify(collection)
要在堆上执行插入和提取操作,我们可以使用heapq.heappush和heapq.heappop函数。heapq.heappop函数将以O(log(N))时间从集合中提取最小值,并可以按以下方式使用:
heapq.heappop(collection)
# Returns: 3
同样地,你可以使用heapq.heappush函数将整数1推入堆中,如下所示:
heapq.heappush(collection, 1)
另一个易于使用的选项是queue.PriorityQueue类,它作为额外的好处是线程和进程安全的。可以使用PriorityQueue.put方法向PriorityQueue类填充元素,而PriorityQueue.get可以用来提取集合中的最小值:
from queue import PriorityQueue
queue = PriorityQueue()
for element in collection:
queue.put(element)
queue.get()
# Returns: 3
如果需要最大元素,一个简单的技巧是将列表中的每个元素乘以-1。这样,元素的顺序将被反转。此外,如果你想将对象(例如,要运行的任务)与每个数字(它可以表示优先级)关联起来,可以插入(number, object)形式的元组;元组的比较操作将根据其第一个元素进行排序,如下例所示:
queue = PriorityQueue()
queue.put((3, "priority 3"))
queue.put((2, "priority 2"))
queue.put((1, "priority 1"))
queue.get()
# Returns: (1, "priority 1")
字典树
另一种可能不太受欢迎但实际中非常有用的数据结构是字典树(有时称为前缀树)。字典树在匹配字符串列表与前缀方面非常快速。这在实现搜索即输入和自动完成等特性时特别有用,其中可用的完成列表非常大且需要短响应时间。
不幸的是,Python 的标准库中没有包含字典树的实现;然而,通过 PyPI 可以轻松获得许多高效的实现。在本小节中,我们将使用patricia-trie,这是一个单文件、纯 Python 实现的字典树。例如,我们将使用patricia-trie来执行在字符串集中查找最长前缀的任务(就像自动完成一样)。
例如,我们可以展示 trie 如何快速搜索字符串列表。为了生成大量唯一的随机字符串,我们可以定义一个函数,random_string。random_string 函数将返回由随机大写字母组成的字符串,虽然有可能得到重复的字符串,但如果我们使字符串足够长,我们可以将重复的概率大大降低到可以忽略不计的程度。random_string 函数的实现如下:
from random import choice
from string import ascii_uppercase
def random_string(length):
"""Produce a random string made of *length* uppercase ascii
characters"""
return ''.join(choice(ascii_uppercase) for i in range(length))
我们可以构建一个随机字符串列表,并使用 str.startswith 函数计时它搜索前缀(在我们的情况下,是 "AA" 字符串)的速度:
strings = [random_string(32) for i in range(10000)]
matches = [s for s in strings if s.startswith('AA')]
列推导和 str.startwith 已经是非常优化的操作了,在这个小数据集上,搜索只需一毫秒左右:
%timeit [s for s in strings if s.startswith('AA')]
1000 loops, best of 3: 1.76 ms per loop
现在,让我们尝试使用 trie 进行相同的操作。在这个例子中,我们将使用可以通过 pip 安装的 patricia-trie 库。patricia.trie 类实现了一种与字典接口相似的 trie 数据结构变体。我们可以通过创建一个字典来初始化我们的 trie,如下所示:
from patricia import trie
strings_dict = {s:0 for s in strings}
# A dictionary where all values are 0
strings_trie = trie(**strings_dict)
要查询 patricia-trie 以匹配前缀,我们可以使用 trie.iter 方法,它返回匹配字符串的迭代器:
matches = list(strings_trie.iter('AA'))
现在我们知道了如何初始化和查询 trie,我们可以计时操作:
%timeit list(strings_trie.iter('AA'))
10000 loops, best of 3: 60.1 µs per loop
如果你仔细观察,这个输入大小的计时为 60.1 µs,这比线性搜索快约 30 倍(1.76 ms = 1760 µs)!这种速度的提升如此令人印象深刻,是因为 trie 前缀搜索的计算复杂度更好。查询 trie 的时间复杂度为 O(S),其中 S 是集合中最长字符串的长度,而简单线性扫描的时间复杂度为 O(N),其中 N 是集合的大小。
注意,如果我们想返回所有匹配的前缀,运行时间将与匹配前缀的结果数量成比例。因此,在设计时间基准时,必须小心确保我们总是返回相同数量的结果。
以下表格显示了 trie 与线性扫描在包含十个前缀匹配的不同大小数据集上的缩放特性:
| 算法 | N=10000 (µs) | N=20000 (µs) | N=30000 (µs) | 时间 |
|---|---|---|---|---|
| Trie | 17.12 | 17.27 | 17.47 | O(S) |
| 线性扫描 | 1978.44 | 4075.72 | 6398.06 | O(N) |
一个有趣的事实是,patricia-trie 的实现实际上是一个单独的 Python 文件;这清楚地展示了聪明算法的简单和强大。为了额外的功能和性能,还有其他 C 优化的 trie 库可用,例如 datrie 和 marisa-trie。
缓存和记忆化
缓存是一种用于提高各种应用程序性能的出色技术。缓存背后的想法是将昂贵的计算结果存储在临时位置,称为缓存,该缓存可以位于内存中、磁盘上或远程位置。
网络应用程序广泛使用缓存。在 Web 应用程序中,用户同时请求同一页面的情况经常发生。在这种情况下,而不是为每个用户重新计算页面,Web 应用程序可以一次性计算并服务已经渲染的页面。理想情况下,缓存还需要一个失效机制,以便在页面需要更新时,我们可以在再次提供服务之前重新计算它。智能缓存允许 Web 应用程序以更少的资源处理越来越多的用户。缓存也可以预先进行,例如,在在线观看视频时,视频的后续部分会预先缓冲。
缓存还用于提高某些算法的性能。一个很好的例子是计算斐波那契数列。由于计算斐波那契数列中的下一个数字需要序列中的前一个数字,因此可以存储和重用以前的结果,从而显著提高运行时间。在应用程序中存储和重用以前函数调用的结果通常被称为记忆化,它是缓存的一种形式。其他几种算法可以利用记忆化来获得令人印象深刻的性能提升,这种编程技术通常被称为动态规划。
然而,缓存的益处并非免费获得。我们实际上是在牺牲一些空间来提高应用程序的速度。此外,如果缓存存储在网络上的某个位置,我们可能会产生传输成本和通信所需的一般时间。应该评估何时使用缓存方便,以及我们愿意为速度的提升牺牲多少空间。
鉴于这种技术的实用性,Python 标准库在functools模块中提供了一个简单的内存缓存。可以使用functools.lru_cache装饰器轻松缓存函数的结果。在下面的示例中,我们创建了一个函数sum2,该函数打印一条语句并返回两个数字的和。通过运行该函数两次,你可以看到第一次执行sum2函数时会产生"Calculating ..."字符串,而第二次执行时则直接返回结果,而无需再次运行函数:
from functools import lru_cache
@lru_cache()
def sum2(a, b):
print("Calculating {} + {}".format(a, b))
return a + b
print(sum2(1, 2))
# Output:
# Calculating 1 + 2
# 3
print(sum2(1, 2))
# Output:
# 3
lru_cache装饰器还提供了其他基本功能。为了限制缓存的大小,可以通过max_size参数设置我们打算维护的元素数量。如果我们想使缓存大小无限制,可以指定一个None值。这里展示了max_size的一个示例用法:
@lru_cache(max_size=16)
def sum2(a, b):
...
这样,当我们用不同的参数执行sum2时,缓存将达到最大大小16,并且随着我们不断请求更多的计算,新的值将替换缓存中的旧值。lru前缀来源于这种策略,这意味着最近最少使用。
lru_cache装饰器还为装饰函数添加了额外的功能。例如,可以使用cache_info方法检查缓存性能,并且可以使用cache_clear方法重置缓存,如下所示:
sum2.cache_info()
# Output: CacheInfo(hits=0, misses=1, maxsize=128, currsize=1)
sum2.cache_clear()
例如,我们可以看到计算斐波那契数列等问题如何从缓存中受益。我们可以定义一个fibonacci函数并测量其执行时间:
def fibonacci(n):
if n < 1:
return 1
else:
return fibonacci(n - 1) + fibonacci(n - 2)
# Non-memoized version
%timeit fibonacci(20)
100 loops, best of 3: 5.57 ms per loop
执行时间为 5.57 毫秒,这非常高。以这种方式编写的函数的扩展性表现很差;之前计算的斐波那契序列没有被重用,导致这个算法具有大约O(2^N)的指数扩展。
缓存可以通过存储和重用已经计算过的斐波那契数来提高这个算法。要实现缓存的版本,只需将lru_cache装饰器应用于原始的fibonacci函数即可。此外,为了设计一个合适的基准测试,我们需要确保每次运行时都实例化一个新的缓存;为此,我们可以使用timeit.repeat函数,如下面的示例所示:
import timeit
setup_code = '''
from functools import lru_cache
from __main__ import fibonacci
fibonacci_memoized = lru_cache(maxsize=None)(fibonacci)
'''
results = timeit.repeat('fibonacci_memoized(20)',
setup=setup_code,
repeat=1000,
number=1)
print("Fibonacci took {:.2f} us".format(min(results)))
# Output: Fibonacci took 0.01 us
尽管我们通过添加一个简单的装饰器改变了算法,但现在的运行时间现在比微秒还少。原因是,由于缓存,我们现在有一个线性时间算法而不是指数算法。
可以使用lru_cache装饰器在您的应用程序中实现简单的内存缓存。对于更高级的使用场景,可以使用第三方模块来实现更强大的实现和磁盘缓存。
Joblib
一个简单的库,除了其他功能外,还提供了一个简单的磁盘缓存,就是joblib。该包可以像lru_cache一样使用,只不过结果将存储在磁盘上,并且会在运行之间持久化。
可以使用pip install joblib命令从 PyPI 安装joblib模块。
joblib模块提供了一个Memory类,可以使用Memory.cache装饰器来记忆化函数:
from joblib import Memory
memory = Memory(cachedir='/path/to/cachedir')
@memory.cache
def sum2(a, b):
return a + b
该函数的行为类似于lru_cache,但结果将存储在初始化Memory时通过cachedir参数指定的目录中的磁盘上。此外,缓存的結果将在后续运行中持久化!
Memory.cache方法还允许在仅当某些参数发生变化时才限制重新计算,并且结果装饰函数支持基本的清除和分析缓存的功能。
可能最好的joblib特性是,由于智能哈希算法,它提供了对操作numpy数组的函数的高效记忆化,这在科学和工程应用中尤其有用。
Comprehensions and generators
在本节中,我们将探讨一些简单的策略来使用推导式和生成器加快 Python 循环的速度。在 Python 中,推导式和生成器表达式是相当优化的操作,应该优先于显式 for 循环。使用这种结构的另一个原因是可读性;即使与标准循环相比速度提升不大,推导式和生成器语法更紧凑,并且(大多数时候)更直观。
在下面的示例中,我们可以看到列表推导式和生成器表达式与sum函数结合使用时比显式循环更快:
def loop():
res = []
for i in range(100000):
res.append(i * i)
return sum(res)
def comprehension():
return sum([i * i for i in range(100000)])
def generator():
return sum(i * i for i in range(100000))
%timeit loop()
100 loops, best of 3: 16.1 ms per loop
%timeit comprehension()
100 loops, best of 3: 10.1 ms per loop
%timeit generator()
100 loops, best of 3: 12.4 ms per loop
就像列表一样,我们可以使用dict推导式以稍微高效和紧凑的方式构建字典,如下面的代码所示:
def loop():
res = {}
for i in range(100000):
res[i] = i
return res
def comprehension():
return {i: i for i in range(100000)}
%timeit loop()
100 loops, best of 3: 13.2 ms per loop
%timeit comprehension()
100 loops, best of 3: 12.8 ms per loop
高效循环(特别是在内存方面)可以通过使用迭代器和如filter和map之类的函数来实现。例如,考虑这样一个问题:使用列表推导式对列表应用一系列操作,然后取最大值:
def map_comprehension(numbers):
a = [n * 2 for n in numbers]
b = [n ** 2 for n in a]
c = [n ** 0.33 for n in b]
return max(c)
这种方法的缺点是,对于每个列表推导式,我们都在分配一个新的列表,从而增加内存使用。与其使用列表推导式,我们不如使用生成器。生成器是当迭代时即时计算值并返回结果的对象。
例如,map函数接受两个参数——一个函数和一个迭代器——并返回一个生成器,该生成器将函数应用于集合中的每个元素。重要的是,操作仅在我们迭代时发生,而不是在调用map时发生!
我们可以使用map和创建中间生成器而不是列表来重写前面的函数,从而通过即时计算值来节省内存:
def map_normal(numbers):
a = map(lambda n: n * 2, numbers)
b = map(lambda n: n ** 2, a)
c = map(lambda n: n ** 0.33, b)
return max(c)
我们可以使用来自 IPython 会话的memory_profiler扩展来分析这两个解决方案的内存。该扩展提供了一个小的实用工具%memit,它将帮助我们以类似于%timeit的方式评估 Python 语句的内存使用情况,如下面的代码片段所示:
%load_ext memory_profiler
numbers = range(1000000)
%memit map_comprehension(numbers)
peak memory: 166.33 MiB, increment: 102.54 MiB
%memit map_normal(numbers)
peak memory: 71.04 MiB, increment: 0.00 MiB
如您所见,第一个版本使用的内存为102.54 MiB,而第二个版本消耗的内存为0.00 MiB!对于感兴趣的读者,可以在itertools模块中找到更多返回生成器的函数,该模块提供了一组旨在处理常见迭代模式的实用工具。
概述
算法优化可以提高我们处理越来越大的数据时应用程序的扩展性。在本章中,我们展示了 Python 中可用的一些最常见数据结构的使用案例和运行时间,例如列表、双端队列、字典、堆和前缀树。我们还介绍了缓存技术,这是一种可以在内存或磁盘上以牺牲一些空间为代价来提高应用程序响应性的技术。我们还演示了如何通过用快速结构(如列表推导式和生成器表达式)替换 for 循环来获得适度的速度提升。
在随后的章节中,我们将学习如何使用如numpy等数值库进一步提高性能,以及如何在 Cython 的帮助下用更低级的语言编写扩展模块。
第三章:使用 NumPy 和 Pandas 进行快速数组操作
NumPy 是 Python 中科学计算的 de facto 标准。它通过提供灵活的多维数组扩展了 Python,允许快速简洁的数学计算。
NumPy 提供了旨在使用简洁语法表达复杂数学运算的常见数据结构和算法。多维数组 numpy.ndarray 在内部基于 C 数组。除了性能优势外,这种选择还允许 NumPy 代码轻松地与现有的 C 和 FORTRAN 例程接口;NumPy 有助于弥合 Python 和使用这些语言编写的旧代码之间的差距。
在本章中,我们将学习如何创建和操作 NumPy 数组。我们还将探索用于以高效和简洁的方式重写复杂数学表达式的 NumPy 广播功能。
Pandas 是一个高度依赖 NumPy 的工具,它提供了针对数据分析的额外数据结构和算法。我们将介绍 Pandas 的主要功能和用法。我们还将学习如何从 Pandas 数据结构和矢量化操作中获得高性能。
本章涵盖的主题如下:
-
创建和操作 NumPy 数组
-
掌握 NumPy 的广播功能以实现快速简洁的矢量化操作
-
使用 NumPy 提高我们的粒子模拟器
-
使用
numexpr达到最佳性能 -
Pandas 基础知识
-
使用 Pandas 进行数据库式操作
使用 NumPy 入门
NumPy 库围绕其多维数组对象 numpy.ndarray 展开。NumPy 数组是相同数据类型元素的集合;这种基本限制允许 NumPy 以一种允许高性能数学运算的方式打包数据。
创建数组
您可以使用 numpy.array 函数创建 NumPy 数组。它接受一个类似列表的对象(或另一个数组)作为输入,并可选地接受一个表示其数据类型的字符串。您可以使用 IPython shell 交互式测试数组创建,如下所示:
import numpy as np
a = np.array([0, 1, 2])
每个 NumPy 数组都有一个关联的数据类型,可以使用 dtype 属性访问。如果我们检查 a 数组,我们会发现其 dtype 是 int64,代表 64 位整数:
a.dtype
# Result:
# dtype('int64')
我们可能决定将这些整数数字转换为 float 类型。为此,我们可以在数组初始化时传递 dtype 参数,或者使用 astype 方法将数组转换为另一种数据类型。以下代码显示了选择数据类型的两种方法:
a = np.array([1, 2, 3], dtype='float32')
a.astype('float32')
# Result:
# array([ 0., 1., 2.], dtype=float32)
要创建一个具有两个维度(数组数组)的数组,我们可以使用嵌套序列进行初始化,如下所示:
a = np.array([[0, 1, 2], [3, 4, 5]])
print(a)
# Output:
# [[0 1 2]
# [3 4 5]]
以这种方式创建的数组具有两个维度,在 NumPy 的术语中称为 轴。以这种方式形成的数组就像一个包含两行三列的表格。我们可以使用 ndarray.shape 属性来访问轴:
a.shape
# Result:
# (2, 3)
只要形状维度的乘积等于数组中的总元素数(即,总元素数保持不变),数组也可以被重塑。例如,我们可以以下列方式重塑包含 16 个元素的数组:(2, 8)、(4, 4) 或 (2, 2, 4)。要重塑数组,我们可以使用 ndarray.reshape 方法或给 ndarray.shape 元组赋新值。以下代码说明了 ndarray.reshape 方法的使用:
a = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8,
9, 10, 11, 12, 13, 14, 15])
a.shape
# Output:
# (16,)
a.reshape(4, 4) # Equivalent: a.shape = (4, 4)
# Output:
# array([[ 0, 1, 2, 3],
# [ 4, 5, 6, 7],
# [ 8, 9, 10, 11],
# [12, 13, 14, 15]])
多亏了这一特性,你可以自由地添加大小为 1 的维度。你可以将包含 16 个元素的数组重塑为 (16, 1)、(1, 16)、(16, 1, 1) 等等。在下一节中,我们将广泛使用这一特性通过 广播 实现复杂操作。
NumPy 提供了一些便利函数,如下面的代码所示,用于创建填充零、一或无初始值(在这种情况下,其实际值没有意义且取决于内存状态)的数组。这些函数接受数组形状作为元组,并且可选地接受其 dtype:
np.zeros((3, 3))
np.empty((3, 3))
np.ones((3, 3), dtype='float32')
在我们的示例中,我们将使用 numpy.random 模块在 (0, 1) 区间内生成随机浮点数。numpy.random.rand 将接受一个形状并返回具有该形状的随机数数组:
np.random.rand(3, 3)
有时初始化与某个其他数组形状相同的数组很方便。为此目的,NumPy 提供了一些实用的函数,例如 zeros_like、empty_like 和 ones_like。这些函数可以按如下方式使用:
np.zeros_like(a)
np.empty_like(a)
np.ones_like(a)
访问数组
在浅层上,NumPy 数组接口与 Python 列表类似。NumPy 数组可以使用整数索引,并使用 for 循环迭代:
A = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8])
A[0]
# Result:
# 0
[a for a in A]
# Result:
# [0, 1, 2, 3, 4, 5, 6, 7, 8]
在 NumPy 中,可以通过在下标操作符 [] 内使用多个以逗号分隔的值方便地访问数组元素和子数组。如果我们取一个 (3,3) 的数组(包含三个三元组的数组),并且我们访问索引为 0 的元素,我们获得第一行,如下所示:
A = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])
A[0]
# Result:
# array([0, 1, 2])
我们可以通过添加另一个以逗号分隔的索引来再次索引行。要获取第一行的第二个元素,我们可以使用 (0, 1) 索引。一个重要的观察是,A[0, 1] 语法实际上是一个简写,即 A[(0, 1)],也就是说,我们实际上是在使用 元组 进行索引!以下代码片段显示了这两种版本:
A[0, 1]
# Result:
# 1
# Equivalent version using tuple
A[(0, 1)]
NumPy 允许你将数组切割成多个维度。如果我们对第一个维度进行切割,我们可以获得一系列三元组,如下所示:
A[0:2]
# Result:
# array([[0, 1, 2],
# [3, 4, 5]])
如果我们再次使用 0:2 在第二个维度上切割数组,我们基本上是从之前显示的三元组集合中提取前两个元素。这导致了一个形状为 (2, 2) 的数组,如下所示:
A[0:2, 0:2]
# Result:
# array([[0, 1],
# [3, 4]])
直观地讲,你可以使用数值索引和切片来更新数组中的值。以下代码片段展示了这一点的示例:
A[0, 1] = 8
A[0:2, 0:2] = [[1, 1], [1, 1]]
使用切片语法进行索引非常快,因为与列表不同,它不会生成数组的副本。在 NumPy 的术语中,它返回相同内存区域的视图。如果我们从原始数组中取一个切片,然后改变其值中的一个,原始数组也会被更新。以下代码展示了这一特性的一个示例:
a= np.array([1, 1, 1, 1])
a_view = a[0:2]
a_view[0] = 2
print(a)
# Output:
# [2 1 1 1]
在修改 NumPy 数组时,需要格外小心。由于视图共享数据,改变视图的值可能会导致难以发现的错误。为了防止副作用,您可以设置a.flags.writeable = False标志,这将防止意外修改数组或其任何视图。
我们可以看看另一个示例,展示如何在实际场景中使用切片语法。我们定义一个r_i数组,如下面的代码行所示,它包含一组 10 个坐标(x,y)。它的形状将是(10, 2):
r_i = np.random.rand(10, 2)
如果您在区分轴顺序不同的数组时遇到困难,例如在形状为(10, 2)的数组与(2, 10)的数组之间,那么每次您说“of”这个词时,都应该引入一个新的维度。一个大小为二的十个元素的数组将是(10, 2)。相反,一个大小为十的两个元素的数组将是(2, 10)。
我们可能感兴趣的一个典型操作是从每个坐标中提取x分量。换句话说,您想要提取(0, 0)、(1, 0)、(2, 0)等等项,结果得到一个形状为(10,)的数组。有助于思考的是,第一个索引是移动的,而第二个索引是固定的(在0处)。带着这个想法,我们将第一个轴(移动的轴)上的每个索引进行切片,并在第二个轴上取第一个元素(固定的元素),如下面的代码行所示:
x_i = r_i[:, 0]
另一方面,以下表达式将保持第一个索引固定,第二个索引移动,返回第一个(x,y)坐标:
r_0 = r_i[0, :]
在最后一个轴上对所有的索引进行切片是可选的;使用r_i[0]与r_i[0, :]具有相同的效果。
NumPy 允许您使用另一个由整数或布尔值组成的 NumPy 数组来索引数组,这是一个称为花式索引的特性。
如果您使用另一个整数数组(例如,idx)来索引数组(例如,a),NumPy 将解释这些整数为索引,并返回一个包含它们对应值的数组。如果我们使用np.array([0, 2, 3])来索引包含 10 个元素的数组,我们将得到一个形状为(3,)的数组,包含位置0、2和3的元素。以下代码为我们展示了这一概念:
a = np.array([9, 8, 7, 6, 5, 4, 3, 2, 1, 0])
idx = np.array([0, 2, 3])
a[idx]
# Result:
# array([9, 7, 6])
您可以通过为每个维度传递一个数组来实现多维度的花式索引。如果我们想提取(0, 2)和(1, 3)元素,我们必须将作用于第一个轴的所有索引打包在一个数组中,而将作用于第二个轴的索引放在另一个数组中。这可以在以下代码中看到:
a = np.array([[0, 1, 2], [3, 4, 5],
[6, 7, 8], [9, 10, 11]])
idx1 = np.array([0, 1])
idx2 = np.array([2, 3])
a[idx1, idx2]
你也可以使用正常的列表作为索引数组,但不能使用元组。例如,以下两个语句是等价的:
a[np.array([0, 1])] # is equivalent to
a[[0, 1]]
然而,如果你使用元组,NumPy 将以下语句解释为对多个维度的索引:
a[(0, 1)] # is equivalent to
a[0, 1]
索引数组不需要是一维的;我们可以以任何形状从原始数组中提取元素。例如,我们可以从原始数组中选择元素来形成一个 (2,2) 的数组,如下所示:
idx1 = [[0, 1], [3, 2]]
idx2 = [[0, 2], [1, 1]]
a[idx1, idx2]
# Output:
# array([[ 0, 5],
# [10, 7]])
数组切片和花式索引功能可以组合使用。这在例如我们想要交换坐标数组中的 x 和 y 列时很有用。在下面的代码中,第一个索引将遍历所有元素(一个切片),对于这些元素中的每一个,我们首先提取位置 1(y)的元素,然后是位置 0(x)的元素:
r_i = np.random(10, 2)
r_i[:, [0, 1]] = r_i[:, [1, 0]]
当索引数组是 bool 类型时,规则略有不同。bool 数组将像 掩码 一样工作;每个对应于 True 的元素将被提取并放入输出数组中。这个过程在下面的代码中显示:
a = np.array([0, 1, 2, 3, 4, 5])
mask = np.array([True, False, True, False, False, False])
a[mask]
# Output:
# array([0, 2])
当处理多个维度时,相同的规则适用。此外,如果索引数组的形状与原始数组相同,对应于 True 的元素将被选中并放入结果数组中。
NumPy 中的索引是一个相对快速的运算。无论如何,当速度至关重要时,你可以使用稍微快一点的 numpy.take 和 numpy.compress 函数来挤出更多性能。numpy.take 的第一个参数是我们想要操作的数组,第二个参数是我们想要提取的索引列表。最后一个参数是 axis;如果没有提供,索引将作用于展平后的数组;否则,它们将沿着指定的轴进行操作:
r_i = np.random(100, 2)
idx = np.arange(50) # integers 0 to 50
%timeit np.take(r_i, idx, axis=0)
1000000 loops, best of 3: 962 ns per loop
%timeit r_i[idx]
100000 loops, best of 3: 3.09 us per loop
对于布尔数组,有一个类似但更快的版本是 numpy.compress,它以相同的方式工作。以下是如何使用 numpy.compress 的示例:
In [51]: idx = np.ones(100, dtype='bool') # all True values
In [52]: %timeit np.compress(idx, r_i, axis=0)
1000000 loops, best of 3: 1.65 us per loop
In [53]: %timeit r_i[idx]
100000 loops, best of 3: 5.47 us per loop
广播
NumPy 的真正力量在于其快速的数学运算。NumPy 使用的策略是通过使用优化的 C 代码进行逐元素计算来避免进入 Python 解释器。广播 是一组巧妙的规则,它使得形状相似(但不完全相同!)的数组能够进行快速数组计算。
无论何时你在两个数组(如乘积)上进行算术运算,如果两个操作数具有相同的形状,该运算将以逐元素的方式应用。例如,在乘以两个形状为 (2,2) 的数组时,操作将在对应元素对之间进行,产生另一个 (2, 2) 的数组,如下面的代码所示:
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
A * B
# Output:
# array([[ 5, 12],
# [21, 32]])
如果操作数的形状不匹配,NumPy 将尝试使用广播规则来匹配它们。如果一个操作数是 标量(例如,一个数字),它将被应用到数组的每个元素上,如下面的代码所示:
A * 2
# Output:
# array([[2, 4],
# [6, 8]])
如果操作数是另一个数组,NumPy 将尝试从最后一个轴开始匹配形状。例如,如果我们想将形状为(3, 2)的数组与形状为(2,)的数组组合,第二个数组将被重复三次以生成一个(3, 2)的数组。换句话说,数组沿着一个维度进行广播以匹配另一个操作数的形状,如下面的图所示:

如果形状不匹配,例如,当将(3, 2)的数组与(2, 2)的数组组合时,NumPy 将抛出异常。
如果轴的大小为 1,数组将在这个轴上重复,直到形状匹配。为了说明这一点,考虑以下形状的数组:
5, 10, 2
现在,假设我们想要与形状为(5, 1, 2)的数组进行广播;数组将在第二个轴上重复 10 次,如下所示:
5, 10, 2
5, 1, 2 → repeated
- - - -
5, 10, 2
之前,我们看到了可以自由重塑数组以添加大小为 1 的轴。在索引时使用numpy.newaxis常量将引入一个额外的维度。例如,如果我们有一个(5, 2)的数组,我们想要与形状为(5, 10, 2)的数组组合,我们可以在中间添加一个额外的轴,如下面的代码所示,以获得兼容的(5, 1, 2)数组:
A = np.random.rand(5, 10, 2)
B = np.random.rand(5, 2)
A * B[:, np.newaxis, :]
此功能可用于操作两个数组所有可能的组合。其中一种应用是外积。考虑以下两个数组:
a = [a1, a2, a3]
b = [b1, b2, b3]
外积是一个矩阵,包含两个数组元素所有可能的组合(i, j)的乘积,如下面的代码片段所示:
a x b = a1*b1, a1*b2, a1*b3
a2*b1, a2*b2, a2*b3
a3*b1, a3*b2, a3*b3
要使用 NumPy 计算此操作,我们将重复[a1, a2, a3]元素在一个维度上,[b1, b2, b3]元素在另一个维度上,然后取它们的逐元素乘积,如下面的图所示:

使用代码,我们的策略是将a数组从形状(3,)转换为形状(3, 1),将b数组从形状(3,)转换为形状(1, 3)。两个数组在两个维度上广播并使用以下代码相乘:
AB = a[:, np.newaxis] * b[np.newaxis, :]
此操作非常快且非常有效,因为它避免了 Python 循环,并且能够以与纯 C 或 FORTRAN 代码相当的速度处理大量元素。
数学运算
NumPy 默认包含了广播中最常见的数学运算,从简单的代数到三角学、舍入和逻辑。例如,要计算数组中每个元素的平方根,我们可以使用numpy.sqrt,如下面的代码所示:
np.sqrt(np.array([4, 9, 16]))
# Result:
# array([2., 3., 4.])
比较运算符在尝试根据条件过滤某些元素时很有用。想象一下,我们有一个从0到1的随机数数组,我们想要提取所有大于0.5的数字。我们可以在数组上使用>运算符来获得一个bool数组,如下所示:
a = np.random.rand(5, 3)
a > 0.3
# Result:
# array([[ True, False, True],
# [ True, True, True],
# [False, True, True],
# [ True, True, False],
# [ True, True, False]], dtype=bool)
然后,我们可以将生成的 bool 数组作为索引重用,以检索大于 0.5 的元素:
a[a > 0.5]
print(a[a>0.5])
# Output:
# [ 0.9755 0.5977 0.8287 0.6214 0.5669 0.9553 0.5894
0.7196 0.9200 0.5781 0.8281 ]
NumPy 还实现了 ndarray.sum 等方法,该方法对轴上的所有元素求和。如果我们有一个形状为 (5, 3) 的数组,我们可以使用 ndarray.sum 方法对第一个轴、第二个轴或整个数组的所有元素求和,如下面的代码片段所示:
a = np.random.rand(5, 3)
a.sum(axis=0)
# Result:
# array([ 2.7454, 2.5517, 2.0303])
a.sum(axis=1)
# Result:
# array([ 1.7498, 1.2491, 1.8151, 1.9320, 0.5814])
a.sum() # With no argument operates on flattened array
# Result:
# 7.3275
注意,通过在一个轴上对元素求和,我们消除了该轴。从前面的例子中,轴 0 上的求和产生了一个形状为 (3,) 的数组,而轴 1 上的求和产生了一个形状为 (5,) 的数组。
计算范数
我们可以通过计算一组坐标的 norm 来回顾本节中展示的基本概念。对于二维向量,范数定义为以下内容:
norm = sqrt(x**2 + y**2)
给定一个包含 10 个坐标 (x, y) 的数组,我们想要找到每个坐标的范数。我们可以通过以下步骤来计算范数:
-
将坐标平方,得到一个包含
(x**2, y**2)元素的数组。 -
使用
numpy.sum在最后一个轴上对这些值求和。 -
使用
numpy.sqrt对每个元素进行平方根运算。
最终的表达式可以压缩成一行:
r_i = np.random.rand(10, 2)
norm = np.sqrt((r_i ** 2).sum(axis=1))
print(norm)
# Output:
# [ 0.7314 0.9050 0.5063 0.2553 0.0778 0.9143 1.3245
0.9486 1.010 1.0212]
在 NumPy 中重写粒子模拟器
在本节中,我们将通过使用 NumPy 重写粒子模拟器的一些部分来优化我们的粒子模拟器。我们从 第一章 “Benchmarking and Profiling” 中所做的分析中发现,我们程序中最慢的部分是 ParticleSimulator.evolve 方法中包含的以下循环:
for i in range(nsteps):
for p in self.particles:
norm = (p.x**2 + p.y**2)**0.5
v_x = (-p.y)/norm
v_y = p.x/norm
d_x = timestep * p.ang_vel * v_x
d_y = timestep * p.ang_vel * v_y
p.x += d_x
p.y += d_y
你可能已经注意到,循环的主体仅对当前粒子起作用。如果我们有一个包含粒子位置和角速度的数组,我们可以使用广播操作重写循环。相比之下,循环的步骤依赖于前一步,不能以这种方式并行化。
因此,将所有数组坐标存储在形状为 (nparticles, 2) 的数组中,并将角速度存储在形状为 (nparticles,) 的数组中是很自然的,其中 nparticles 是粒子的数量。我们将这些数组称为 r_i 和 ang_vel_i:
r_i = np.array([[p.x, p.y] for p in self.particles])
ang_vel_i = np.array([p.ang_vel for p in self.particles])
速度方向,垂直于向量 (x, y),被定义为以下内容:
v_x = -y / norm
v_y = x / norm
范数可以使用在“Getting started with NumPy”标题下的“Calculating the norm”部分中展示的策略来计算:
norm_i = ((r_i ** 2).sum(axis=1))**0.5
对于 (-y, x) 分量,我们首先需要在 r_i 中交换 x 和 y 列,然后将第一列乘以 -1,如下面的代码所示:
v_i = r_i[:, [1, 0]] / norm_i
v_i[:, 0] *= -1
要计算位移,我们需要计算 v_i、ang_vel_i 和 timestep 的乘积。由于 ang_vel_i 的形状为 (nparticles,),它需要一个新轴才能与形状为 (nparticles, 2) 的 v_i 操作。我们将使用 numpy.newaxis 来实现这一点,如下所示:
d_i = timestep * ang_vel_i[:, np.newaxis] * v_i
r_i += d_i
在循环外部,我们必须更新粒子实例的新坐标,x 和 y,如下所示:
for i, p in enumerate(self.particles):
p.x, p.y = r_i[i]
总结一下,我们将实现一个名为ParticleSimulator.evolve_numpy的方法,并将其与重命名为ParticleSimulator.evolve_python的纯 Python 版本进行基准测试:
def evolve_numpy(self, dt):
timestep = 0.00001
nsteps = int(dt/timestep)
r_i = np.array([[p.x, p.y] for p in self.particles])
ang_vel_i = np.array([p.ang_vel for p in self.particles])
for i in range(nsteps):
norm_i = np.sqrt((r_i ** 2).sum(axis=1))
v_i = r_i[:, [1, 0]]
v_i[:, 0] *= -1
v_i /= norm_i[:, np.newaxis]
d_i = timestep * ang_vel_i[:, np.newaxis] * v_i
r_i += d_i
for i, p in enumerate(self.particles):
p.x, p.y = r_i[i]
我们还更新了基准测试,以便方便地更改粒子数量和模拟方法,如下所示:
def benchmark(npart=100, method='python'):
particles = [Particle(uniform(-1.0, 1.0),
uniform(-1.0, 1.0),
uniform(-1.0, 1.0))
for i in range(npart)]
simulator = ParticleSimulator(particles)
if method=='python':
simulator.evolve_python(0.1)
elif method == 'numpy':
simulator.evolve_numpy(0.1)
让我们在 IPython 会话中运行基准测试:
from simul import benchmark
%timeit benchmark(100, 'python')
1 loops, best of 3: 614 ms per loop
%timeit benchmark(100, 'numpy')
1 loops, best of 3: 415 ms per loop
我们有一些改进,但看起来并不像是一个巨大的速度提升。NumPy 的强大之处在于处理大型数组。如果我们增加粒子数量,我们将注意到更显著的性能提升:
%timeit benchmark(1000, 'python')
1 loops, best of 3: 6.13 s per loop
%timeit benchmark(1000, 'numpy')
1 loops, best of 3: 852 ms per loop
下一个图中的图是通过运行具有不同粒子数的基准测试产生的:

该图显示,两种实现都与粒子大小成线性关系,但纯 Python 版本的运行时间增长速度比 NumPy 版本快得多;在更大的尺寸下,我们有更大的 NumPy 优势。一般来说,当使用 NumPy 时,你应该尽量将事物打包成大型数组,并使用广播功能分组计算。
使用 numexpr 达到最佳性能
当处理复杂表达式时,NumPy 会在内存中存储中间结果。David M. Cooke 编写了一个名为numexpr的包,该包在运行时优化和编译数组表达式。它是通过优化 CPU 缓存的使用并利用多个处理器来工作的。
它的使用通常很简单,基于一个单一的功能--numexpr.evaluate。该函数将包含数组表达式的字符串作为其第一个参数。语法基本上与 NumPy 相同。例如,我们可以以下这种方式计算一个简单的a + b * c表达式:
a = np.random.rand(10000)
b = np.random.rand(10000)
c = np.random.rand(10000)
d = ne.evaluate('a + b * c')
numexpr包几乎在所有情况下都能提高性能,但要获得实质性的优势,你应该使用它来处理大型数组。一个涉及大型数组的应用是计算一个距离矩阵。在粒子系统中,距离矩阵包含粒子之间所有可能距离。为了计算它,我们首先应该计算连接任何两个粒子(i,j)的所有向量,如下所示:
x_ij = x_j - x_i
y_ij = y_j - y_i.
然后,我们通过取其范数来计算这个向量的长度,如下所示:
d_ij = sqrt(x_ij**2 + y_ij**2)
我们可以通过使用通常的广播规则(操作类似于外积)在 NumPy 中编写这个表达式:
r = np.random.rand(10000, 2)
r_i = r[:, np.newaxis]
r_j = r[np.newaxis, :]
d_ij = r_j - r_i
最后,我们使用以下代码行计算最后一个轴上的范数:
d_ij = np.sqrt((d_ij ** 2).sum(axis=2))
使用numexpr语法重写相同的表达式非常简单。numexpr包不支持在数组表达式中进行切片;因此,我们首先需要通过添加一个额外的维度来准备广播的操作数,如下所示:
r = np.random(10000, 2)
r_i = r[:, np.newaxis]
r_j = r[np.newaxis, :]
在那个时刻,我们应该尽量在一个表达式中包含尽可能多的操作,以便进行显著的优化。
大多数 NumPy 数学函数也存在于 numexpr 中。然而,有一个限制——减少操作(如求和)必须在最后发生。因此,我们必须首先计算总和,然后退出 numexpr,最后在另一个表达式中计算平方根:
d_ij = ne.evaluate('sum((r_j - r_i)**2, 2)')
d_ij = ne.evaluate('sqrt(d_ij)')
numexpr 编译器将通过不存储中间结果来避免冗余内存分配。在可能的情况下,它还会将操作分布到多个处理器上。在 distance_matrix.py 文件中,你可以找到实现两个版本的函数:distance_matrix_numpy 和 distance_matrix_numexpr:
from distance_matrix import (distance_matrix_numpy,
distance_matrix_numexpr)
%timeit distance_matrix_numpy(10000)
1 loops, best of 3: 3.56 s per loop
%timeit distance_matrix_numexpr(10000)
1 loops, best of 3: 858 ms per loop
通过简单地将表达式转换为使用 numexpr,我们能够将性能提高 4.5 倍。numexpr 包可以在你需要优化涉及大型数组和复杂操作的 NumPy 表达式时使用,并且你可以通过代码的最小更改来实现这一点。
Pandas
Pandas 是由 Wes McKinney 开发的库,最初是为了以无缝和高效的方式分析数据集而设计的。近年来,这个强大的库在 Python 社区中看到了令人难以置信的增长和巨大的采用。在本节中,我们将介绍这个库中提供的主要概念和工具,并使用它来提高各种用例的性能,这些用例无法使用 NumPy 的矢量化操作和广播来解决。
Pandas 基础知识
虽然 NumPy 主要处理数组,但 Pandas 的主要数据结构是 pandas.Series、pandas.DataFrame 和 pandas.Panel。在本章的其余部分,我们将用 pd 来缩写 pandas。
pd.Series 对象与 np.array 的主要区别在于,pd.Series 对象将一个特定的 键 关联到数组的每个元素。让我们通过一个例子来看看这在实践中是如何工作的。
假设我们正在尝试测试一种新的降压药,并且我们想要存储每个患者在接受药物后血压是否有所改善。我们可以通过将每个受试者 ID(用一个整数表示)与 True 关联来编码此信息,如果药物有效,否则为 False。
我们可以通过将表示药物有效性的值数组与键数组(患者)关联来创建一个 pd.Series 对象。键数组可以通过 Series 构造函数的 index 参数传递给 Series,如下面的代码片段所示:
import pandas as pd
patients = [0, 1, 2, 3]
effective = [True, True, False, False]
effective_series = pd.Series(effective, index=patients)
将一组从 0 到 N 的整数与一组值关联,在技术上可以使用 np.array 实现,因为在这种情况下,键将简单地是数组中元素的位置。在 Pandas 中,键不仅限于整数,还可以是字符串、浮点数,甚至是通用的(可哈希的)Python 对象。例如,我们可以轻松地将我们的 ID 转换为字符串,如下面的代码所示:
patients = ["a", "b", "c", "d"]
effective = [True, True, False, False]
effective_series = pd.Series(effective, index=patients)
一个有趣的观察是,虽然 NumPy 数组可以被看作是类似于 Python 列表的连续值集合,但 Pandas 的pd.Series对象可以被看作是一个将键映射到值的结构,类似于 Python 字典。
如果你想存储每个患者的初始和最终血压值怎么办?在 Pandas 中,可以使用pd.DataFrame对象将多个数据关联到每个键。
pd.DataFrame可以通过传递列和索引的字典,类似于pd.Series对象进行初始化。在以下示例中,我们将看到如何创建包含四个列的pd.DataFrame,这些列代表我们患者的收缩压和舒张压的初始和最终测量值:
patients = ["a", "b", "c", "d"]
columns = {
"sys_initial": [120, 126, 130, 115],
"dia_initial": [75, 85, 90, 87],
"sys_final": [115, 123, 130, 118],
"dia_final": [70, 82, 92, 87]
}
df = pd.DataFrame(columns, index=patients)
同样,你可以将pd.DataFrame视为pd.Series集合。实际上,你可以直接使用pd.Series实例的字典初始化pd.DataFrame:
columns = {
"sys_initial": pd.Series([120, 126, 130, 115], index=patients),
"dia_initial": pd.Series([75, 85, 90, 87], index=patients),
"sys_final": pd.Series([115, 123, 130, 118], index=patients),
"dia_final": pd.Series([70, 82, 92, 87], index=patients)
}
df = pd.DataFrame(columns)
要检查pd.DataFrame或pd.Series对象的内容,你可以使用pd.Series.head和pd.DataFrame.head方法,这些方法会打印数据集的前几行:
effective_series.head()
# Output:
# a True
# b True
# c False
# d False
# dtype: bool
df.head()
# Output:
# dia_final dia_initial sys_final sys_initial
# a 70 75 115 120
# b 82 85 123 126
# c 92 90 130 130
# d 87 87 118 115
就像pd.DataFrame可以用来存储pd.Series集合一样,你可以使用pd.Panel来存储pd.DataFrames集合。我们不会介绍pd.Panel的用法,因为它不像pd.Series和pd.DataFrame那样常用。要了解更多关于pd.Panel的信息,请确保参考优秀的文档pandas.pydata.org/pandas-docs/stable/dsintro.html#panel。
索引 Series 和 DataFrame 对象
根据其键检索pd.Series中的数据可以通过索引pd.Series.loc属性直观地完成:
effective_series.loc["a"]
# Result:
# True
你也可以使用pd.Series.iloc属性,根据其底层数组中的位置访问元素:
effective_series.iloc[0]
# Result:
# True
你还可以使用pd.Series.ix属性进行混合访问。如果键不是整数,它将尝试通过键匹配,否则它将提取由整数指示的位置的元素。当你直接访问pd.Series时,将发生类似的行为。以下示例演示了这些概念:
effective_series.ix["a"] # By key
effective_series.ix[0] # By position
# Equivalent
effective_series["a"] # By key
effective_series[0] # By position
注意,如果索引由整数组成,此方法将回退到仅键的方法(如loc)。在这种情况下,按位置索引的唯一选项是iloc方法。
pd.DataFrame的索引工作方式类似。例如,你可以使用pd.DataFrame.loc通过键提取一行,你也可以使用pd.DataFrame.iloc通过位置提取一行:
df.loc["a"]
df.iloc[0]
# Result:
# dia_final 70
# dia_initial 75
# sys_final 115
# sys_initial 120
# Name: a, dtype: int64
重要的一个方面是,在这种情况下返回的类型是pd.Series,其中每一列都是一个新键。为了检索特定的行和列,你可以使用以下代码。loc属性将按键索引行和列,而iloc版本将按整数索引行和列:
df.loc["a", "sys_initial"] # is equivalent to
df.loc["a"].loc["sys_initial"]
df.iloc[0, 1] # is equivalent to
df.iloc[0].iloc[1]
使用 ix 属性索引 pd.DataFrame 便于混合使用索引和基于位置的索引。例如,检索位置为 0 的行的 "sys_initial" 列可以按以下方式完成:
df.ix[0, "sys_initial"]
通过名称从 pd.DataFrame 中检索列可以通过常规索引或属性访问实现。要按位置检索列,可以使用 iloc 或使用 pd.DataFrame.column 属性来检索列名:
# Retrieve column by name
df["sys_initial"] # Equivalent to
df.sys_initial
# Retrieve column by position
df[df.columns[2]] # Equivalent to
df.iloc[:, 2]
提到的这些方法也支持类似于 NumPy 的更高级的索引,例如 bool、列表和 int 数组。
现在是时候考虑一些性能问题。Pandas 中的索引与字典之间有一些区别。例如,虽然字典的键不能包含重复项,但 Pandas 索引可以包含重复元素。然而,这种灵活性是有代价的--如果我们尝试访问非唯一索引中的元素,我们可能会遭受重大的性能损失--访问将是 O(N),类似于线性搜索,而不是 O(1),类似于字典。
减缓这种影响的一种方法是对索引进行排序;这将允许 Pandas 使用计算复杂度为 O(log(N)) 的二分搜索算法,这要好得多。这可以通过使用 pd.Series.sort_index 函数实现,如下面的代码所示(同样适用于 pd.DataFrame):
# Create a series with duplicate index
index = list(range(1000)) + list(range(1000))
# Accessing a normal series is a O(N) operation
series = pd.Series(range(2000), index=index)
# Sorting the will improve look-up scaling to O(log(N))
series.sort_index(inplace=True)
不同版本的计时总结在下表中:
| 索引类型 | N=10000 | N=20000 | N=30000 | 时间 |
|---|---|---|---|---|
| 唯一 | 12.30 | 12.58 | 13.30 | O(1) |
| 非唯一 | 494.95 | 814.10 | 1129.95 | O(N) |
| 非唯一(排序) | 145.93 | 145.81 | 145.66 | O(log(N)) |
使用 Pandas 进行数据库风格的操作
你可能已经注意到,“表格”数据类似于通常存储在数据库中的数据。数据库通常使用主键进行索引,而不同的列可以有不同的数据类型,就像在 pd.DataFrame 中一样。
Pandas 中索引操作的效率使其适合于数据库风格的操作,如计数、连接、分组和聚合。
映射
Pandas 支持与 NumPy 类似的元素级操作(毕竟,pd.Series 使用 np.array 存储其数据)。例如,可以在 pd.Series 和 pd.DataFrame 上非常容易地应用转换:
np.log(df.sys_initial) # Logarithm of a series
df.sys_initial ** 2 # Square a series
np.log(df) # Logarithm of a dataframe
df ** 2 # Square of a dataframe
你还可以以类似于 NumPy 的方式在两个 pd.Series 之间执行元素级操作。一个重要的区别是操作数将按键匹配,而不是按位置匹配;如果索引不匹配,结果值将被设置为 NaN。以下示例展示了这两种情况:
# Matching index
a = pd.Series([1, 2, 3], index=["a", "b", "c"])
b = pd.Series([4, 5, 6], index=["a", "b", "c"])
a + b
# Result:
# a 5
# b 7
# c 9
# dtype: int64
# Mismatching index
b = pd.Series([4, 5, 6], index=["a", "b", "d"])
# Result:
# a 5.0
# b 7.0
# c NaN
# d NaN
# dtype: float64
为了增加灵活性,Pandas 提供了 map、apply 和 applymap 方法,可以用来应用特定的转换。
可以使用 pd.Series.map 方法对每个值执行一个函数,并返回一个包含每个结果的 pd.Series。在以下示例中,我们展示了如何将 superstar 函数应用于 pd.Series 的每个元素:
a = pd.Series([1, 2, 3], index=["a", "b", "c"])
def superstar(x):
return '*' + str(x) + '*'
a.map(superstar)
# Result:
# a *1*
# b *2*
# c *3*
# dtype: object
pd.DataFrame.applymap 函数是 pd.Series.map 的等价函数,但适用于 DataFrames:
df.applymap(superstar)
# Result:
# dia_final dia_initial sys_final sys_initial
# a *70* *75* *115* *120*
# b *82* *85* *123* *126*
# c *92* *90* *130* *130*
# d *87* *87* *118* *115*
最后,pd.DataFrame.apply 函数可以将传递的函数应用于每一列或每一行,而不是逐个元素。选择可以通过 axis 参数执行,其中 0(默认值)对应于列,1 对应于行。请注意,apply 的返回值是一个 pd.Series:
df.apply(superstar, axis=0)
# Result:
# dia_final *a 70nb 82nc 92nd 87nName: dia...
# dia_initial *a 75nb 85nc 90nd 87nName: dia...
# sys_final *a 115nb 123nc 130nd 118nName:...
# sys_initial *a 120nb 126nc 130nd 115nName:...
# dtype: object
df.apply(superstar, axis=1)
# Result:
# a *dia_final 70ndia_initial 75nsys_f...
# b *dia_final 82ndia_initial 85nsys_f...
# c *dia_final 92ndia_initial 90nsys_f...
# d *dia_final 87ndia_initial 87nsys_f...
# dtype: object
Pandas 还支持使用方便的 eval 方法执行高效的 numexpr-风格表达式。例如,如果我们想计算最终和初始血压的差异,我们可以将表达式写成字符串,如下面的代码所示:
df.eval("sys_final - sys_initial")
# Result:
# a -5
# b -3
# c 0
# d 3
# dtype: int64
使用 pd.DataFrame.eval 表达式中的赋值运算符也可以创建新的列。请注意,如果使用 inplace=True 参数,操作将直接应用于原始的 pd.DataFrame;否则,函数将返回一个新的数据框。在下一个示例中,我们计算 sys_final 和 sys_initial 之间的差异,并将其存储在 sys_delta 列中:
df.eval("sys_delta = sys_final - sys_initial", inplace=False)
# Result:
# dia_final dia_initial sys_final sys_initial sys_delta
# a 70 75 115 120 -5
# b 82 85 123 126 -3
# c 92 90 130 130 0
# d 87 87 118 115 3
分组、聚合和转换
Pandas 最受赞赏的功能之一是简单简洁地表达需要分组、转换和聚合数据的分析管道。为了演示这个概念,让我们通过添加两个未接受治疗的新患者来扩展我们的数据集(这通常被称为 对照组)。我们还包含一个列,drug_admst,该列记录患者是否接受了治疗:
patients = ["a", "b", "c", "d", "e", "f"]
columns = {
"sys_initial": [120, 126, 130, 115, 150, 117],
"dia_initial": [75, 85, 90, 87, 90, 74],
"sys_final": [115, 123, 130, 118, 130, 121],
"dia_final": [70, 82, 92, 87, 85, 74],
"drug_admst": [True, True, True, True, False, False]
}
df = pd.DataFrame(columns, index=patients)
到目前为止,我们可能想知道两组之间的血压变化情况。您可以使用 pd.DataFrame.groupby 函数根据 drug_amst 对患者进行分组。返回值将是 DataFrameGroupBy 对象,可以迭代以获取每个 drug_admst 列值的新的 pd.DataFrame:
df.groupby('drug_admst')
for value, group in df.groupby('drug_admst'):
print("Value: {}".format(value))
print("Group DataFrame:")
print(group)
# Output:
# Value: False
# Group DataFrame:
# dia_final dia_initial drug_admst sys_final sys_initial
# e 85 90 False 130 150
# f 74 74 False 121 117
# Value: True
# Group DataFrame:
# dia_final dia_initial drug_admst sys_final sys_initial
# a 70 75 True 115 120
# b 82 85 True 123 126
# c 92 90 True 130 130
# d 87 87 True 118 115
在 DataFrameGroupBy 对象上迭代几乎从不必要,因为,多亏了方法链,可以直接计算与组相关的属性。例如,我们可能想要计算每个组的平均值、最大值或标准差。所有以某种方式总结数据的操作都称为聚合,可以使用 agg 方法执行。agg 的结果是一个新的 pd.DataFrame,它关联了分组变量和聚合结果,如下面的代码所示:
df.groupby('drug_admst').agg(np.mean)
# dia_final dia_initial sys_final sys_initial
# drug_admst
# False 79.50 82.00 125.5 133.50
# True 82.75 84.25 121.5 122.75
还可以对不表示汇总的 DataFrame 组进行处理。这类操作的一个常见例子是填充缺失值。这些中间步骤被称为 转换。
我们可以用一个例子来说明这个概念。假设我们的数据集中有一些缺失值,我们想要用同一组中其他值的平均值来替换这些值。这可以通过以下方式使用转换来完成:
df.loc['a','sys_initial'] = None
df.groupby('drug_admst').transform(lambda df: df.fillna(df.mean()))
# dia_final dia_initial sys_final sys_initial
# a 70 75 115 123.666667
# b 82 85 123 126.000000
# c 92 90 130 130.000000
# d 87 87 118 115.000000
# e 85 90 130 150.000000
# f 74 74 121 117.000000
连接
连接对于聚合分散在不同表中的数据非常有用。假设我们想在数据集中包含患者测量所进行的医院的位置。我们可以使用H1、H2和H3标签来引用每个患者的位置,并将医院的地址和标识符存储在hospital表中:
hospitals = pd.DataFrame(
{ "name" : ["City 1", "City 2", "City 3"],
"address" : ["Address 1", "Address 2", "Address 3"],
"city": ["City 1", "City 2", "City 3"] },
index=["H1", "H2", "H3"])
hospital_id = ["H1", "H2", "H2", "H3", "H3", "H3"]
df['hospital_id'] = hospital_id
现在,我们想要找到每个患者所测量的城市。我们需要将hospital_id列中的键映射到存储在hospitals表中的城市。
这肯定可以用 Python 中的字典来实现:
hospital_dict = {
"H1": ("City 1", "Name 1", "Address 1"),
"H2": ("City 2", "Name 2", "Address 2"),
"H3": ("City 3", "Name 3", "Address 3")
}
cities = [hospital_dict[key][0]
for key in hospital_id]
此算法以O(N)的时间复杂度高效运行,其中N是hospital_id的大小。Pandas 允许您使用简单的索引来编码相同的操作;优势在于连接将在高度优化的 Cython 和高效的哈希算法下执行。前面的简单 Python 表达式可以很容易地以这种方式转换为 Pandas:
cities = hospitals.loc[hospital_id, "city"]
更高级的连接也可以使用pd.DataFrame.join方法执行,这将生成一个新的pd.DataFrame,将为每个患者附加医院信息:
result = df.join(hospitals, on='hospital_id')
result.columns
# Result:
# Index(['dia_final', 'dia_initial', 'drug_admst',
# 'sys_final', 'sys_initial',
# 'hospital_id', 'address', 'city', 'name'],
# dtype='object')
概述
在本章中,我们学习了如何操作 NumPy 数组,以及如何使用数组广播编写快速数学表达式。这些知识将帮助您编写更简洁、更具表现力的代码,同时获得实质性的性能提升。我们还介绍了numexpr库,以最小的努力进一步加快 NumPy 计算的速度。
Pandas 实现了高效的数据结构,这在分析大型数据集时非常有用。特别是,当数据通过非整数键索引时,Pandas 表现得尤为出色,并提供了非常快速的哈希算法。
NumPy 和 Pandas 在处理大型、同质输入时表现良好,但当表达式变得复杂且无法使用这些库提供的工具表达时,它们就不太适合了。在这种情况下,我们可以通过使用 Cython 包与 C 接口,利用 Python 作为粘合语言的能力。
第四章:使用 Cython 的 C 性能
Cython 是一种通过支持函数、变量和类的类型声明来扩展 Python 的语言。这些类型声明使 Cython 能够将 Python 脚本编译成高效的 C 代码。Cython 还可以作为 Python 和 C 之间的桥梁,因为它提供了易于使用的结构来编写对外部 C 和 C++ 例程的接口。
在本章中,我们将学习以下内容:
-
Cython 语法基础
-
如何编译 Cython 程序
-
如何使用 静态类型 生成快速代码
-
如何使用类型化的 内存视图 高效地操作数组
-
优化示例粒子模拟器
-
在 Jupyter 笔记本中使用 Cython 的技巧
-
可用于 Cython 的分析工具
虽然对 C 的基本了解有帮助,但本章仅关注 Python 优化背景下的 Cython。因此,它不需要任何 C 背景。
编译 Cython 扩展
Cython 语法设计上是一个 Python 的超集。Cython 可以编译大多数 Python 模块,只有少数例外,而无需任何更改。Cython 源文件具有 .pyx 扩展名,可以使用 cython 命令编译成 C 文件。
我们的第一个 Cython 脚本将包含一个简单的函数,该函数将打印输出 Hello, World!。创建一个新的 hello.pyx 文件,包含以下代码:
def hello():
print('Hello, World!')
cython 命令将读取 hello.pyx 并生成 hello.c 文件:
$ cython hello.pyx
要将 hello.c 编译成 Python 扩展模块,我们将使用 GCC 编译器。我们需要添加一些依赖于操作系统的特定于 Python 的编译选项。指定包含头文件的目录很重要;在以下示例中,目录是 /usr/include/python3.5/:
$ gcc -shared -pthread -fPIC -fwrapv -O2 -Wall -fno-strict-aliasing -lm -I/usr/include/python3.5/ -o hello.so hello.c
要找到您的 Python 包含目录,您可以使用 distutils 工具:sysconfig.get_python_inc。要执行它,您可以简单地发出以下命令 python -c "from distutils import sysconfig; print(sysconfig.get_python_inc())"。
这将生成一个名为 hello.so 的文件,这是一个可以直接导入 Python 会话的 C 扩展模块:
>>> import hello
>>> hello.hello()
Hello, World!
Cython 接受 Python 2 和 Python 3 作为输入和输出语言。换句话说,您可以使用 -3 选项编译 Python 3 脚本 hello.pyx 文件:
$ cython -3 hello.pyx
生成的 hello.c 可以通过包含相应的头文件并使用 -I 选项来编译,以便在 Python 2 和 Python 3 中使用,如下所示:
$ gcc -I/usr/include/python3.5 # ... other options
$ gcc -I/usr/include/python2.7 # ... other options
使用 distutils,Python 的标准打包工具,可以更直接地编译 Cython 程序。通过编写一个 setup.py 脚本,我们可以直接将 .pyx 文件编译成扩展模块。为了编译我们的 hello.pyx 示例,我们可以编写一个包含以下代码的最小 setup.py:
from distutils.core import setup
from Cython.Build import cythonize
setup(
name='Hello',
ext_modules = cythonize('hello.pyx')
)
在前面的代码的前两行中,我们导入了 setup 函数和 cythonize 辅助函数。setup 函数包含一些键值对,指定了应用程序的名称和需要构建的扩展。
cythonize 辅助函数接受一个字符串或一个包含我们想要编译的 Cython 模块的字符串列表。您也可以使用以下代码使用 glob 模式:
cythonize(['hello.pyx', 'world.pyx', '*.pyx'])
要使用 distutils 编译我们的扩展模块,您可以执行以下代码的 setup.py 脚本:
$ python setup.py build_ext --inplace
build_ext 选项告诉脚本构建 ext_modules 中指示的扩展模块,而 --inplace 选项告诉脚本将 hello.so 输出文件放置在源文件相同的目录中(而不是构建目录)。
Cython 模块也可以通过 pyximport 自动编译。您只需要在脚本开头调用 pyximport.install()(或者您需要在解释器中发出该命令)。完成此操作后,您可以直接导入 .pyx 文件,pyximport 将透明地编译相应的 Cython 模块:
>>> import pyximport
>>> pyximport.install()
>>> import hello # This will compile hello.pyx
不幸的是,pyximport 并不适用于所有类型的配置(例如,当它们涉及 C 和 Cython 文件的组合时),但它对于测试简单的脚本来说很方便。
自 0.13 版本以来,IPython 包含了 cythonmagic 扩展,可以交互式地编写和测试一系列 Cython 语句。您可以使用 load_ext 在 IPython 壳中加载扩展:
%load_ext cythonmagic
一旦加载了扩展,您就可以使用 %%cython 单元魔法来编写多行 Cython 片段。在以下示例中,我们定义了一个 hello_snippet 函数,该函数将被编译并添加到 IPython 会话命名空间中:
%%cython
def hello_snippet():
print("Hello, Cython!")
hello_snippet()
Hello, Cython!
添加静态类型
在 Python 中,变量在程序执行过程中可以与不同类型的对象关联。虽然这个特性使得语言更加灵活和动态,但它也给解释器带来了显著的开销,因为解释器需要在运行时查找变量的类型和方法,这使得进行各种优化变得困难。Cython 通过扩展 Python 语言,增加了显式的类型声明,以便通过编译生成高效的 C 扩展。
在 Cython 中声明数据类型的主要方式是通过 cdef 语句。cdef 关键字可以在多个上下文中使用,例如变量、函数和扩展类型(静态类型类)。
变量
在 Cython 中,您可以通过在变量前加上 cdef 和相应的类型来声明变量的类型。例如,我们可以以下这种方式声明 i 变量为 16 位整数:
cdef int i
cdef 语句支持在同一行上使用多个变量名,以及可选的初始化,如下所示:
cdef double a, b = 2.0, c = 3.0
与常规变量相比,类型化变量被处理得不同。在 Python 中,变量通常被描述为指向内存中对象的 标签。例如,我们可以在程序的任何位置将值 'hello' 赋予 a 变量,而不会受到限制:
a = 'hello'
a变量持有对'hello'字符串的引用。我们还可以在代码的稍后部分自由地将另一个值(例如,整数1)赋给同一个变量:
a = 1
Python 将没有任何问题地将整数1赋值给a变量。
带类型的变量表现得很不同,通常被描述为数据容器:我们只能存储适合由其数据类型确定的容器中的值。例如,如果我们将a变量声明为int,然后我们尝试将其赋值给double,Cython 将触发一个错误,如下面的代码所示:
%%cython
cdef int i
i = 3.0
# Output has been cut
...cf4b.pyx:2:4 Cannot assign type 'double' to 'int'
静态类型使编译器能够执行有用的优化。例如,如果我们声明循环索引为int,Cython 将重写循环为纯 C,而无需进入 Python 解释器。类型声明保证了索引的类型始终是int,并且在运行时不能被覆盖,这样编译器就可以自由地进行优化,而不会损害程序的正确性。
我们可以通过一个小测试用例来评估这种情况下的速度提升。在下面的示例中,我们实现了一个简单的循环,该循环将一个变量增加 100 次。使用 Cython,example函数可以编写如下:
%%cython
def example():
cdef int i, j=0
for i in range(100):
j += 1
return j
example()
# Result:
# 100
我们可以比较一个类似的无类型、纯 Python 循环的速度:
def example_python():
j=0
for i in range(100):
j += 1
return j
%timeit example()
10000000 loops, best of 3: 25 ns per loop
%timeit example_python()
100000 loops, best of 3: 2.74 us per loop
通过实现这种简单的类型声明获得的加速效果是惊人的 100 倍!这是因为 Cython 循环首先被转换为纯 C,然后转换为高效的机器代码,而 Python 循环仍然依赖于慢速的解释器。
在 Cython 中,可以声明变量为任何标准 C 类型,也可以使用经典的 C 构造,如struct、enum和typedef来定义自定义类型。
一个有趣的例子是,如果我们声明一个变量为object类型,该变量将接受任何类型的 Python 对象:
cdef object a_py
# both 'hello' and 1 are Python objects
a_py = 'hello'
a_py = 1
注意,将变量声明为object没有性能优势,因为访问和操作该对象仍然需要解释器查找变量的底层类型及其属性和方法。
有时,某些数据类型(例如float和int数字)在某种意义上是兼容的,即它们可以被相互转换。在 Cython 中,可以通过在尖括号中包围目标类型来在类型之间进行转换(cast),如下面的代码片段所示:
cdef int a = 0
cdef double b
b = <double> a
函数
您可以通过在每个参数名称前指定类型来向 Python 函数的参数添加类型信息。以这种方式指定的函数将像常规 Python 函数一样工作并执行,但它们的参数将进行类型检查。我们可以编写一个max_python函数,它返回两个整数之间的较大值:
def max_python(int a, int b):
return a if a > b else b
以这种方式指定的函数将执行类型检查并将参数视为类型化变量,就像在cdef定义中一样。然而,该函数仍然是一个 Python 函数,多次调用它仍然需要切换回解释器。为了允许 Cython 进行函数调用优化,我们应该使用cdef语句声明返回类型的类型:
cdef int max_cython(int a, int b):
return a if a > b else b
以这种方式声明的函数将被转换为本地 C 函数,与 Python 函数相比,它们的开销要小得多。一个显著的缺点是它们不能从 Python 中使用,而只能从 Cython 中使用,并且它们的范围限制在同一个 Cython 文件中,除非它们在定义文件中公开(参考共享声明部分)。
幸运的是,Cython 允许你定义既可以从 Python 调用也可以转换为高性能 C 函数的函数。如果你使用cpdef语句声明一个函数,Cython 将生成该函数的两个版本:一个可供解释器使用的 Python 版本,以及一个从 Cython 可用的快速 C 函数。cpdef语法与cdef相同,如下所示:
cpdef int max_hybrid(int a, int b):
return a if a > b else b
有时,即使有 C 函数,调用开销也可能成为性能问题,尤其是在关键循环中多次调用同一个函数时。当函数体很小的时候,在函数定义前添加inline关键字是很方便的;函数调用将被函数体本身替换。我们的max函数是进行内联的好候选:
cdef inline int max_inline(int a, int b):
return a if a > b else b
类
我们可以使用cdef class语句定义扩展类型,并在类体中声明其属性。例如,我们可以创建一个扩展类型--Point--如下面的代码所示,它存储两个double类型的坐标(x,y):
cdef class Point
cdef double x
cdef double y
def __init__(self, double x, double y):
self.x = x
self.y = y
在类方法中访问声明的属性允许 Cython 通过直接访问底层 C struct中的给定字段来绕过昂贵的 Python 属性查找。因此,类型化类中的属性访问是一个极快的操作。
在你的代码中使用cdef class,需要在编译时明确声明你打算使用的变量类型。你可以在任何你将使用标准类型(如double、float和int)的上下文中使用扩展类型名(如Point)。例如,如果我们想创建一个 Cython 函数来计算从原点(在示例中,该函数名为norm)到Point的距离,我们必须将输入变量声明为Point,如下面的代码所示:
cdef double norm(Point p):
return (p.x**2 + p.y**2)**0.5
就像类型化函数一样,类型化类也有一些限制。如果你尝试从 Python 访问扩展类型属性,你会得到一个AttributeError,如下所示:
>>> a = Point(0.0, 0.0)
>>> a.x
AttributeError: 'Point' object has no attribute 'x'
为了从 Python 代码中访问属性,你必须使用public(用于读写访问)或readonly指定符在属性声明中,如下面的代码所示:
cdef class Point:
cdef public double x
此外,可以使用 cpdef 语句声明方法,就像常规函数一样。
扩展类型不支持在运行时添加额外的属性。为了做到这一点,一种解决方案是定义一个 Python 类,它是类型类的子类,并在纯 Python 中扩展其属性和方法。
分享声明
当编写您的 Cython 模块时,您可能希望将最常用的函数和类声明重新组织到一个单独的文件中,以便它们可以在不同的模块中重用。Cython 允许您将这些组件放入一个 定义文件 中,并通过 cimport 语句访问它们.
假设我们有一个包含 max 和 min 函数的模块,并且我们想在多个 Cython 程序中重用这些函数。如果我们简单地在 .pyx 文件中编写一些函数,声明将仅限于同一文件。
定义文件也用于将 Cython 与外部 C 代码接口。想法是将定义文件中的类型和函数原型复制(或更准确地说,翻译)到外部 C 代码中,该代码将在单独的步骤中编译和链接。
要共享 max 和 min 函数,我们需要编写一个具有 .pxd 扩展名的定义文件。此类文件仅包含我们想要与其他模块共享的类型和函数原型--一个 公共 接口。我们可以在名为 mathlib.pxd 的文件中声明 max 和 min 函数的原型,如下所示:
cdef int max(int a, int b)
cdef int min(int a, int b)
如您所见,我们只编写了函数名称和参数,而没有实现函数体。
函数实现将放入具有相同基本名称但具有 .pyx 扩展名的实现文件中--mathlib.pyx:
cdef int max(int a, int b):
return a if a > b else b
cdef int min(int a, int b):
return a if a < b else b
mathlib 模块现在可以从另一个 Cython 模块导入。
为了测试我们新的 Cython 模块,我们将创建一个名为 distance.pyx 的文件,其中包含一个名为 chebyshev 的函数。该函数将计算两点之间的 Chebyshev 距离,如下面的代码所示。两点坐标--(x1, y1) 和 (x2, y2) 之间的 Chebyshev 距离定义为每个坐标之间差异的最大值:
max(abs(x1 - x2), abs(y1 - y2))
要实现 chebyshev 函数,我们将使用通过 cimport 语句导入的 mathlib.pxd 中声明的 max 函数,如下面的代码片段所示:
from mathlib cimport max
def chebyshev(int x1, int y1, int x2, int y2):
return max(abs(x1 - x2), abs(y1 - y2))
cimport 语句将读取 hello.pxd,并使用 max 定义来生成 distance.c 文件。
与数组一起工作
数值和高性能计算通常使用数组。Cython 提供了一种直接使用低级 C 数组或更通用的 类型内存视图 与它们交互的简单方法。
C 数组和指针
C 数组是一系列相同类型的项的集合,在内存中连续存储。在深入了解细节之前,了解(或复习)C 中内存的管理方式是有帮助的。
C 中的变量就像容器。当创建变量时,会在内存中预留空间以存储其值。例如,如果我们创建一个包含 64 位浮点数(double)的变量,程序将分配 64 位(16 字节)的内存。可以通过该内存位置的地址访问这部分内存。
要获取变量的地址,我们可以使用表示为&符号的地址操作符。我们还可以使用printf函数,如下所示,它可在libc.stdio Cython 模块中找到,以打印该变量的地址:
%%cython
cdef double a
from libc.stdio cimport printf
printf("%p", &a)
# Output:
# 0x7fc8bb611210
内存地址可以存储在特殊的变量中,称为指针,可以通过在变量名前加上*前缀来声明,如下所示:
from libc.stdio cimport printf
cdef double a
cdef double *a_pointer
a_pointer = &a # a_pointer and &a are of the same type
如果我们有一个指针,并且我们想获取它所指向的地址中的值,我们可以使用表示为*符号的解引用操作符。请注意,在此上下文中使用的*与在变量声明中使用的*有不同的含义:
cdef double a
cdef double *a_pointer
a_pointer = &a
a = 3.0
print(*a_pointer) # prints 3.0
当声明 C 数组时,程序会分配足够的空间来容纳请求的所有元素。例如,要创建一个包含 10 个double值(每个 16 字节)的数组,程序将在内存中预留16 * 10 = 160字节的连续空间。在 Cython 中,我们可以使用以下语法声明此类数组:
cdef double arr[10]
我们还可以使用以下语法声明多维数组,例如具有5行和2列的数组:
cdef double arr[5][2]
内存将在单个内存块中分配,一行接一行。这种顺序通常被称为行主序,如下面的图中所示。数组也可以按列主序排序,正如 FORTRAN 编程语言的情况:

数组排序有重要的后果。当我们遍历 C 数组的最后一个维度时,我们访问连续的内存块(在我们的例子中,0, 1, 2, 3 ...),而当我们遍历第一个维度时,我们会跳过一些位置(0, 2, 4, 6, 8, 1 ...)。你应该始终尝试顺序访问内存,因为这优化了缓存和内存使用。
我们可以使用标准索引来存储和检索数组中的元素;C 数组不支持复杂索引或切片:
arr[0] = 1.0
C 数组具有许多与指针相同的行为。实际上,arr变量指向数组的第一个元素的内存位置。我们可以使用解引用操作符来验证数组第一个元素的地址与arr变量中包含的地址相同,如下所示:
%%cython
from libc.stdio cimport printf
cdef double arr[10]
printf("%pn", arr)
printf("%pn", &arr[0])
# Output
# 0x7ff6de204220
# 0x7ff6de204220
当与现有的 C 库接口或需要精细控制内存时(此外,它们性能非常出色),你应该使用 C 数组和指针。这种精细控制水平也容易出错,因为它不能阻止你访问错误的内存位置。对于更常见的用例和改进的安全性,你可以使用 NumPy 数组或类型化内存视图。
NumPy 数组
在 Cython 中,可以使用 NumPy 数组作为常规 Python 对象,利用它们已经优化的广播操作。然而,Cython 提供了一个更好的直接迭代支持的 numpy 模块。
当我们通常访问 NumPy 数组的一个元素时,解释器级别会发生一些其他操作,造成主要开销。Cython 可以通过直接在 NumPy 数组使用的底层内存区域上操作来绕过这些操作和检查,从而获得令人印象深刻的性能提升。
NumPy 数组可以声明为 ndarray 数据类型。为了在我们的代码中使用该数据类型,我们首先需要 cimport numpy Cython 模块(它与 Python NumPy 模块不同)。我们将该模块绑定到 c_np 变量,以使与 Python numpy 模块的差异更加明确:
cimport numpy as c_np
import numpy as np
现在,我们可以通过指定类型和方括号内的维度数(这称为 缓冲区语法)来声明 NumPy 数组。要声明一个类型为 double 的二维数组,我们可以使用以下代码:
cdef c_np.ndarray[double, ndim=2] arr
对此数组的访问将通过直接操作底层内存区域来完成;操作将避免进入解释器,从而给我们带来巨大的速度提升。
在下一个示例中,我们将展示使用类型化 numpy 数组的方法,并将它们与常规 Python 版本进行比较。
我们首先编写 numpy_bench_py 函数,该函数递增 py_arr 的每个元素。我们声明 i 索引为整数,以避免 for 循环的开销:
%%cython
import numpy as np
def numpy_bench_py():
py_arr = np.random.rand(1000)
cdef int i
for i in range(1000):
py_arr[i] += 1
然后,我们使用 ndarray 类型编写相同的函数。请注意,在定义 c_arr 变量使用 c_np.ndarray 之后,我们可以从 numpy Python 模块给它赋值一个数组:
%%cython
import numpy as np
cimport numpy as c_np
def numpy_bench_c():
cdef c_np.ndarray[double, ndim=1] c_arr
c_arr = np.random.rand(1000)
cdef int i
for i in range(1000):
c_arr[i] += 1
我们可以使用 timeit 来计时结果,并看到类型化版本的速度快了 50 倍:
%timeit numpy_bench_c()
100000 loops, best of 3: 11.5 us per loop
%timeit numpy_bench_py()
1000 loops, best of 3: 603 us per loop
类型化内存视图
C 和 NumPy 数组以及内置的 bytes、bytearray 和 array.array 对象在某种程度上是相似的,因为它们都在连续的内存区域(也称为内存 缓冲区)上操作。Cython 提供了一个统一的接口——类型化内存视图,它统一并简化了对所有这些数据类型的访问。
memoryview 是一个保持对特定内存区域的引用的对象。它实际上并不拥有内存,但它可以读取和更改其内容;换句话说,它是对底层数据的视图。内存视图可以使用特殊语法定义。例如,我们可以以下这种方式定义 int 类型的内存视图和一个二维 double 类型的内存视图:
cdef int[:] a
cdef double[:, :] b
相同的语法也适用于变量、函数定义、类属性等任何类型的声明。任何暴露缓冲区接口的对象(例如,NumPy 数组、bytes 和 array.array 对象)都将自动绑定到 memoryview。例如,我们可以通过简单的变量赋值将 memoryview 绑定到 NumPy 数组:
import numpy as np
cdef int[:] arr
arr_np = np.zeros(10, dtype='int32')
arr = arr_np # We bind the array to the memoryview
重要的是要注意,memoryview 并不“拥有”数据,它只提供了一种访问和更改其绑定数据的途径;在这种情况下,所有权留给了 NumPy 数组。正如您在以下示例中可以看到的,通过 memoryview 进行的更改将作用于底层内存区域,并将反映在原始 NumPy 结构中(反之亦然):
arr[2] = 1 # Changing memoryview
print(arr_np)
# [0 0 1 0 0 0 0 0 0 0]
在某种意义上,memoryviews 背后的机制与我们在 第三章 使用 NumPy 和 Pandas 进行快速数组操作 中看到的 NumPy 切片时 NumPy 产生的机制相似。正如我们所见,切片 NumPy 数组不会复制数据,而是返回对相同内存区域的视图,对视图的更改将反映在原始数组上。
Memoryviews 也支持使用标准的 NumPy 语法进行数组切片:
cdef int[:, :, :] a
arr[0, :, :] # Is a 2-dimensional memoryview
arr[0, 0, :] # Is a 1-dimensional memoryview
arr[0, 0, 0] # Is an int
要在两个 memoryview 之间复制数据,您可以使用类似于切片赋值的语法,如下面的代码所示:
import numpy as np
cdef double[:, :] b
cdef double[:] r
b = np.random.rand(10, 3)
r = np.zeros(3, dtype='float64')
b[0, :] = r # Copy the value of r in the first row of b
在下一节中,我们将使用类型化的 memoryviews 为粒子模拟器中的数组声明类型。
Cython 中的粒子模拟器
现在我们已经对 Cython 的工作原理有了基本的了解,我们可以重写 ParticleSimulator.evolve 方法。多亏了 Cython,我们可以将我们的循环转换为 C 语言,从而消除由 Python 解释器引入的开销。
在 第三章 使用 NumPy 和 Pandas 进行快速数组操作 中,我们使用 NumPy 编写了一个相当高效的 evolve 方法版本。我们可以将旧版本重命名为 evolve_numpy 以区分新旧版本:
def evolve_numpy(self, dt):
timestep = 0.00001
nsteps = int(dt/timestep)
r_i = np.array([[p.x, p.y] for p in self.particles])
ang_speed_i = np.array([p.ang_speed for p in self.particles])
v_i = np.empty_like(r_i)
for i in range(nsteps):
norm_i = np.sqrt((r_i ** 2).sum(axis=1))
v_i = r_i[:, [1, 0]]
v_i[:, 0] *= -1
v_i /= norm_i[:, np.newaxis]
d_i = timestep * ang_speed_i[:, np.newaxis] * v_i
r_i += d_i
for i, p in enumerate(self.particles):
p.x, p.y = r_i[i]
我们希望将此代码转换为 Cython。我们的策略将是利用快速的索引操作,通过移除 NumPy 数组广播,从而回到基于索引的算法。由于 Cython 生成高效的 C 代码,我们可以自由地使用尽可能多的循环,而不会产生任何性能惩罚。
作为设计选择,我们可以决定将循环封装在一个函数中,我们将用 Cython 模块 cevolve.pyx 重新编写这个函数。该模块将包含一个单一的 Python 函数 c_evolve,它将接受粒子位置、角速度、时间步长和步数作为输入。
起初,我们不添加类型信息;我们只想隔离函数并确保我们可以无错误地编译我们的模块:
# file: simul.py
def evolve_cython(self, dt):
timestep = 0.00001
nsteps = int(dt/timestep)
r_i = np.array([[p.x, p.y] for p in self.particles])
ang_speed_i = np.array([p.ang_speed for p in self.particles])
c_evolve(r_i, ang_speed_i, timestep, nsteps)
for i, p in enumerate(self.particles):
p.x, p.y = r_i[i]
# file: cevolve.pyx
import numpy as np
def c_evolve(r_i, ang_speed_i, timestep, nsteps):
v_i = np.empty_like(r_i)
for i in range(nsteps):
norm_i = np.sqrt((r_i ** 2).sum(axis=1))
v_i = r_i[:, [1, 0]]
v_i[:, 0] *= -1
v_i /= norm_i[:, np.newaxis]
d_i = timestep * ang_speed_i[:, np.newaxis] * v_i
r_i += d_i
注意,我们不需要为 c_evolve 提供返回值,因为值是在 r_i 数组中就地更新的。我们可以通过稍微更改我们的基准函数来对无类型的 Cython 版本与旧的 NumPy 版本进行基准测试,如下所示:
def benchmark(npart=100, method='python'):
particles = [Particle(uniform(-1.0, 1.0),
uniform(-1.0, 1.0),
uniform(-1.0, 1.0))
for i in range(npart)]
simulator = ParticleSimulator(particles)
if method=='python':
simulator.evolve_python(0.1)
elif method == 'cython':
simulator.evolve_cython(0.1)
elif method == 'numpy':
simulator.evolve_numpy(0.1)
我们可以在 IPython 壳中计时不同的版本:
%timeit benchmark(100, 'cython')
1 loops, best of 3: 401 ms per loop
%timeit benchmark(100, 'numpy')
1 loops, best of 3: 413 ms per loop
这两个版本的速度相同。编译不带静态类型的 Cython 模块与纯 Python 没有任何优势。下一步是声明所有重要变量的类型,以便 Cython 可以执行其优化。
我们可以开始向函数参数添加类型,看看性能如何变化。我们可以声明数组为包含 double 值的 typed memoryviews。值得一提的是,如果我们传递一个 int 或 float32 类型的数组,转换不会自动发生,我们将得到一个错误:
def c_evolve(double[:, :] r_i,
double[:] ang_speed_i,
double timestep,
int nsteps):
到目前为止,我们可以重写遍历粒子和时间步的循环。我们可以声明 i 和 j 迭代索引以及 nparticles 粒子数量为 int:
cdef int i, j
cdef int nparticles = r_i.shape[0]
该算法与纯 Python 版本非常相似;我们遍历粒子和时间步,并使用以下代码计算每个粒子坐标的速度和位移向量:
for i in range(nsteps):
for j in range(nparticles):
x = r_i[j, 0]
y = r_i[j, 1]
ang_speed = ang_speed_i[j]
norm = sqrt(x ** 2 + y ** 2)
vx = (-y)/norm
vy = x/norm
dx = timestep * ang_speed * vx
dy = timestep * ang_speed * vy
r_i[j, 0] += dx
r_i[j, 1] += dy
在前面的代码中,我们添加了 x、y、ang_speed、norm、vx、vy、dx 和 dy 变量。为了避免 Python 解释器的开销,我们不得不在函数开始时声明它们对应的类型,如下所示:
cdef double norm, x, y, vx, vy, dx, dy, ang_speed
我们还使用了一个名为 sqrt 的函数来计算范数。如果我们使用 math 模块或 numpy 中的 sqrt,我们将在我们的关键循环中再次包含一个慢速的 Python 函数,从而降低我们的性能。标准 C 库中有一个快速的 sqrt,已经包含在 libc.math Cython 模块中:
from libc.math cimport sqrt
我们可以重新运行我们的基准测试来评估我们的改进,如下所示:
In [4]: %timeit benchmark(100, 'cython')
100 loops, best of 3: 13.4 ms per loop
In [5]: %timeit benchmark(100, 'numpy')
1 loops, best of 3: 429 ms per loop
对于小的粒子数量,速度提升非常巨大,我们获得了比上一个版本高 40 倍的性能。然而,我们也应该尝试用更多的粒子数量来测试性能缩放:
In [2]: %timeit benchmark(1000, 'cython')
10 loops, best of 3: 134 ms per loop
In [3]: %timeit benchmark(1000, 'numpy')
1 loops, best of 3: 877 ms per loop
随着粒子数量的增加,两个版本的速度越来越接近。通过将粒子大小增加到 1000,我们已经将速度提升降低到更适度的 6 倍。这可能是由于随着粒子数量的增加,Python for 循环的开销与其他操作速度相比变得越来越不显著。
分析 Cython
Cython 提供了一个名为 annotated view 的功能,有助于找到在 Python 解释器中执行的哪些行,以及哪些是后续优化的良好候选。我们可以通过使用 -a 选项编译 Cython 文件来启用此功能。这样,Cython 将生成一个包含我们代码的 HTML 文件,并附有一些有用的信息。使用 -a 选项的方法如下:
$ cython -a cevolve.pyx
$ firefox cevolve.html
下面的截图显示的 HTML 文件逐行显示了我们的 Cython 文件:

源代码中的每一行都可能以不同的黄色阴影出现。颜色越深,与解释器相关的调用就越多,而白色行则被转换为常规的 C 代码。由于解释器调用会显著减慢执行速度,目标是将函数体尽可能地变为白色。通过单击任何一行,我们可以检查 Cython 编译器生成的代码。例如,v_y = x/norm 行检查 norm 是否不是 0,如果条件未验证,则抛出 ZeroDivisionError。x = r_i[j, 0] 行显示 Cython 检查索引是否在数组的边界内。您可能会注意到最后一行颜色非常深;通过检查代码,我们可以看到这实际上是一个错误;代码引用了与函数结束相关的样板代码。
Cython 可以关闭检查,例如除以零,以便它可以移除那些额外的解释器相关调用;这通常是通过编译器指令完成的。有几种不同的方法可以添加编译器指令:
-
使用装饰器或上下文管理器
-
在文件开头使用注释
-
使用 Cython 命令行选项
要获取 Cython 编译器指令的完整列表,您可以参考官方文档docs.cython.org/src/reference/compilation.html#compiler-directives。
例如,要禁用数组边界检查,只需用 cython.boundscheck 装饰一个函数,如下所示:
cimport cython
@cython.boundscheck(False)
def myfunction():
# Code here
或者,我们可以使用 cython.boundscheck 将代码块包装成上下文管理器,如下所示:
with cython.boundscheck(False):
# Code here
如果我们想禁用整个模块的边界检查,我们可以在文件开头添加以下代码行:
# cython: boundscheck=False
要使用命令行选项更改指令,您可以使用 -X 选项,如下所示:
$ cython -X boundscheck=True
要禁用 c_evolve 函数中的额外检查,我们可以禁用 boundscheck 指令并启用 cdivision(这防止了 ZeroDivisionError 的检查),如下面的代码所示:
cimport cython
@cython.boundscheck(False)
@cython.cdivision(True)
def c_evolve(double[:, :] r_i,
double[:] ang_speed_i,
double timestep,
int nsteps):
如果我们再次查看注释视图,循环体已经完全变成白色——我们移除了内循环中所有解释器的痕迹。为了重新编译,只需再次输入 python setup.py build_ext --inplace。然而,通过运行基准测试,我们注意到我们没有获得性能提升,这表明这些检查不是瓶颈的一部分:
In [3]: %timeit benchmark(100, 'cython')
100 loops, best of 3: 13.4 ms per loop
另一种对 Cython 代码进行性能分析的方法是通过使用 cProfile 模块。例如,我们可以编写一个简单的函数来计算坐标数组之间的 Chebyshev 距离。创建一个 cheb.py 文件:
import numpy as np
from distance import chebyshev
def benchmark():
a = np.random.rand(100, 2)
b = np.random.rand(100, 2)
for x1, y1 in a:
for x2, y2 in b:
chebyshev(x1, x2, y1, y2)
如果我们尝试以当前状态分析此脚本,我们将无法获取关于我们在 Cython 中实现的函数的任何统计信息。如果我们想收集 max 和 min 函数的配置文件信息,我们需要将 profile=True 选项添加到 mathlib.pyx 文件中,如下面的代码所示:
# cython: profile=True
cdef int max(int a, int b):
# Code here
我们现在可以使用 IPython 的 %prun 来配置文件分析我们的脚本,如下所示:
import cheb
%prun cheb.benchmark()
# Output:
2000005 function calls in 2.066 seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 1.664 1.664 2.066 2.066 cheb.py:4(benchmark)
1000000 0.351 0.000 0.401 0.000 {distance.chebyshev}
1000000 0.050 0.000 0.050 0.000 mathlib.pyx:2(max)
2 0.000 0.000 0.000 0.000 {method 'rand' of 'mtrand.RandomState' objects}
1 0.000 0.000 2.066 2.066 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
从输出中,我们可以看到 max 函数存在并且不是瓶颈。大部分时间似乎花在了 benchmark 函数中,这意味着瓶颈很可能是纯 Python 的 for 循环。在这种情况下,最佳策略将是用 NumPy 重写循环或将代码移植到 Cython。
使用 Cython 与 Jupyter
优化 Cython 代码需要大量的试验和错误。幸运的是,Cython 工具可以通过 Jupyter 笔记本方便地访问,以获得更流畅和集成的体验。
您可以通过在命令行中键入 jupyter notebook 来启动笔记本会话,并且可以在一个单元中键入 %load_ext cython 来加载 Cython 魔法。
如前所述,可以使用 %%cython 魔法在当前会话中编译和加载 Cython 代码。例如,我们可以将 cheb.py 的内容复制到一个笔记本单元中:
%%cython
import numpy as np
cdef int max(int a, int b):
return a if a > b else b
cdef int chebyshev(int x1, int y1, int x2, int y2):
return max(abs(x1 - x2), abs(y1 - y2))
def c_benchmark():
a = np.random.rand(1000, 2)
b = np.random.rand(1000, 2)
for x1, y1 in a:
for x2, y2 in b:
chebyshev(x1, x2, y1, y2)
%%cython 魔法的有用功能是 -a 选项,它将编译代码并直接在笔记本中生成源代码的注释视图(就像命令行 -a 选项一样),如下面的截图所示:

这允许您快速测试代码的不同版本,并使用 Jupyter 中可用的其他集成工具。例如,我们可以使用 %prun 和 %timeit 等工具在同一个会话中计时和配置文件分析代码(前提是在单元中激活配置文件指令)。例如,我们可以通过利用 %prun 魔法来检查配置文件结果,如下面的截图所示:

还可以直接在笔记本中使用我们在第一章基准测试和配置文件分析中讨论的 line_profiler 工具。为了支持行注释,必须执行以下操作:
-
启用
linetrace=True和binding=True编译器指令 -
在编译时启用
CYTHON_TRACE=1标志
这可以通过向 %%cython 魔法添加相应的参数以及设置编译器指令轻松实现,如下面的代码所示:
%%cython -a -f -c=-DCYTHON_TRACE=1
# cython: linetrace=True
# cython: binding=True
import numpy as np
cdef int max(int a, int b):
return a if a > b else b
def chebyshev(int x1, int y1, int x2, int y2):
return max(abs(x1 - x2), abs(y1 - y2))
def c_benchmark():
a = np.random.rand(1000, 2)
b = np.random.rand(1000, 2)
for x1, y1 in a:
for x2, y2 in b:
chebyshev(x1, x2, y1, y2)
一旦代码被配置,我们可以使用 %lprun 魔法进行配置文件分析:
%lprun -f c_benchmark c_benchmark()
# Output:
Timer unit: 1e-06 s
Total time: 2.322 s
File: /home/gabriele/.cache/ipython/cython/_cython_magic_18ad8204e9d29650f3b09feb48ab0f44.pyx
Function: c_benchmark at line 11
Line # Hits Time Per Hit % Time Line Contents
==============================================================
11 def c_benchmark():
12 1 226 226.0 0.0 a = np.random.rand...
13 1 67 67.0 0.0 b = np.random.rand...
14
15 1001 1715 1.7 0.1 for x1, y1 in a:
16 1001000 1299792 1.3 56.0 for x2, y2 in b:
17 1000000 1020203 1.0 43.9 chebyshev...
如您所见,大部分时间实际上花在了第 16 行,这是一个纯 Python 循环,是进一步优化的良好候选者。
Jupyter 笔记本中可用的工具允许快速编辑-编译-测试周期,以便您可以快速原型设计并在测试不同解决方案时节省时间。
摘要
Cython 是一种工具,它将 Python 的便利性与 C 的速度相结合。与 C 绑定相比,Cython 程序由于与 Python 的紧密集成和兼容性,以及优秀工具的可用性,维护和调试要容易得多。
在本章中,我们介绍了 Cython 语言的基础知识,以及如何通过给变量和函数添加静态类型来使我们的程序运行更快。我们还学习了如何与 C 数组、NumPy 数组和内存视图一起工作。
我们通过重写关键的 evolve 函数优化了我们的粒子模拟器,获得了巨大的速度提升。最后,我们学会了如何使用注释视图来查找难以发现的与解释器相关的调用,以及如何在 Cython 中启用 cProfile 支持。此外,我们还学会了如何利用 Jupyter 笔记本来进行 Cython 代码的集成分析和性能分析。
在下一章中,我们将探讨其他可以在不预先将我们的代码编译为 C 的情况下即时生成快速机器代码的工具。
第五章:探索编译器
Python 是一种成熟且广泛使用的语言,人们对其性能的改进有着浓厚的兴趣,这包括直接将函数和方法编译成机器代码,而不是在解释器中执行指令。我们已经在第四章中看到了一个编译器的例子,即使用 Cython 进行 C 性能优化,其中 Python 代码通过添加类型被增强,编译成高效的 C 代码,并且解释器调用被绕过。
在本章中,我们将探讨两个项目——Numba 和 PyPy——它们以略微不同的方式处理编译。Numba 是一个设计用于即时编译小函数的库。Numba 不将 Python 代码转换为 C 代码,而是分析和直接编译 Python 函数到机器代码。PyPy 是一个替换解释器,它通过在运行时分析代码并自动优化慢速循环来工作。
这些工具被称为 即时(Just-In-Time,JIT)编译器,因为编译是在运行时而不是在运行代码之前(在其他情况下,编译器是在编译时或 AOT 调用)进行的。
本章要涵盖的主题列表如下:
-
开始使用 Numba
-
使用本地模式编译实现快速函数
-
理解和实现通用函数
-
JIT 类
-
设置 PyPy
-
使用 PyPy 运行粒子模拟器
-
其他有趣的编译器
Numba
Numba 是由 NumPy 的原始作者 Travis Oliphant 在 2012 年启动的,作为一个在运行时使用 低级虚拟机(LLVM)工具链编译单个 Python 函数的库。
LLVM 是一套用于编写编译器的工具。LLVM 是语言无关的,用于编写广泛语言的编译器(一个重要例子是 clang 编译器)。LLVM 的一个核心方面是中间表示(LLVM IR),这是一种非常低级的、平台无关的语言,类似于汇编语言,它可以编译为特定目标平台的机器代码。
Numba 通过检查 Python 函数并使用 LLVM 编译它们到 IR 来工作。正如我们在上一章中看到的,当我们为变量和函数引入类型时,可以获得速度提升。Numba 实现了聪明的算法来猜测类型(这被称为类型推断),并为快速执行编译了类型感知版本的函数。
注意,Numba 是为了提高数值代码的性能而开发的。开发工作通常优先考虑优化那些大量使用 NumPy 数组的应用程序。
Numba 发展非常快,在版本之间可能会有实质性的改进,有时甚至会有向后不兼容的更改。为了跟上进度,请确保您参考每个版本的发布说明。在本章的其余部分,我们将使用 Numba 版本 0.30.1;请确保您安装了正确的版本,以避免任何错误。
本章中的完整代码示例可以在 Numba.ipynb 笔记本中找到。
Numba 的第一步
开始使用 Numba 相对简单。作为一个示例,我们将实现一个计算数组平方和的函数。函数定义如下:
def sum_sq(a):
result = 0
N = len(a)
for i in range(N):
result += a[i]
return result
要使用 Numba 设置此函数,只需应用 nb.jit 装饰器即可。
from numba import nb
@nb.jit
def sum_sq(a):
...
当应用 nb.jit 装饰器时,它不会做太多。然而,当函数第一次被调用时,Numba 将检测输入参数 a 的类型,并编译原始函数的专用、高性能版本。
要衡量 Numba 编译器带来的性能提升,我们可以比较原始函数和专用函数的运行时间。原始的未装饰函数可以通过 py_func 属性轻松访问。两个函数的运行时间如下:
import numpy as np
x = np.random.rand(10000)
# Original
%timeit sum_sq.py_func(x)
100 loops, best of 3: 6.11 ms per loop
# Numba
%timeit sum_sq(x)
100000 loops, best of 3: 11.7 µs per loop
从前面的代码中,你可以看到 Numba 版本(11.7 µs)比 Python 版本(6.11 ms)快一个数量级。我们还可以将这种实现与 NumPy 标准运算符进行比较:
%timeit (x**2).sum()
10000 loops, best of 3: 14.8 µs per loop
在这种情况下,Numba 编译的函数略快于 NumPy 向量化操作。Numba 版本额外速度的原因可能是与我们的 sum_sq 函数执行的就地操作相比,NumPy 版本在执行求和之前分配了一个额外的数组。
由于我们在 sum_sq 中没有使用特定于数组的函数,我们也可以尝试在常规 Python 浮点数列表上应用相同的函数。有趣的是,与列表推导相比,Numba 在这种情况下也能获得显著的速度提升。
x_list = x.tolist()
%timeit sum_sq(x_list)
1000 loops, best of 3: 199 µs per loop
%timeit sum([x**2 for x in x_list])
1000 loops, best of 3: 1.28 ms per loop
考虑到我们只需要应用一个简单的装饰器就能在不同数据类型上获得令人难以置信的速度提升,Numba 所做的看起来就像是魔法。在接下来的章节中,我们将深入了解 Numba 的工作原理,并评估 Numba 编译器的优点和局限性。
类型专用化
如前所述,nb.jit 装饰器通过在遇到新的参数类型时编译函数的专用版本来工作。为了更好地理解其工作原理,我们可以检查 sum_sq 示例中的装饰函数。
Numba 通过 signatures 属性公开专用类型。在 sum_sq 定义之后,我们可以通过访问 sum_sq.signatures 来检查可用的专用化,如下所示:
sum_sq.signatures
# Output:
# []
如果我们用特定的参数调用这个函数,例如一个 float64 数字数组,我们可以看到 Numba 如何即时编译一个专用版本。如果我们也对 float32 数组应用这个函数,我们可以看到一个新的条目被添加到 sum_sq.signatures 列表中:
x = np.random.rand(1000).astype('float64')
sum_sq(x)
sum_sq.signatures
# Result:
# [(array(float64, 1d, C),)]
x = np.random.rand(1000).astype('float32')
sum_sq(x)
sum_sq.signatures
# Result:
# [(array(float64, 1d, C),), (array(float32, 1d, C),)]
可以通过传递签名到 nb.jit 函数来显式地为某些类型编译函数。
可以将单个签名作为包含我们希望接受的类型的元组传递。Numba 提供了 nb.types 模块中可以找到的多种类型,它们也存在于顶级 nb 命名空间中。如果我们想指定特定类型的数组,我们可以在类型本身上使用切片运算符 [:]。在以下示例中,我们演示了如何声明一个接受 float64 数组作为唯一参数的函数:
@nb.jit((nb.float64[:],))
def sum_sq(a):
注意,当我们显式声明一个签名时,我们将无法使用其他类型,以下示例进行了演示。如果我们尝试将数组 x 作为 float32 传递,Numba 将引发一个 TypeError:
sum_sq(x.astype('float32'))
# TypeError: No matching definition for argument type(s)
array(float32, 1d, C)
声明签名的另一种方式是通过类型字符串。例如,一个接受 float64 作为输入并返回 float64 作为输出的函数可以使用 float64(float64) 字符串声明。可以使用 [:] 后缀声明数组类型。将它们组合起来,我们可以按照以下方式为我们的 sum_sq 函数声明一个签名:
@nb.jit("float64(float64[:])")
def sum_sq(a):
您也可以通过传递一个列表来传递多个签名:
@nb.jit(["float64(float64[:])",
"float64(float32[:])"])
def sum_sq(a):
对象模式与原生模式
到目前为止,我们已经展示了 Numba 在处理相对简单的函数时的行为。在这种情况下,Numba 工作得非常好,我们在数组和列表上获得了出色的性能。
从 Numba 获得的优化程度取决于 Numba 能够多好地推断变量类型以及它能够多好地将这些标准 Python 操作转换为快速的类型特定版本。如果发生这种情况,解释器将被绕过,我们可以获得类似于 Cython 的性能提升。
当 Numba 无法推断变量类型时,它仍然会尝试编译代码,在类型无法确定或某些操作不受支持时回退到解释器。在 Numba 中,这被称为 对象模式,与称为 原生模式 的无解释器场景相对。
Numba 提供了一个名为 inspect_types 的函数,有助于了解类型推断的有效性以及哪些操作被优化。作为一个例子,我们可以查看 sum_sq 函数推断出的类型:
sum_sq.inspect_types()
当调用此函数时,Numba 将打印出为函数的每个专用版本推断出的类型。输出由包含与变量及其类型相关的信息的块组成。例如,我们可以检查 N = len(a) 这一行:
# --- LINE 4 ---
# a = arg(0, name=a) :: array(float64, 1d, A)
# $0.1 = global(len: <built-in function len>) ::
Function(<built-in function len>)
# $0.3 = call $0.1(a) :: (array(float64, 1d, A),) -> int64
# N = $0.3 :: int64
N = len(a)
对于每一行,Numba 都会打印出关于变量、函数和中间结果的详细描述。在前面的示例中,你可以看到(第二行),参数 a 被正确地识别为 float64 数字数组。在 LINE 4,len 函数的输入和返回类型也被正确地识别(并且可能已优化)为接受 float64 数字数组并返回 int64。
如果你滚动查看输出,你可以看到所有变量都有一个明确定义的类型。因此,我们可以确信 Numba 能够相当高效地编译代码。这种编译形式被称为 原生模式。
作为反例,我们可以看看如果我们编写一个包含不支持的操作的函数会发生什么。例如,截至版本 0.30.1,Numba 对字符串操作的支持有限。
我们可以实现一个连接一系列字符串的函数,并按以下方式编译它:
@nb.jit
def concatenate(strings):
result = ''
for s in strings:
result += s
return result
现在,我们可以用字符串列表调用这个函数并检查其类型:
concatenate(['hello', 'world'])
concatenate.signatures
# Output: [(reflected list(str),)]
concatenate.inspect_types()
Numba 将返回 reflected list (str) 类型的函数输出。例如,我们可以检查第 3 行是如何推断出来的。concatenate.inspect_types() 的输出如下所示:
# --- LINE 3 ---
# strings = arg(0, name=strings) :: pyobject
# $const0.1 = const(str, ) :: pyobject
# result = $const0.1 :: pyobject
# jump 6
# label 6
result = ''
你可以看到这次,每个变量或函数都是通用的 pyobject 类型,而不是特定的类型。这意味着在这种情况下,Numba 没有 Python 解释器的帮助无法编译这个操作。最重要的是,如果我们对原始函数和编译后的函数进行计时,我们会注意到编译后的函数大约比纯 Python 版本慢三倍:
x = ['hello'] * 1000
%timeit concatenate.py_func(x)
10000 loops, best of 3: 111 µs per loop
%timeit concatenate(x)
1000 loops, best of 3: 317 µs per loop
这是因为 Numba 编译器无法优化代码,并在函数调用中添加了一些额外的开销。
正如你可能已经注意到的,即使效率不高,Numba 也能无怨无悔地编译代码。主要原因在于 Numba 仍然能够以高效的方式编译代码的其他部分,而对于其他部分则回退到 Python 解释器。这种编译策略被称为 对象模式。
可以通过将 nopython=True 选项传递给 nb.jit 装饰器来强制使用原生模式。例如,如果我们将这个装饰器应用于我们的连接函数,我们会观察到 Numba 在第一次调用时抛出错误:
@nb.jit(nopython=True)
def concatenate(strings):
result = ''
for s in strings:
result += s
return result
concatenate(x)
# Exception:
# TypingError: Failed at nopython (nopython frontend)
这个特性对于调试和确保所有代码都快速且正确类型非常有用。
Numba 和 NumPy
Numba 最初是为了方便提高使用 NumPy 数组的代码的性能而开发的。目前,许多 NumPy 功能都由编译器高效实现。
Numba 中的通用函数
通用函数是在 NumPy 中定义的特殊函数,能够根据广播规则在不同的数组大小和形状上操作。Numba 的最佳特性之一是实现快速的 ufuncs。
我们已经在 第三章,使用 NumPy 和 Pandas 的快速数组操作 中看到了一些 ufunc 示例。例如,np.log 函数是一个 ufunc,因为它可以接受不同大小和形状的标量和数组。此外,接受多个参数的通用函数仍然遵循广播规则。接受多个参数的通用函数的例子有 np.sum 或 np.difference。
通用函数可以通过实现标量版本并使用np.vectorize函数来增强广播功能来在标准 NumPy 中定义。作为一个例子,我们将看到如何编写Cantor 配对函数。
配对函数是一个将两个自然数编码为单个自然数的函数,这样你可以轻松地在两种表示之间进行转换。Cantor 配对函数可以写成如下形式:
import numpy as np
def cantor(a, b):
return int(0.5 * (a + b)*(a + b + 1) + b)
如前所述,可以使用np.vectorized装饰器在纯 Python 中创建一个 ufunc:
@np.vectorize
def cantor(a, b):
return int(0.5 * (a + b)*(a + b + 1) + b)
cantor(np.array([1, 2]), 2)
# Result:
# array([ 8, 12])
除了方便之外,在纯 Python 中定义通用函数并不很有用,因为它需要大量的函数调用,这些调用受解释器开销的影响。因此,ufunc 的实现通常在 C 或 Cython 中完成,但 Numba 凭借其便利性击败了所有这些方法。
为了执行转换,所需做的只是使用等效的装饰器nb.vectorize。我们可以比较标准np.vectorized版本的速度,在以下代码中称为cantor_py,以及使用标准 NumPy 操作实现相同功能的函数:
# Pure Python
%timeit cantor_py(x1, x2)
100 loops, best of 3: 6.06 ms per loop
# Numba
%timeit cantor(x1, x2)
100000 loops, best of 3: 15 µs per loop
# NumPy
%timeit (0.5 * (x1 + x2)*(x1 + x2 + 1) + x2).astype(int)
10000 loops, best of 3: 57.1 µs per loop
你可以看到 Numba 版本如何以很大的优势击败所有其他选项!Numba 工作得非常好,因为函数简单,可以进行类型推断。
通用函数的另一个优点是,由于它们依赖于单个值,它们的评估也可以并行执行。Numba 通过将target="cpu"或target="gpu"关键字参数传递给nb.vectorize装饰器,提供了一个轻松并行化此类函数的方法。
广义通用函数
通用函数的一个主要限制是它们必须在标量值上定义。广义通用函数(简称gufunc)是通用函数的扩展,它将数组作为过程。
一个经典的例子是矩阵乘法。在 NumPy 中,可以使用np.matmul函数进行矩阵乘法,该函数接受两个二维数组并返回另一个二维数组。np.matmul的一个示例用法如下:
a = np.random.rand(3, 3)
b = np.random.rand(3, 3)
c = np.matmul(a, b)
c.shape
# Result:
# (3, 3)
如前所述,ufunc 会将操作广播到标量数组上,其自然推广将是广播到数组数组上。例如,如果我们取两个 3x3 矩阵的数组,我们期望np.matmul会匹配矩阵并取它们的乘积。在以下示例中,我们取包含 10 个形状为(3, 3)矩阵的数组。如果我们应用np.matmul,乘法将按矩阵方式应用,以获得包含 10 个结果的新数组(这些结果再次是(3, 3)矩阵):
a = np.random.rand(10, 3, 3)
b = np.random.rand(10, 3, 3)
c = np.matmul(a, b)
c.shape
# Output
# (10, 3, 3)
广播的常规规则将以类似的方式工作。例如,如果我们有一个 (3, 3) 矩阵的数组,它将具有 (10, 3, 3) 的形状,我们可以使用 np.matmul 来计算每个元素与单个 (3, 3) 矩阵的矩阵乘法。根据广播规则,我们得到单个矩阵将被重复以获得 (10, 3, 3) 的大小:
a = np.random.rand(10, 3, 3)
b = np.random.rand(3, 3) # Broadcasted to shape (10, 3, 3)
c = np.matmul(a, b)
c.shape
# Result:
# (10, 3, 3)
Numba 通过 nb.guvectorize 装饰器支持高效通用函数的实现。作为一个例子,我们将实现一个函数,该函数计算两个数组之间的欧几里得距离作为一个 gufunc。要创建一个 gufunc,我们必须定义一个函数,该函数接受输入数组,以及一个输出数组,我们将在这里存储计算结果。
nb.guvectorize 装饰器需要两个参数:
-
输入和输出的类型:两个一维数组作为输入,一个标量作为输出
-
所说的布局字符串,它是输入和输出大小的表示;在我们的情况下,我们取两个相同大小的数组(任意表示为
n),并输出一个标量
在下面的示例中,我们展示了使用 nb.guvectorize 装饰器实现 euclidean 函数的方法:
@nb.guvectorize(['float64[:], float64[:], float64[:]'], '(n), (n) -
> ()')
def euclidean(a, b, out):
N = a.shape[0]
out[0] = 0.0
for i in range(N):
out[0] += (a[i] - b[i])**2
有几个非常重要的要点需要说明。可以预见的是,我们将输入 a 和 b 的类型声明为 float64[:],因为它们是一维数组。然而,输出参数呢?它不是应该是一个标量吗?是的,然而,Numba 将标量参数视为大小为 1 的数组。这就是为什么它被声明为 float64[:]。
类似地,布局字符串表示我们有两个大小为 (n) 的数组,输出是一个标量,用空括号 () 表示。然而,输出数组将以大小为 1 的数组形式传递。
此外,请注意,我们从函数中不返回任何内容;所有输出都必须写入 out 数组。
布局字符串中的字母 n 完全任意;你可以选择使用 k 或其他你喜欢的字母。此外,如果你想组合不同大小的数组,你可以使用布局字符串,例如 (n, m)。
我们全新的 euclidean 函数可以方便地用于不同形状的数组,如下面的示例所示:
a = np.random.rand(2)
b = np.random.rand(2)
c = euclidean(a, b) # Shape: (1,)
a = np.random.rand(10, 2)
b = np.random.rand(10, 2)
c = euclidean(a, b) # Shape: (10,)
a = np.random.rand(10, 2)
b = np.random.rand(2)
c = euclidean(a, b) # Shape: (10,)
euclidean 的速度与标准 NumPy 相比如何?在下面的代码中,我们使用先前定义的 euclidean 函数与 NumPy 向量化版本进行基准测试:
a = np.random.rand(10000, 2)
b = np.random.rand(10000, 2)
%timeit ((a - b)**2).sum(axis=1)
1000 loops, best of 3: 288 µs per loop
%timeit euclidean(a, b)
10000 loops, best of 3: 35.6 µs per loop
再次,Numba 版本在性能上大幅领先于 NumPy 版本!
JIT 类
截至目前,Numba 不支持通用 Python 对象的优化。然而,这个限制对数值代码的影响并不大,因为它们通常只涉及数组和数学运算。
尽管如此,某些数据结构使用对象实现起来更为自然;因此,Numba 提供了对定义可以用于并编译为快速原生代码的类的支持。
请记住,这是最新(几乎是实验性的)功能之一,它非常有用,因为它允许我们将 Numba 扩展以支持那些难以用数组实现的快速数据结构。
作为示例,我们将展示如何使用 JIT 类实现一个简单的链表。链表可以通过定义一个包含两个字段的Node类来实现:一个值和列表中的下一个项目。如图所示,每个Node连接到下一个节点并持有值,最后一个节点包含一个断开链接,我们将其赋值为None:

在 Python 中,我们可以将Node类定义为如下:
class Node:
def __init__(self, value):
self.next = None
self.value = value
我们可以通过创建另一个名为LinkedList的类来管理Node实例的集合。这个类将跟踪列表的头部(在先前的图中,这对应于值为3的Node)。为了在列表前面插入一个元素,我们可以简单地创建一个新的Node并将其链接到当前头部。
在以下代码中,我们开发了LinkedList的初始化函数和LinkedList.push_back方法,该方法使用前面概述的策略在列表前面插入一个元素:
class LinkedList:
def __init__(self):
self.head = None
def push_front(self, value):
if self.head == None:
self.head = Node(value)
else:
# We replace the head
new_head = Node(value)
new_head.next = self.head
self.head = new_head
为了调试目的,我们还可以实现一个LinkedList.show方法,该方法遍历并打印列表中的每个元素。该方法在下面的代码片段中展示:
def show(self):
node = self.head
while node is not None:
print(node.value)
node = node.next
在这一点上,我们可以测试我们的LinkedList并查看它是否表现正确。我们可以创建一个空列表,添加一些元素,并打印其内容。请注意,由于我们是在列表前面推入元素,最后插入的元素将是首先打印的:
lst = LinkedList()
lst.push_front(1)
lst.push_front(2)
lst.push_front(3)
lst.show()
# Output:
# 3
# 2
# 1
最后,我们可以实现一个函数,sum_list,它返回链表中元素的总和。我们将使用此方法来测量 Numba 和纯 Python 版本之间的差异:
@nb.jit
def sum_list(lst):
result = 0
node = lst.head
while node is not None:
result += node.value
node = node.next
return result
如果我们测量原始sum_list版本和nb.jit版本的执行时间,我们会看到没有太大的差异。原因是 Numba 无法推断类的类型:
lst = LinkedList()
[lst.push_front(i) for i in range(10000)]
%timeit sum_list.py_func(lst)
1000 loops, best of 3: 2.36 ms per loop
%timeit sum_list(lst)
100 loops, best of 3: 1.75 ms per loop
我们可以通过使用nb.jitclass装饰器编译Node和LinkedList类来提高sum_list的性能。
nb.jitclass装饰器接受一个包含属性类型的单个参数。在Node类中,属性类型为value的int64和next的Node。nb.jitclass装饰器还将编译为该类定义的所有方法。在深入代码之前,需要提出两个观察点。
首先,属性声明必须在定义类之前完成,但我们如何声明尚未定义的类型?Numba 提供了nb.deferred_type()函数,可用于此目的。
第二,next属性可以是None或Node实例。这被称为可选类型,Numba 提供了一个名为nb.optional的实用工具,允许你声明可以(可选地)为None的变量。
以下代码示例展示了这个Node类。正如你所见,node_type是使用nb.deferred_type()预先声明的。属性被声明为一个包含属性名和类型的对列表(也请注意nb.optional的使用)。在类声明之后,我们需要声明延迟类型:
node_type = nb.deferred_type()
node_spec = [
('next', nb.optional(node_type)),
('value', nb.int64)
]
@nb.jitclass(node_spec)
class Node:
# Body of Node is unchanged
node_type.define(Node.class_type.instance_type)
LinkedList类可以很容易地编译,如下所示。所需做的只是定义head属性并应用nb.jitclass装饰器:
ll_spec = [
('head', nb.optional(Node.class_type.instance_type))
]
@nb.jitclass(ll_spec)
class LinkedList:
# Body of LinkedList is unchanged
现在我们可以测量当我们传递一个 JIT LinkedList给sum_list函数时的执行时间:
lst = LinkedList()
[lst.push_front(i) for i in range(10000)]
%timeit sum_list(lst)
1000 loops, best of 3: 345 µs per loop
%timeit sum_list.py_func(lst)
100 loops, best of 3: 3.36 ms per loop
有趣的是,当使用编译函数中的 JIT 类时,我们获得了与纯 Python 版本相比的显著性能提升。然而,从原始的sum_list.py_func中使用 JIT 类实际上会导致性能下降。确保你只在编译函数内部使用 JIT 类!
Numba 的限制
有一些情况下,Numba 无法正确推断变量类型,并将拒绝编译。在以下示例中,我们定义了一个函数,它接受一个整数嵌套列表并返回每个子列表中元素的求和。在这种情况下,Numba 将引发ValueError并拒绝编译:
a = [[0, 1, 2],
[3, 4],
[5, 6, 7, 8]]
@nb.jit
def sum_sublists(a):
result = []
for sublist in a:
result.append(sum(sublist))
return result
sum_sublists(a)
# ValueError: cannot compute fingerprint of empty list
这个代码的问题在于 Numba 无法确定列表的类型,从而导致失败。解决这个问题的一个方法是通过用一个样本元素初始化列表并在结束时移除它来帮助编译器确定正确的类型:
@nb.jit
def sum_sublists(a):
result = [0]
for sublist in a:
result.append(sum(sublist))
return result[1:]
在 Numba 编译器中尚未实现的其他特性包括函数和类定义、列表、集合和字典推导、生成器、with语句以及try except块。然而,值得注意的是,许多这些特性可能在将来得到支持。
PyPy 项目
PyPy 是一个旨在提高 Python 解释器性能的非常雄心勃勃的项目。PyPy 提高性能的方式是在运行时自动编译代码中的慢速部分。
PyPy 是用一种称为 RPython 的特殊语言编写的(而不是 C 语言),这使得开发者能够快速且可靠地实现高级功能和改进。RPython 意味着受限 Python,因为它实现了一个针对编译器开发的 Python 语言的受限子集。
到目前为止,PyPy 版本 5.6 支持许多 Python 特性,并且是各种应用的一个可能选择。
PyPy 使用一种非常巧妙的策略来编译代码,称为跟踪 JIT 编译。最初,代码是使用解释器调用正常执行的。然后 PyPy 开始分析代码并识别最密集的循环。在识别完成后,编译器随后观察(跟踪)操作,并能够编译其优化、无解释器的版本。
一旦有了代码的优化版本,PyPy 就能比解释版本更快地运行慢速循环。
这种策略可以与 Numba 的做法进行对比。在 Numba 中,编译的单位是方法和函数,而 PyPy 的重点是慢速循环。总体而言,项目的重点也非常不同,因为 Numba 对数值代码的范围有限,并且需要大量的仪器,而 PyPy 的目标是替换 CPython 解释器。
在本节中,我们将演示并基准测试 PyPy 在我们的粒子模拟器应用程序上的使用。
设置 PyPy
PyPy 以预编译的二进制形式分发,可以从 pypy.org/download.html 下载,并且目前支持 Python 版本 2.7(PyPy 5.6 中的 beta 支持)和 3.3(PyPy 5.5 中的 alpha 支持)。在本章中,我们将演示 2.7 版本的使用。
一旦下载并解压 PyPy,你可以在解压存档的 bin/pypy 目录中找到解释器。你可以使用以下命令初始化一个新的虚拟环境,在该环境中我们可以使用以下命令安装额外的包:
$ /path/to/bin/pypy -m ensurepip
$ /path/to/bin/pypy -m pip install virtualenv
$ /path/to/bin/virtualenv my-pypy-env
要激活环境,我们将使用以下命令:
$ source my-pypy-env/bin/activate
到目前为止,你可以通过输入 python -V 来验证二进制 Python 是否链接到 PyPy 可执行文件。到这一点,我们可以继续安装我们可能需要的包。截至版本 5.6,PyPy 对使用 Python C API 的软件支持有限(最著名的是 numpy 和 matplotlib 等包)。我们可以按照常规方式继续安装它们:
(my-pypy-env) $ pip install numpy matplotlib
在某些平台上,安装 numpy 和 matplotlib 可能很棘手。你可以跳过安装步骤,并从我们将运行的脚本中移除这两个包的任何导入。
在 PyPy 中运行粒子模拟器
现在我们已经成功设置了 PyPy 安装,我们可以继续运行我们的粒子模拟器。作为第一步,我们将对来自 第一章,基准测试和性能分析 的粒子模拟器在标准 Python 解释器上进行计时。如果虚拟环境仍然处于活动状态,你可以输入命令 deactivate 退出环境。我们可以通过使用 python -V 命令来确认 Python 解释器是标准的:
(my-pypy-env) $ deactivate
$ python -V
Python 3.5.2 :: Continuum Analytics, Inc.
到目前为止,我们可以使用 timeit 命令行界面来计时我们的代码:
$ python -m timeit --setup "from simul import benchmark" "benchmark()"
10 loops, best of 3: 886 msec per loop
我们可以重新激活环境并运行与 PyPy 完全相同的代码。在 Ubuntu 上,你可能会在导入 matplotlib.pyplot 模块时遇到问题。你可以尝试执行以下 export 命令来修复问题,或者从 simul.py 中移除 matplotlib 的导入:
$ export MPLBACKEND='agg'
现在,我们可以使用 PyPy 来计时代码:
$ source my-pypy-env/bin/activate
Python 2.7.12 (aff251e54385, Nov 09 2016, 18:02:49)
[PyPy 5.6.0 with GCC 4.8.2]
(my-pypy-env) $ python -m timeit --setup "from simul import benchmark" "benchmark()"
WARNING: timeit is a very unreliable tool. use perf or something else for real measurements
10 loops, average of 7: 106 +- 0.383 msec per loop (using standard deviation)
注意,我们获得了很大的加速,超过八倍!然而,PyPy 警告我们 timeit 模块可能不可靠。我们可以使用 PyPy 建议的 perf 模块来确认我们的计时:
(my-pypy-env) $ pip install perf
(my-pypy-env) $ python -m perf timeit --setup 'from simul import benchmark' 'benchmark()'
.......
Median +- std dev: 97.8 ms +- 2.3 ms
其他有趣的项目
多年来,许多项目试图通过多种策略提高 Python 的性能,但遗憾的是,许多项目失败了。截至目前,有一些项目幸存下来,并承诺将带来更快的 Python。
Numba 和 PyPy 是成熟的项目,多年来一直在稳步改进。功能持续被添加,并且它们对未来 Python 的前景有着巨大的承诺。
Nuitka 是由 Kay Hayen 开发的一个程序,它将 Python 代码编译成 C 语言。截至目前(版本 0.5.x),它提供了与 Python 语言的极致兼容性,并生成高效的代码,与 CPython 相比,实现了适度的性能提升。
Nuitka 与 Cython 在意义上相当不同,因为它专注于与 Python 语言的极致兼容性,并且不通过额外的结构扩展语言。
Pyston 是 Dropbox 开发的一个新的解释器,它为 JIT 编译器提供动力。它与 PyPy 有很大不同,因为它不使用追踪 JIT,而是使用逐方法 JIT(类似于 Numba 所做的)。Pyston,像 Numba 一样,也是建立在 LLVM 编译器基础设施之上的。
Pyston 目前仍处于早期开发阶段(alpha 版本),仅支持 Python 2.7。基准测试显示,它的速度比 CPython 快,但比 PyPy 慢;尽管如此,随着新功能的添加和兼容性的提高,它仍然是一个值得关注的有趣项目。
摘要
Numba 是一个工具,它在运行时编译 Python 函数的快速、专用版本。在本章中,我们学习了如何编译、检查和分析由 Numba 编译的函数。我们还学习了如何实现快速 NumPy 通用函数,这些函数在广泛的数值应用中非常有用。最后,我们使用 nb.jitclass 装饰器实现了更复杂的数据结构。
工具如 PyPy 允许我们在不改变 Python 程序的情况下运行,以获得显著的速度提升。我们展示了如何设置 PyPy,并评估了我们在粒子模拟应用程序上的性能提升。
我们也简要地描述了当前 Python 编译器的生态系统,并相互比较了它们。
在下一章中,我们将学习并发和异步编程。使用这些技术,我们将能够提高那些花费大量时间等待网络和磁盘资源的应用程序的响应性和设计。
第六章:实现并发
到目前为止,我们已经探讨了如何通过使用巧妙的算法和更高效的机器代码来减少 CPU 执行的操作数量,从而测量和改进程序的性能。在本章中,我们将把重点转向那些大部分时间都花在等待比 CPU 慢得多的资源(如持久存储和网络资源)的程序。
异步编程是一种编程范式,有助于处理慢速和不可预测的资源(如用户),并且广泛用于构建响应式服务和用户界面。在本章中,我们将向您展示如何使用协程和响应式编程等技术以异步方式在 Python 中进行编程。
在本章中,我们将涵盖以下主题:
-
内存层次结构
-
回调
-
未来
-
事件循环
-
使用
asyncio编写协程 -
将同步代码转换为异步代码
-
使用 RxPy 进行响应式编程
-
与可观察对象一起工作
-
使用 RxPY 构建内存监控器
异步编程
异步编程是一种处理慢速和不可预测资源(如用户)的方法。异步程序能够同时高效地处理多个资源,而不是空闲等待资源可用。以异步方式编程可能具有挑战性,因为必须处理可能以任何顺序到达、可能花费可变的时间或可能不可预测地失败的外部请求。在本节中,我们将通过解释主要概念和术语以及异步程序的工作方式来介绍这个主题。
等待 I/O
现代计算机使用不同类型的内存来存储数据和执行操作。一般来说,计算机拥有能够以快速速度运行的昂贵内存和更便宜、更丰富的内存,后者以较慢的速度运行,用于存储大量数据。
以下图表显示了内存层次结构:

在内存层次结构的顶部是 CPU 寄存器。这些是集成在 CPU 中的,用于存储和执行机器指令。在寄存器中访问数据通常需要一个时钟周期。这意味着如果 CPU 以 3 GHz 的速度运行,那么访问 CPU 寄存器中的一个元素所需的时间大约是 0.3 纳秒。
在寄存器层之下,你可以找到 CPU 缓存,它由多个级别组成,并集成在处理器中。缓存的运行速度略慢于寄存器,但处于同一数量级。
层次结构中的下一项是主存储器(RAM),它能够存储更多的数据,但比缓存慢。从内存中检索一个项目可能需要几百个时钟周期。
在底层,你可以找到持久存储,例如旋转磁盘(HDD)和固态硬盘(SSD)。这些设备存储的数据最多,比主存储慢几个数量级。一个 HDD 可能需要几毫秒来定位和检索一个项目,而 SSD 则快得多,只需几分之一毫秒。
为了将每种存储类型的相对速度置于一个可比较的视角,假设 CPU 的时钟速度大约为一秒,那么对寄存器的访问就相当于从桌子上拿起一支笔。对缓存的访问则相当于从书架上取下一本书。在层次结构中继续向上,对 RAM 的访问将相当于洗衣服(比缓存慢大约二十倍)。当我们转向持久存储时,情况就大不相同了。从 SSD 中检索一个元素将相当于进行四天的旅行,而从 HDD 中检索一个元素可能需要长达六个月!如果我们转向通过网络访问资源,时间可能会进一步延长。
从前面的例子中,应该很明显,从存储和其他 I/O 设备访问数据比 CPU 慢得多;因此,非常重要的是要妥善处理这些资源,以确保 CPU 不会无目的地等待。这可以通过精心设计软件来实现,该软件能够同时管理多个正在进行中的请求。
并发
并发是一种实现能够同时处理多个请求的系统的方法。其思想是,我们可以在等待资源可用时继续处理其他资源。并发通过将任务分割成可以无序执行的小子任务来实现,这样多个任务就可以在不等待前一个任务完成的情况下部分地向前推进。
作为第一个例子,我们将描述如何实现对慢速网络资源的并发访问。假设我们有一个将数字平方的 Web 服务,我们的请求和响应之间的时间大约为一秒。我们可以实现network_request函数,该函数接受一个数字并返回一个包含操作成功信息和结果的字典。我们可以使用time.sleep函数来模拟这样的服务,如下所示:
import time
def network_request(number):
time.sleep(1.0)
return {"success": True, "result": number ** 2}
我们还将编写一些额外的代码来执行请求,验证请求是否成功,并打印结果。在下面的代码中,我们定义了fetch_square函数,并使用它通过调用network_request来计算数字二的平方:
def fetch_square(number):
response = network_request(number)
if response["success"]:
print("Result is: {}".format(response["result"]))
fetch_square(2)
# Output:
# Result is: 4
由于网络速度慢,从网络上获取一个数字将需要一秒钟。如果我们想计算多个数字的平方呢?我们可以调用fetch_square函数,该函数将在前一个请求完成后立即启动网络请求:
fetch_square(2)
fetch_square(3)
fetch_square(4)
# Output:
# Result is: 4
# Result is: 9
# Result is: 16
之前的代码将需要三秒钟来运行,但这并不是我们能做的最好的。等待前一个结果完成是不必要的,因为我们实际上可以提交多个请求并并行等待它们。
在下面的图中,三个任务被表示为方框。CPU 处理和提交请求所花费的时间用橙色表示,而等待时间用蓝色表示。您可以看到,大部分时间都花在等待资源上,而我们的机器闲置,什么也不做:

理想情况下,我们希望在等待已提交的任务完成的同时开始其他新的任务。在下面的图中,您可以看到,当我们提交请求到fetch_square(2)后,我们可以立即开始准备fetch_square(3)等等。这使我们能够减少 CPU 的等待时间,并在结果可用时立即开始处理:

这种策略之所以可行,是因为这三个请求是完全独立的,我们不需要等待前一个任务的完成就可以开始下一个任务。此外,请注意,单个 CPU 可以轻松地处理这种场景。虽然将工作分配到多个 CPU 可以进一步提高执行速度,但如果等待时间与处理时间相比很大,那么速度提升将是微不足道的。
要实现并发,需要不同的思考和编码方式;在接下来的章节中,我们将展示实现健壮并发应用程序的技术和最佳实践。
回调
我们迄今为止看到的代码会在资源可用之前阻塞程序的执行。负责等待的调用是time.sleep。为了使代码开始处理其他任务,我们需要找到一种方法来避免阻塞程序流程,以便程序的其余部分可以继续处理其他任务。
实现这种行为的最简单方法是通过回调。这种策略与我们请求出租车时所做的非常相似。
想象一下,你正在一家餐厅,已经喝了几杯酒。外面在下雨,你也不想坐公交车;因此,你要求叫一辆出租车,并让他们在你外面时打电话给你,这样你就可以出来,不必在雨中等待。
在这种情况下,你所做的是请求一辆出租车(即慢速资源),但你不是在外面等待出租车到达,而是提供你的电话号码和指示(回调),这样他们准备好时你可以出来,然后回家。
现在,我们将展示这种机制如何在代码中工作。我们将比较time.sleep的阻塞代码与等效的非阻塞代码threading.Timer。
对于这个例子,我们将编写一个函数wait_and_print,该函数将阻塞程序执行一秒钟,然后打印一条消息:
def wait_and_print(msg):
time.sleep(1.0)
print(msg)
如果我们想以非阻塞的方式编写相同的函数,我们可以使用threading.Timer类。我们可以通过传递我们想要等待的时间量和回调函数来初始化一个threading.Timer实例。回调是一个当计时器到期时将被调用的函数。请注意,我们还需要调用Timer.start方法来激活计时器:
import threading
def wait_and_print_async(msg):
def callback():
print(msg)
timer = threading.Timer(1.0, callback)
timer.start()
wait_and_print_async函数的一个重要特性是,没有任何语句会阻塞程序的执行流程。
threading.Timer是如何在不阻塞的情况下等待的呢?
threading.Timer使用的策略是启动一个新的线程,该线程能够并行执行代码。如果这让你感到困惑,不要担心,我们将在接下来的章节中详细探讨线程和并行编程。
这种在响应某些事件时注册回调以执行的技术通常被称为好莱坞原则。这是因为,在好莱坞试镜一个角色后,你可能会被告知"不要给我们打电话,我们会给你打电话
",这意味着他们不会立即告诉你是否选择了你,但如果他们选择了你,他们会给你打电话。
为了突出wait_and_print的阻塞和非阻塞版本之间的差异,我们可以测试和比较两个版本的执行。在输出注释中,等待时间由<wait...>表示:
# Syncronous
wait_and_print("First call")
wait_and_print("Second call")
print("After call")
# Output:
# <wait...>
# First call
# <wait...>
# Second call
# After call
# Async
wait_and_print_async("First call async")
wait_and_print_async("Second call async")
print("After submission")
# Output:
# After submission
# <wait...>
# First call
# Second call
同步版本的行为非常熟悉。代码等待一秒钟,打印First call,然后等待另一秒钟,接着打印Second call和After call消息。
在异步版本中,wait_and_print_async提交(而不是执行)那些调用,并立即继续。你可以通过确认“提交后”消息立即打印出来看到这个机制的作用。
在这个前提下,我们可以通过使用回调重写我们的network_request函数来探索一个稍微复杂的情况。在下面的代码中,我们定义了network_request_async函数。network_request_async与其阻塞版本之间最大的区别是network_request_async不返回任何内容。这是因为我们在调用network_request_async时仅提交请求,但值只有在请求完成时才可用。
如果我们无法返回任何内容,我们如何传递请求的结果?与其返回值,我们不如将结果作为参数传递给on_done回调函数。
函数的其余部分包括向timer.Timer类提交一个回调(称为timer_done),当它准备好时将调用on_done:
def network_request_async(number, on_done):
def timer_done():
on_done({"success": True,
"result": number ** 2})
timer = threading.Timer(1.0, timer_done)
timer.start()
network_request_async的使用与timer.Timer非常相似;我们只需传递我们想要平方的数字和一个将在结果准备好时接收结果的回调函数。这将在下面的代码片段中演示:
def on_done(result):
print(result)
network_request_async(2, on_done)
现在,如果我们提交多个网络请求,我们会注意到调用是并发执行的,并不会阻塞代码:
network_request_async(2, on_done)
network_request_async(3, on_done)
network_request_async(4, on_done)
print("After submission")
为了在fetch_square中使用network_request_async,我们需要修改代码以使用异步构造。在下面的代码中,我们通过定义和传递on_done回调到network_request_async来修改fetch_square:
def fetch_square(number):
def on_done(response):
if response["success"]:
print("Result is: {}".format(response["result"]))
network_request_async(number, on_done)
你可能已经注意到,异步代码比它的同步版本要复杂得多。这是因为每次我们需要检索某个结果时,都必须编写和传递一个回调,导致代码变得嵌套且难以跟踪。
期货
期货是一种更方便的模式,可以用来跟踪异步调用的结果。在前面的代码中,我们看到我们不是返回值,而是接受回调,并在结果准备好时传递结果。值得注意的是,到目前为止,还没有简单的方法来跟踪资源的状态。
期货是一个抽象,帮助我们跟踪请求的资源,并等待它们变得可用。在 Python 中,你可以在concurrent.futures.Future类中找到期货的实现。可以通过不带参数调用其构造函数来创建Future实例:
fut = Future()
# Result:
# <Future at 0x7f03e41599e8 state=pending>
期货代表一个尚未可用的值。你可以看到它的字符串表示报告了结果当前的状态,在我们的例子中,仍然是挂起的。为了使结果可用,我们可以使用Future.set_result方法:
fut.set_result("Hello")
# Result:
# <Future at 0x7f03e41599e8 state=finished returned str>
fut.result()
# Result:
# "Hello"
你可以看到,一旦我们设置了结果,Future将报告任务已完成,并且可以使用Future.result方法访问。还可能订阅一个回调到期货,以便一旦结果可用,回调就会被执行。要附加回调,只需将一个函数传递给Future.add_done_callback方法。当任务完成时,该函数将以Future实例作为其第一个参数被调用,并且可以使用Future.result()方法检索结果:
fut = Future()
fut.add_done_callback(lambda future: print(future.result(), flush=True))
fut.set_result("Hello")
# Output:
# Hello
为了了解期货在实际中的应用,我们将network_request_async函数修改为使用期货。想法是,这次,我们不是返回空值,而是返回一个Future,它会为我们跟踪结果。注意两点:
-
我们不需要接受
on_done callback,因为回调可以在之后使用Future.add_done_callback方法连接。此外,我们将通用的Future.set_result方法作为threading.Timer的回调。 -
这次我们能够返回一个值,从而使代码与我们在上一节中看到的阻塞版本更加相似:
from concurrent.futures import Future
def network_request_async(number):
future = Future()
result = {"success": True, "result": number ** 2}
timer = threading.Timer(1.0, lambda: future.set_result(result))
timer.start()
return future
fut = network_request_async(2)
尽管在这些例子中我们直接实例化和管理期货;在实际应用中,期货是由框架处理的。
如果你执行前面的代码,将不会发生任何事情,因为代码只包含准备和返回一个Future实例。为了启用对未来的进一步操作,我们需要使用Future.add_done_callback方法。在下面的代码中,我们将fetch_square函数调整为使用 futures:
def fetch_square(number):
fut = network_request_async(number)
def on_done_future(future):
response = future.result()
if response["success"]:
print("Result is: {}".format(response["result"]))
fut.add_done_callback(on_done_future)
代码看起来仍然与回调版本非常相似。Futures 是处理回调的不同且稍微方便一些的方式。Futures 还有优势,因为它们可以跟踪资源状态,取消(取消调度)已安排的任务,并且更自然地处理异常。
事件循环
到目前为止,我们已经使用操作系统线程实现了并行性。然而,在许多异步框架中,并发任务的协调是由事件循环管理的。
事件循环背后的想法是持续监控各种资源的状态(例如,网络连接和数据库查询),并在事件发生时(例如,当资源就绪或计时器过期时)触发回调的执行。
为什么不坚持使用线程呢?
有时人们更喜欢事件循环,因为每个执行单元永远不会同时运行,这可以简化处理共享变量、数据结构和资源。阅读下一章了解更多关于并行执行及其缺点的详细信息。
作为第一个例子,我们将实现一个无线程版本的threading.Timer。我们可以定义一个Timer类,它将接受一个超时时间,并实现一个Timer.done方法,如果计时器已过期则返回True:
class Timer:
def __init__(self, timeout):
self.timeout = timeout
self.start = time.time()
def done(self):
return time.time() - self.start > self.timeout
要确定计时器是否已过期,我们可以编写一个循环,通过调用Timer.done方法连续检查计时器状态。当计时器过期时,我们可以打印一条消息并退出循环:
timer = Timer(1.0)
while True:
if timer.done():
print("Timer is done!")
break
通过这种方式实现计时器,执行流程永远不会被阻塞,原则上我们可以在 while 循环内部做其他工作。
通过循环不断轮询等待事件发生,通常被称为忙等待。
理想情况下,我们希望附加一个自定义函数,当计时器响起时执行,就像我们在threading.Timer中做的那样。为此,我们可以实现一个方法,Timer.on_timer_done,它将接受一个回调,当计时器响起时执行:
class Timer:
# ... previous code
def on_timer_done(self, callback):
self.callback = callback
注意,on_timer_done仅仅存储了回调的引用。监控事件并执行回调的是循环。这一概念如下所示。而不是使用打印函数,循环将在适当的时候调用timer.callback:
timer = Timer(1.0)
timer.on_timer_done(lambda: print("Timer is done!"))
while True:
if timer.done():
timer.callback()
break
如你所见,异步框架正在开始占据一席之地。我们在循环之外所做的所有事情只是定义计时器和回调,而循环则负责监控计时器并执行相关的回调。我们可以通过实现对多个计时器的支持来进一步扩展我们的代码。
实现多个计时器的一种自然方式是将几个Timer实例添加到一个列表中,并修改我们的事件循环,使其定期检查所有计时器,并在需要时调度回调。在下面的代码中,我们定义了两个计时器,并将它们各自附加了一个回调。这些计时器被添加到列表timers中,该列表由我们的事件循环持续监控。一旦计时器完成,我们就执行回调并将事件从列表中删除:
timers = []
timer1 = Timer(1.0)
timer1.on_timer_done(lambda: print("First timer is done!"))
timer2 = Timer(2.0)
timer2.on_timer_done(lambda: print("Second timer is done!"))
timers.append(timer1)
timers.append(timer2)
while True:
for timer in timers:
if timer.done():
timer.callback()
timers.remove(timer)
# If no more timers are left, we exit the loop
if len(timers) == 0:
break
事件循环的主要限制是,由于执行流程是由一个持续运行的循环管理的,因此它永远不会使用阻塞调用。如果我们在这个循环中使用任何阻塞语句(例如time.sleep),你可以想象事件监控和回调调度将停止,直到阻塞调用完成。
为了避免这种情况,我们不是使用阻塞调用,如time.sleep,而是让事件循环在资源准备好时检测并执行回调。通过不阻塞执行流程,事件循环可以自由地以并发方式监控多个资源。
事件的通知通常是通过操作系统调用(例如select Unix 工具)来实现的,这些调用会在事件准备好时(与忙等待相反)恢复程序的执行。
Python 标准库包括一个非常方便的事件循环基于的并发框架,asyncio,这将是下一节的主题。
asyncio 框架
到现在为止,你应该已经对并发的工作原理以及如何使用回调和未来有了坚实的基础。现在我们可以继续学习如何使用从版本 3.4 开始的标准库中存在的asyncio包。我们还将探索全新的async/await语法,以非常自然的方式处理异步编程。
作为第一个示例,我们将看到如何使用asyncio检索和执行一个简单的回调。可以通过调用asyncio.get_event_loop()函数来检索asyncio循环。我们可以使用loop.call_later来调度一个回调执行,该函数接受秒数延迟和回调。我们还可以使用loop.stop方法来停止循环并退出程序。要开始处理计划中的调用,必须启动循环,这可以通过loop.run_forever来完成。以下示例通过调度一个将打印消息并停止循环的回调来演示这些基本方法的用法:
import asyncio
loop = asyncio.get_event_loop()
def callback():
print("Hello, asyncio")
loop.stop()
loop.call_later(1.0, callback)
loop.run_forever()
协程
回调函数的一个主要问题在于,它们要求你将程序执行分解成小的函数,这些函数将在某个事件发生时被调用。正如我们在前面的章节中看到的,回调函数很快就会变得繁琐。
协程是另一种,也许更自然的方法,将程序执行分解成块。它们允许程序员编写类似于同步代码的代码,但将以异步方式执行。你可以把协程想象成一个可以被停止和恢复的函数。协程的基本例子是生成器。
生成器可以在 Python 中使用函数内的 yield 语句定义。在下面的例子中,我们实现了 range_generator 函数,它从 0 到 n 产生并返回值。我们还添加了一个打印语句来记录生成器的内部状态:
def range_generator(n):
i = 0
while i < n:
print("Generating value {}".format(i))
yield i
i += 1
当我们调用 range_generator 函数时,代码不会立即执行。注意,当执行以下代码片段时,没有任何内容打印到输出。相反,返回了一个 生成器对象:
generator = range_generator(3)
generator
# Result:
# <generator object range_generator at 0x7f03e418ba40>
为了开始从生成器中提取值,必须使用 next 函数:
next(generator)
# Output:
# Generating value 0
next(generator)
# Output:
# Generating value 1
注意,每次我们调用 next 时,代码会运行直到遇到下一个 yield 语句,并且必须发出另一个 next 语句来恢复生成器的执行。你可以把 yield 语句看作是一个断点,在那里我们可以停止和恢复执行(同时保持生成器的内部状态)。这种停止和恢复执行的能力可以被事件循环利用,以实现并发。
也可以通过 yield 语句在生成器中 注入(而不是 提取)值。在下面的例子中,我们声明了一个名为 parrot 的函数,它将重复发送的每条消息。为了允许生成器接收一个值,你可以将 yield 赋值给一个变量(在我们的例子中,它是 message = yield)。要在生成器中插入值,我们可以使用 send 方法。在 Python 世界中,既能接收值又能产生值的生成器被称为 基于生成器的协程:
def parrot():
while True:
message = yield
print("Parrot says: {}".format(message))
generator = parrot()
generator.send(None)
generator.send("Hello")
generator.send("World")
注意,在我们开始发送消息之前,我们还需要发出 generator.send(None);这是为了启动函数执行并带我们到第一个 yield 语句。另外,注意 parrot 中有一个无限循环;如果我们不使用生成器来实现这个循环,我们将陷入无限循环中!
在这种情况下,你可以想象事件循环如何在不阻塞整个程序执行的情况下部分地推进这些生成器。你也可以想象,只有在某些资源准备好时,生成器才能被推进,从而消除了回调的需要。
使用 asyncio 可以通过 yield 语句实现协程。然而,自 Python 3.5 版本以来,Python 支持使用更直观的语法定义强大的协程。
使用 asyncio 定义协程,你可以使用 async def 语句:
async def hello():
print("Hello, async!")
coro = hello()
coro
# Output:
# <coroutine object hello at 0x7f314846bd58>
如你所见,如果我们调用 hello 函数,函数体不会立即执行,而是返回一个 协程对象。asyncio 协程不支持 next,但它们可以很容易地通过使用 run_until_complete 方法在 asyncio 事件循环中运行:
loop = asyncio.get_event_loop()
loop.run_until_complete(coro)
使用 async def 语句定义的协程也被称为 原生协程。
asyncio 模块提供了资源(称为 awaitables),这些资源可以通过 await 语法在协程内部请求。例如,如果我们想等待一段时间然后执行一个语句,我们可以使用 asyncio.sleep 函数:
async def wait_and_print(msg):
await asyncio.sleep(1)
print("Message: ", msg)
loop.run_until_complete(wait_and_print("Hello"))
结果是漂亮的、干净的代码。我们正在编写功能齐全的异步代码,而没有回调的所有丑陋之处!
你可能已经注意到了 await 如何为事件循环提供一个断点,这样在等待资源的同时,事件循环可以继续运行并并发管理其他协程。
更好的是,协程也是 awaitable 的,我们可以使用 await 语句来异步地链式调用协程。在下面的示例中,我们通过将 time.sleep 调用替换为 asyncio.sleep 来重写我们之前定义的 network_request 函数:
async def network_request(number):
await asyncio.sleep(1.0)
return {"success": True, "result": number ** 2}
我们可以通过重新实现 fetch_square 来跟进。正如你所看到的,我们可以直接等待 network_request,而不需要额外的 futures 或回调。
async def fetch_square(number):
response = await network_request(number)
if response["success"]:
print("Result is: {}".format(response["result"]))
我们可以使用 loop.run_until_complete 单独执行协程:
loop.run_until_complete(fetch_square(2))
loop.run_until_complete(fetch_square(3))
loop.run_until_complete(fetch_square(4))
使用 run_until_complete 运行任务对于测试和调试来说是可行的。然而,我们的程序大多数情况下将以 loop.run_forever 启动,并且我们需要在循环已经运行时提交我们的任务。
asyncio 提供了 ensure_future 函数,该函数安排协程(以及 futures)的执行。ensure_future 可以通过简单地传递我们想要安排的协程来使用。以下代码将安排多个并发执行的 fetch_square 调用:
asyncio.ensure_future(fetch_square(2))
asyncio.ensure_future(fetch_square(3))
asyncio.ensure_future(fetch_square(4))
loop.run_forever()
# Hit Ctrl-C to stop the loop
作为额外的好处,当传递一个协程时,asyncio.ensure_future 函数将返回一个 Task 实例(它是 Future 的子类),这样我们就可以利用 await 语法,而不必放弃常规未来的资源跟踪能力。
将阻塞代码转换为非阻塞代码
虽然 asyncio 支持以异步方式连接到资源,但在某些情况下需要使用阻塞调用。例如,当第三方 API 仅公开阻塞调用(例如,许多数据库库)时,以及执行长时间运行的计算时,这种情况会发生。在本小节中,我们将学习如何处理阻塞 API 并使其与 asyncio 兼容。
处理阻塞代码的有效策略是在单独的线程中运行它。线程在 操作系统 (OS) 级别实现,并允许并行执行阻塞代码。为此,Python 提供了 Executor 接口,用于在单独的线程中运行任务,并使用未来来监控它们的进度。
您可以通过从 concurrent.futures 模块导入它来初始化 ThreadPoolExecutor。执行器将启动一组线程(称为 workers),它们将等待执行我们向它们投递的任何任务。一旦提交了一个函数,执行器将负责将其执行调度到可用的工作线程,并跟踪结果。可以使用 max_workers 参数来选择线程数。
注意,一旦任务完成,执行器不会销毁一个线程。这样做可以减少与线程创建和销毁相关的成本。
在以下示例中,我们创建了一个具有三个工作者的 ThreadPoolExecutor,并提交了一个 wait_and_return 函数,该函数将阻塞程序执行一秒钟并返回一个消息字符串。然后我们使用 submit 方法来安排其执行:
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=3)
def wait_and_return(msg):
time.sleep(1)
return msg
executor.submit(wait_and_return, "Hello. executor")
# Result:
# <Future at 0x7ff616ff6748 state=running>
executor.submit 方法立即安排函数并返回一个未来。可以使用 loop.run_in_executor 方法来管理 asyncio 中的任务执行,该方法与 executor.submit 工作方式非常相似:
fut = loop.run_in_executor(executor, wait_and_return, "Hello, asyncio
executor")
# <Future pending ...more info...>
run_in_executor 方法也会返回一个 asyncio.Future 实例,可以从其他代码中等待,主要区别在于未来将不会运行,直到我们开始循环。我们可以使用 loop.run_until_complete 来运行并获取响应:
loop.run_until_complete(fut)
# Result:
# 'Hello, executor'
作为实际示例,我们可以使用这项技术来实现多个网页的并发获取。为此,我们将导入流行的(阻塞的)requests 库,并在执行器中运行 requests.get 函数:
import requests
async def fetch_urls(urls):
responses = []
for url in urls:
responses.append(await loop.run_in_executor
(executor, requests.get, url))
return responses
loop.run_until_complete(fetch_ruls(['http://www.google.com',
'http://www.example.com',
'http://www.facebook.com']))
# Result
# []
此版本的 fetch_url 不会阻塞执行,并允许 asyncio 中的其他协程运行;然而,它并不理想,因为函数不会并行获取 URL。为此,我们可以使用 asyncio.ensure_future 或使用 asyncio.gather 便利函数,该函数将一次性提交所有协程并收集结果。这里展示了 asyncio.gather 的用法:
def fetch_urls(urls):
return asyncio.gather(*[loop.run_in_executor
(executor, requests.get, url)
for url in urls])
使用此方法可以并行获取的 URL 数量将取决于您拥有的工作线程数量。为了避免这种限制,您应该使用原生的非阻塞库,例如 aiohttp。
响应式编程
响应式编程是一种旨在构建更好的并发系统的范式。响应式应用程序旨在符合响应式宣言中例示的要求:
-
响应式:系统立即响应用户。
-
弹性:系统能够处理不同级别的负载,并能适应不断增长的需求。
-
弹性:系统优雅地处理故障。这是通过模块化和避免单一故障点来实现的。
-
消息驱动:系统不应阻塞并利用事件和消息。消息驱动型应用程序有助于实现所有先前的要求。
如您所见,反应式系统的意图非常崇高,但反应式编程究竟是如何工作的呢?在本节中,我们将通过使用 RxPy 库来学习反应式编程的原则。
RxPy 库是 ReactiveX (reactivex.io/) 的一部分,ReactiveX 是一个实现多种语言反应式编程工具的项目。
可观察对象
如同其名所暗示的,反应式编程的主要思想是响应事件。在前一节中,我们通过回调示例看到了这个想法的一些例子;您订阅它们,回调在事件发生时立即执行。
在反应式编程中,这种想法通过将事件视为数据流来扩展。这可以通过在 RxPy 中展示此类数据流的示例来体现。可以使用 Observable.from_iterable 工厂方法从迭代器创建数据流,如下所示:
from rx import Observable
obs = Observable.from_iterable(range(4))
为了从 obs 接收数据,我们可以使用 Observable.subscribe 方法,该方法将为数据源发出的每个值执行我们传递的函数:
obs.subscribe(print)
# Output:
# 0
# 1
# 2
# 3
您可能已经注意到,可观察对象就像列表或更一般地说,迭代器一样是有序集合。这不是巧合。
“可观察”一词来自观察者和可迭代的组合。一个观察者是一个对它观察的变量的变化做出反应的对象,而一个可迭代是一个能够产生并跟踪迭代器的对象。
在 Python 中,迭代器是定义了 __next__ 方法的对象,并且可以通过调用 next 提取其元素。迭代器通常可以通过使用 iter 从集合中获得;然后我们可以使用 next 或 for 循环提取元素。一旦从迭代器中消耗了一个元素,我们就不能回退。我们可以通过从列表创建迭代器来演示其用法:
collection = list([1, 2, 3, 4, 5])
iterator = iter(collection)
print("Next")
print(next(iterator))
print(next(iterator))
print("For loop")
for i in iterator:
print(i)
# Output:
# Next
# 1
# 2
# For loop
# 3
# 4
# 5
您可以看到,每次我们调用 next 或迭代时,迭代器都会产生一个值并前进。从某种意义上说,我们是从迭代器中“拉取”结果。
迭代器听起来很像生成器;然而,它们更为通用。在 Python 中,生成器是由使用 yield 表达式的函数返回的。正如我们所见,生成器支持 next,因此,它们是迭代器的一个特殊类别。
现在你可以欣赏到迭代器和可观察对象之间的对比。可观察对象在准备好时将数据流 推送 给我们,但这不仅仅是这样。可观察对象还能告诉我们何时出现错误以及何时没有更多数据。实际上,可以注册更多的回调到 Observable.subscribe 方法。在以下示例中,我们创建了一个可观察对象,并注册了回调,当有下一个项目可用时使用 on_next 调用,当没有更多数据时使用 on_completed 参数:
obs = Observable.from_iter(range(4))
obs.subscribe(on_next=lambda x: print(on_next="Next item: {}"),
on_completed=lambda: print("No more data"))
# Output:
# Next element: 0
# Next element: 1
# Next element: 2
# Next element: 3
# No more data
这个与迭代器的类比很重要,因为我们可以使用与迭代器相同的技巧来处理事件流。
RxPy 提供了可以用来创建、转换、过滤和分组可观察对象的操作符。响应式编程的强大之处在于,这些操作返回其他可观察对象,可以方便地链接和组合在一起。为了快速体验,我们将演示 take 操作符的使用。
给定一个可观察对象,take 将返回一个新的可观察对象,该对象将在 n 个项目后停止。其用法很简单:
obs = Observable.from_iterable(range(100000))
obs2 = obs.take(4)
obs2.subscribe(print)
# Output:
# 0
# 1
# 2
# 3
在 RxPy 中实现的操作集合多种多样且丰富,可以用这些操作符作为构建块来构建复杂的应用程序。
有用的操作符
在本小节中,我们将探讨以某种方式转换源可观察对象元素的运算符。这个家族中最突出的成员是熟悉的 map,它在应用函数后发出源可观察对象的元素。例如,我们可能使用 map 来计算数字序列的平方:
(Observable.from_iterable(range(4))
.map(lambda x: x**2)
.subscribe(print))
# Output:
# 0
# 1
# 4
# 9
操作符可以用水滴图来表示,这有助于我们更好地理解操作符的工作原理,尤其是在考虑到元素可以在一段时间内发出时。在水滴图中,数据流(在我们的例子中,是一个可观察对象)由一条实线表示。一个圆圈(或其他形状)标识由可观察对象发出的值,一个 X 符号表示错误,一条垂直线表示流的结束。
在下面的图中,我们可以看到 map 的水滴图:

源可观察对象位于图的顶部,转换位于中间,结果可观察对象位于底部。
另一个转换的例子是 group_by,它根据键将项目排序到组中。group_by 操作符接受一个函数,当给定一个元素时提取一个键,并为每个键生成一个与相关元素关联的可观察对象。
使用水滴图可以更清楚地表达 group_by 操作。在下面的图中,你可以看到 group_by 发出两个可观察对象。此外,项目在发出后立即动态地排序到组中:

我们可以通过一个简单的例子进一步了解group_by的工作方式。假设我们想要根据数字是偶数还是奇数来分组。我们可以通过传递lambda x: x % 2表达式作为键函数来实现这一点,如果数字是偶数则返回0,如果是奇数则返回1:
obs = (Observable.from_range(range(4))
.group_by(lambda x: x % 2))
到目前为止,如果我们订阅并打印obs的内容,实际上会打印出两个可观测量:
obs.subscribe(print)
# <rx.linq.groupedobservable.GroupedObservable object at 0x7f0fba51f9e8>
# <rx.linq.groupedobservable.GroupedObservable object at 0x7f0fba51fa58>
您可以使用key属性来确定组密钥。为了提取所有偶数,我们可以取第一个可观测量(对应于等于 0 的密钥)并订阅它。在下面的代码中,我们展示了这是如何工作的:
obs.subscribe(lambda x: print("group key: ", x.key))
# Output:
# group key: 0
# group key: 1
obs.take(1).subscribe(lambda x: x.subscribe(print))
# Output:
# 0
# 2
使用group_by,我们引入了一个发出其他可观测量的可观测量。这实际上是响应式编程中相当常见的一种模式,并且有一些函数允许您组合不同的可观测量。
结合可观测量的两个有用工具是merge_all和concat_all。合并操作接受多个可观测量,并产生一个包含两个可观测量元素的单一可观测量,其顺序与它们被发射的顺序相同。这可以通过一个弹珠图更好地说明:

merge_all可以与类似的操作符concat_all进行比较,它返回一个新的可观测量,该可观测量发射第一个可观测量的所有元素,然后是第二个可观测量的元素,依此类推。concat_all的弹珠图如下所示:

为了演示这两个操作符的用法,我们可以将这些操作应用于group_by返回的可观测量的可观测量。在merge_all的情况下,项目以它们最初相同的顺序返回(记住group_by以两个组中的元素顺序发射):
obs.merge_all().subscribe(print)
# Output
# 0
# 1
# 2
# 3
另一方面,concat_all首先返回偶数元素,然后等待第一个可观测量完成,然后开始发射第二个可观测量的元素。这在下述代码片段中得到了演示。在这个特定的例子中,我们还应用了一个函数make_replay;这是必需的,因为在“偶数”流被消费时,第二个流的元素已经被产生,并且将不可用给concat_all。在阅读了热和冷可观测量章节后,这个概念将变得更加清晰:
def make_replay(a):
result = a.replay(None)
result.connect()
return result
obs.map(make_replay).concat_all().subscribe(print)
# Output
# 0
# 2
# 1
# 3
这次,首先打印偶数,然后打印奇数。
RxPy 还提供了merge和concat操作,可以用来组合单个可观测量。
热和冷可观测量
在前面的章节中,我们学习了如何使用Observable.from_iterable方法创建可观测量。RxPy 提供了许多其他工具来创建更有趣的事件源。
Observable.interval 接收一个时间间隔(以毫秒为单位),period,并且会创建一个在每次周期过去时发出值的可观察对象。以下代码行可以用来定义一个可观察对象 obs,它将从零开始,每秒发出一个数字。我们使用 take 操作符来限制计时器只触发四个事件:
obs = Observable.interval(1000)
obs.take(4).subscribe(print)
# Output:
# 0
# 1
# 2
# 3
关于 Observable.interval 的一个非常重要的事实是,计时器只有在订阅时才会开始。我们可以通过打印从计时器开始定义时的索引和延迟来观察这一点,如下所示:
import time
start = time.time()
obs = Observable.interval(1000).map(lambda a:
(a, time.time() - start))
# Let's wait 2 seconds before starting the subscription
time.sleep(2)
obs.take(4).subscribe(print)
# Output:
# (0, 3.003735303878784)
# (1, 4.004871129989624)
# (2, 5.005947589874268)
# (3, 6.00749135017395)
正如你所见,第一个元素(对应于 0 索引)在经过三秒后产生,这意味着计时器是在我们发出 subscribe(print) 方法时开始的。
如 Observable.interval 这样的可观察对象被称为 lazy,因为它们只有在请求时才开始产生值(想想自动售货机,除非我们按下按钮,否则它们不会发放食物)。在 Rx 术语中,这类可观察对象被称为 cold。冷可观察对象的一个特性是,如果我们附加两个订阅者,间隔计时器将多次启动。这可以从以下示例中清楚地看出。在这里,我们在第一个订阅后 0.5 秒添加一个新的订阅,你可以看到两个订阅的输出在不同的时间到来:
start = time.time()
obs = Observable.interval(1000).map(lambda a:
(a, time.time() - start))
# Let's wait 2 seconds before starting the subscription
time.sleep(2)
obs.take(4).subscribe(lambda x: print("First subscriber:
{}".format(x)))
time.sleep(0.5)
obs.take(4).subscribe(lambda x: print("Second subscriber:
{}".format(x)))
# Output:
# First subscriber: (0, 3.0036110877990723)
# Second subscriber: (0, 3.5052847862243652)
# First subscriber: (1, 4.004414081573486)
# Second subscriber: (1, 4.506155252456665)
# First subscriber: (2, 5.005316972732544)
# Second subscriber: (2, 5.506817102432251)
# First subscriber: (3, 6.0062034130096436)
# Second subscriber: (3, 6.508296489715576)
有时候我们可能不希望这种行为,因为我们可能希望多个订阅者订阅相同的数据源。为了使可观察对象产生相同的数据,我们可以延迟数据的生产,并确保所有订阅者都会通过 publish 方法获得相同的数据。
Publish 将将我们的可观察对象转换为 ConnectableObservable,它不会立即开始推送数据,而只有在调用 connect 方法时才会开始。publish 和 connect 的用法在以下代码片段中得到了演示:
start = time.time()
obs = Observable.interval(1000).map(lambda a: (a, time.time() -
start)).publish()
obs.take(4).subscribe(lambda x: print("First subscriber:
{}".format(x)))
obs.connect() # Data production starts here
time.sleep(2)
obs.take(4).subscribe(lambda x: print("Second subscriber:
{}".format(x)))
# Output:
# First subscriber: (0, 1.0016899108886719)
# First subscriber: (1, 2.0027990341186523)
# First subscriber: (2, 3.003532648086548)
# Second subscriber: (2, 3.003532648086548)
# First subscriber: (3, 4.004265308380127)
# Second subscriber: (3, 4.004265308380127)
# Second subscriber: (4, 5.005320310592651)
# Second subscriber: (5, 6.005795240402222)
在前面的示例中,你可以看到我们首先发出 publish,然后订阅第一个订阅者,最后发出 connect。当发出 connect 时,计时器将开始产生数据。第二个订阅者晚到了派对,实际上将不会收到前两条消息,而是从第三条开始接收数据。注意,这一次,订阅者共享了完全相同的数据。这种独立于订阅者产生数据的数据源被称为 hot。
与 publish 类似,你可以使用 replay 方法,该方法将为每个新的订阅者产生从开始的数据。以下示例说明了这一点,它与前面的示例相同,只是我们将 publish 替换为了 replay:
import time
start = time.time()
obs = Observable.interval(1000).map(lambda a: (a, time.time() -
start)).replay(None)
obs.take(4).subscribe(lambda x: print("First subscriber:
{}".format(x)))
obs.connect()
time.sleep(2)
obs.take(4).subscribe(lambda x: print("Second subscriber:
{}".format(x)))
First subscriber: (0, 1.0008857250213623)
First subscriber: (1, 2.0019824504852295)
Second subscriber: (0, 1.0008857250213623)
Second subscriber: (1, 2.0019824504852295) First subscriber: (2, 3.0030810832977295)
Second subscriber: (2, 3.0030810832977295)
First subscriber: (3, 4.004604816436768)
Second subscriber: (3, 4.004604816436768)
你可以看到,这一次,尽管第二个订阅者晚到了派对,但他仍然得到了到目前为止已经发放的所有物品。
创建热可观察量的另一种方式是通过Subject类。Subject很有趣,因为它既能接收数据也能推送数据,因此可以用来手动推送项目到一个可观察量。使用Subject非常直观;在下面的代码中,我们创建了一个Subject并订阅它。稍后,我们使用on_next方法向它推送值;一旦这样做,订阅者就会被调用:
s = Subject()
s.subscribe(lambda a: print("Subject emitted value: {}".format(x))
s.on_next(1)
# Subject emitted value: 1
s.on_next(2)
# Subject emitted value: 2
注意,Subject是热可观察量的另一个例子。
构建 CPU 监控器
现在我们已经掌握了主要反应式编程概念,我们可以实现一个示例应用程序。在本小节中,我们将实现一个监控器,它将提供有关我们 CPU 使用情况的实时信息,并且能够检测到峰值。
CPU 监控器的完整代码可以在cpu_monitor.py文件中找到。
作为第一步,让我们实现一个数据源。我们将使用psutil模块,它提供了一个名为psutil.cpu_percent的函数,该函数返回最新的 CPU 使用率作为百分比(并且不会阻塞):
import psutil
psutil.cpu_percent()
# Result: 9.7
由于我们正在开发一个监控器,我们希望在不同时间间隔内采样这些信息。为此,我们可以使用熟悉的Observable.interval,然后像上一节那样使用map。此外,我们希望使这个可观察量热,因为对于这个应用程序,所有订阅者都应该接收单一的数据源;为了使Observable.interval成为热可观察量,我们可以使用publish和connect方法。创建cpu_data可观察量的完整代码如下
cpu_data = (Observable
.interval(100) # Each 100 milliseconds
.map(lambda x: psutil.cpu_percent())
.publish())
cpu_data.connect() # Start producing data
我们可以通过打印 4 个样本来测试我们的监控器。
cpu_data.take(4).subscribe(print)
# Output:
# 12.5
# 5.6
# 4.5
# 9.6
现在我们已经设置了主要数据源,我们可以使用matplotlib实现一个监控器可视化。想法是创建一个包含固定数量测量的图表,并且随着新数据的到来,我们包括最新的测量值并移除最旧的测量值。这通常被称为移动窗口,通过以下插图可以更好地理解。在下面的图中,我们的cpu_data流被表示为数字列表。第一个图表是在我们获得前四个数字时产生的,每次新数字到达时,我们通过一个位置移动窗口并更新图表:

为了实现这个算法,我们可以编写一个名为monitor_cpu的函数,该函数将创建和更新我们的绘图窗口。该函数将执行以下操作:
-
初始化一个空图表并设置正确的绘图限制。
-
将我们的
cpu_data可观察量转换为一个在数据上移动的窗口。这可以通过使用buffer_with_count运算符来完成,它将窗口中的点数npoints作为参数,并将偏移量设置为1。 -
订阅这个新的数据流并更新图表中的传入数据。
函数的完整代码如下所示,如您所见,它非常紧凑。花点时间运行该函数并调整参数:
import numpy as np
from matplotlib import pyplot as plt
def monitor_cpu(npoints):
lines, = plt.plot([], [])
plt.xlim(0, npoints)
plt.ylim(0, 100) # 0 to 100 percent
cpu_data_window = cpu_data.buffer_with_count(npoints, 1)
def update_plot(cpu_readings):
lines.set_xdata(np.arange(npoints))
lines.set_ydata(np.array(cpu_readings))
plt.draw()
cpu_data_window.subscribe(update_plot)
plt.show()
我们可能还想开发的一个功能是,例如,当 CPU 使用率持续一段时间时触发警报,因为这可能表明我们机器中的某些进程正在非常努力地工作。这可以通过结合buffer_with_count和map来实现。我们可以取 CPU 流和窗口,然后在map函数中测试所有项的值是否高于二十百分比的使用率(在一个四核 CPU 中,这相当于大约一个处理器以百分之一百的速度工作)。如果窗口中的所有点使用率都高于二十百分比,我们将在我们的绘图窗口中显示警告。
新观察量的实现可以写成以下形式,并将产生一个观察量,如果 CPU 使用率高,则发出True,否则发出False:
alertpoints = 4
high_cpu = (cpu_data
.buffer_with_count(alertpoints, 1)
.map(lambda readings: all(r > 20 for r in readings)))
现在当high_cpu观察量准备就绪后,我们可以创建一个matplotlib标签并订阅它以获取更新:
label = plt.text(1, 1, "normal")
def update_warning(is_high):
if is_high:
label.set_text("high")
else:
label.set_text("normal")
high_cpu.subscribe(update_warning)
摘要
当我们的代码处理缓慢且不可预测的资源时,如 I/O 设备和网络,异步编程是有用的。在本章中,我们探讨了并发和异步编程的基本概念,以及如何使用asyncio和 RxPy 库编写并发代码。
当处理多个相互关联的资源时,asyncio协程是一个很好的选择,因为它们通过巧妙地避免回调来极大地简化代码逻辑。在这些情况下,响应式编程也非常好,但它真正闪耀的时候是处理在实时应用程序和用户界面中常见的流数据。
在接下来的两章中,我们将学习并行编程以及如何通过利用多个核心和多个机器来实现令人印象深刻的性能提升。
第七章:并行处理
通过使用多个核心的并行处理,您可以在给定的时间框架内增加程序可以完成的计算量,而无需更快的处理器。主要思想是将问题划分为独立的子单元,并使用多个核心并行解决这些子单元。
并行处理对于解决大规模问题是必要的。公司每天产生大量数据,需要存储在多台计算机上并进行分析。科学家和工程师在超级计算机上运行并行代码以模拟大型系统。
并行处理允许您利用多核 CPU 以及与高度并行问题配合得非常好的 GPU。在本章中,我们将涵盖以下主题:
-
并行处理基础简介
-
使用
multiprocessingPython 库并行化简单问题的说明 -
使用简单的
ProcessPoolExecutor接口 -
在 Cython 和 OpenMP 的帮助下使用多线程并行化我们的程序
-
使用 Theano 和 Tensorflow 自动实现并行性
-
使用 Theano、Tensorflow 和 Numba 在 GPU 上执行代码
并行编程简介
为了并行化一个程序,有必要将问题划分为可以独立(或几乎独立)运行的子单元。
当子单元完全独立于彼此时,该问题被称为令人尴尬的并行。数组上的元素级操作是一个典型的例子——操作只需要知道它当前处理的元素。另一个例子是我们的粒子模拟器。由于没有相互作用,每个粒子可以独立于其他粒子进化。令人尴尬的并行问题很容易实现,并且在并行架构上表现良好。
其他问题可能被划分为子单元,但必须共享一些数据以执行它们的计算。在这些情况下,实现过程不太直接,并且由于通信成本可能导致性能问题。
我们将通过一个例子来说明这个概念。想象一下,你有一个粒子模拟器,但这次粒子在一定的距离内吸引其他粒子(如图所示)。为了并行化这个问题,我们将模拟区域划分为区域,并将每个区域分配给不同的处理器。如果我们一次进化系统的一步,一些粒子将与相邻区域的粒子相互作用。为了执行下一次迭代,需要与相邻区域的新粒子位置进行通信。

进程之间的通信成本高昂,可能会严重阻碍并行程序的性能。在并行程序中处理数据通信存在两种主要方式:
-
共享内存
-
分布式内存
在共享内存中,子单元可以访问相同的内存空间。这种方法的优点是,你不需要显式处理通信,因为从共享内存中写入或读取就足够了。然而,当多个进程同时尝试访问和更改相同的内存位置时,就会出现问题。应谨慎使用同步技术来避免此类冲突。
在分布式内存模型中,每个进程与其他进程完全隔离,并拥有自己的内存空间。在这种情况下,进程间的通信是显式处理的。与共享内存相比,通信开销通常更昂贵,因为数据可能需要通过网络接口传输。
实现共享内存模型中的并行性的一个常见方法是线程。线程是从进程派生出来的独立子任务,并共享资源,如内存。这一概念在以下图中进一步说明。线程产生多个执行上下文并共享相同的内存空间,而进程提供多个具有自己内存空间和需要显式处理通信的执行上下文。

Python 可以创建和处理线程,但它们不能用来提高性能;由于 Python 解释器的设计,一次只能允许一个 Python 指令运行--这种机制被称为全局解释器锁(GIL)。发生的情况是,每次线程执行 Python 语句时,线程都会获取一个锁,当执行完成后,释放相同的锁。由于锁一次只能被一个线程获取,因此当某个线程持有锁时,其他线程将无法执行 Python 语句。
尽管 GIL 阻止了 Python 指令的并行执行,但在可以释放锁的情况(如耗时的 I/O 操作或 C 扩展)中,线程仍然可以用来提供并发性。
为什么不移除全局解释器锁(GIL)呢?在过去的几年里,已经尝试过多次移除 GIL,包括最近的 gilectomy 实验。首先,移除 GIL 并不是一件容易的事情,它需要修改大多数 Python 数据结构。此外,这种细粒度的锁定可能会带来高昂的成本,并可能在单线程程序中引入显著的性能损失。尽管如此,一些 Python 实现(如 Jython 和 IronPython)并不使用 GIL。
可以通过使用进程而不是线程来完全绕过 GIL。进程不共享相同的内存区域,彼此独立--每个进程都有自己的解释器。进程有一些缺点:启动一个新的进程通常比启动一个新的线程慢,它们消耗更多的内存,并且进程间通信可能较慢。另一方面,进程仍然非常灵活,并且随着它们可以在多台机器上分布而具有更好的可扩展性。
图形处理单元
图形处理单元是专为计算机图形应用设计的特殊处理器。这些应用通常需要处理 3D 场景的几何形状,并将像素数组输出到屏幕上。GPU 执行的操作涉及浮点数的数组和矩阵运算。
GPU 被设计用来非常高效地运行与图形相关的操作,它们通过采用高度并行的架构来实现这一点。与 CPU 相比,GPU 拥有更多的(数千个)小型处理单元。GPU 旨在每秒产生大约 60 帧的数据,这比 CPU 的典型响应时间慢得多,而 CPU 具有更高的时钟速度。
GPU 具有与标准 CPU 非常不同的架构,并且专门用于计算浮点运算。因此,为了为 GPU 编译程序,有必要利用特殊的编程平台,如 CUDA 和 OpenCL。
统一计算设备架构(CUDA)是 NVIDIA 的专有技术。它提供了一个可以从其他语言访问的 API。CUDA 提供了 NVCC 工具,可以用来编译用类似于 C(CUDA C)的语言编写的 GPU 程序,以及实现高度优化的数学例程的众多库。
OpenCL是一种开放技术,具有编写可编译为多种目标设备(多个厂商的 CPU 和 GPU)的并行程序的能力,对于非 NVIDIA 设备来说是一个不错的选择。
GPU 编程在纸面上听起来很美妙。然而,不要急着丢弃你的 CPU。GPU 编程很复杂,并且只有特定的用例能从 GPU 架构中受益。程序员需要意识到在主内存之间进行数据传输所产生的成本,以及如何实现算法以利用 GPU 架构。
通常,GPU 在增加每单位时间内可以执行的操作数量(也称为吞吐量)方面表现很好;然而,它们需要更多的时间来准备数据以进行处理。相比之下,CPU 在从头开始生成单个结果方面要快得多(也称为延迟)。
对于正确的问题,GPU 提供了极端(10 到 100 倍)的加速。因此,它们通常构成了一个非常经济的(同样的加速将需要数百个 CPU)解决方案,用于提高数值密集型应用的性能。我们将在自动并行性部分说明如何在 GPU 上执行一些算法。
使用多个进程
标准的multiprocessing模块可以通过启动几个进程来快速并行化简单任务,同时避免 GIL 问题。它的接口易于使用,包括处理任务提交和同步的几个实用工具。
Process 和 Pool 类
您可以通过继承 multiprocessing.Process 来创建一个独立运行的进程。您可以扩展 __init__ 方法来初始化资源,并且可以通过实现 Process.run 方法来编写将在子进程中执行的部分代码。在以下代码中,我们定义了一个 Process 类,它将等待一秒钟并打印其分配的 id:
import multiprocessing
import time
class Process(multiprocessing.Process):
def __init__(self, id):
super(Process, self).__init__()
self.id = id
def run(self):
time.sleep(1)
print("I'm the process with id: {}".format(self.id))
要启动进程,我们必须实例化 Process 类并调用 Process.start 方法。请注意,您不能直接调用 Process.run;对 Process.start 的调用将创建一个新的进程,然后该进程将调用 Process.run 方法。我们可以在前面的代码片段末尾添加以下行来创建并启动新进程:
if __name__ == '__main__':
p = Process(0)
p.start()
在 Process.start 之后的指令将立即执行,而不需要等待 p 进程完成。要等待任务完成,您可以使用 Process.join 方法,如下所示:
if __name__ == '__main__':
p = Process(0)
p.start()
p.join()
我们可以启动四个不同的进程,它们将以相同的方式并行运行。在串行程序中,所需的总时间将是四秒钟。由于执行是并发的,因此结果的时间将是秒。在以下代码中,我们创建了四个将并发执行的进程:
if __name__ == '__main__':
processes = Process(1), Process(2), Process(3), Process(4)
[p.start() for p in processes]
注意,并行进程的执行顺序是不可预测的,最终取决于操作系统如何调度它们的执行。您可以通过多次执行程序来验证此行为;运行之间的顺序可能会不同。
multiprocessing 模块提供了一个方便的接口,使得将任务分配和分配给 multiprocessing.Pool 类中驻留的一组进程变得容易。
multiprocessing.Pool 类会启动一组进程——称为 工作者——并允许我们通过 apply/apply_async 和 map/map_async 方法提交任务。
Pool.map 方法将函数应用于列表中的每个元素,并返回结果列表。其用法与内置的(串行)map 相当。
要使用并行映射,您首先需要初始化一个 multiprocessing.Pool 对象。它将工作者数量作为其第一个参数;如果没有提供,则该数字将与系统中的核心数相等。您可以通过以下方式初始化一个 multiprocessing.Pool 对象:
pool = multiprocessing.Pool()
pool = multiprocessing.Pool(processes=4)
让我们看看 pool.map 的实际应用。如果您有一个计算数字平方的函数,您可以通过调用 Pool.map 并传递函数和输入列表作为参数来将该函数映射到列表上,如下所示:
def square(x):
return x * x
inputs = [0, 1, 2, 3, 4]
outputs = pool.map(square, inputs)
Pool.map_async 函数与 Pool.map 类似,但返回的是 AsyncResult 对象而不是实际的结果。当我们调用 Pool.map 时,主程序的执行会停止,直到所有工作进程完成结果的处理。使用 map_async,会立即返回 AsyncResult 对象而不阻塞主程序,计算在后台进行。然后我们可以使用 AsyncResult.get 方法在任何时候检索结果,如下面的代码所示:
outputs_async = pool.map_async(square, inputs)
outputs = outputs_async.get()
Pool.apply_async 将一个由单个函数组成的任务分配给一个工作进程。它接受函数及其参数,并返回一个 AsyncResult 对象。我们可以使用 apply_async 获得类似于 map 的效果,如下所示:
results_async = [pool.apply_async(square, i) for i in range(100))]
results = [r.get() for r in results_async]
执行器接口
从版本 3.2 开始,可以使用 concurrent.futures 模块中提供的 Executor 接口并行执行 Python 代码。我们在上一章中已经看到了 Executor 接口的使用,当时我们使用 ThreadPoolExecutor 来并发执行多个任务。在本小节中,我们将演示 ProcessPoolExecutor 类的使用。
ProcessPoolExecutor 提供了一个非常简洁的接口,至少与功能更丰富的 multiprocessing.Pool 相比是这样的。可以通过传递 max_workers 参数(默认情况下,max_workers 将是可用的 CPU 核心数)来实例化 ProcessPoolExecutor,类似于 ThreadPoolExecutor。ProcessPoolExecutor 可用的主要方法有 submit 和 map。
submit 方法将接受一个函数并返回一个 Future(见最后一章),该 Future 将跟踪提交函数的执行。方法 map 与 Pool.map 函数类似,但返回的是一个迭代器而不是列表:
from concurrent.futures import ProcessPoolExecutor
executor = ProcessPoolExecutor(max_workers=4)
fut = executor.submit(square, 2)
# Result:
# <Future at 0x7f5b5c030940 state=running>
result = executor.map(square, [0, 1, 2, 3, 4])
list(result)
# Result:
# [0, 1, 4, 9, 16]
要从一个或多个 Future 实例中提取结果,可以使用 concurrent.futures.wait 和 concurrent.futures.as_completed 函数。wait 函数接受一个 future 列表,并将阻塞程序的执行,直到所有 futures 完成它们的执行。然后可以使用 Future.result 方法提取结果。as_completed 函数也接受一个函数,但会返回一个结果迭代器:
from concurrent.futures import wait, as_completed
fut1 = executor.submit(square, 2)
fut2 = executor.submit(square, 3)
wait([fut1, fut2])
# Then you can extract the results using fut1.result() and fut2.result()
results = as_completed([fut1, fut2])
list(results)
# Result:
# [4, 9]
或者,你可以使用 asyncio.run_in_executor 函数生成 futures,并使用 asyncio 库提供的所有工具和语法来操作结果,这样你就可以同时实现并发和并行。
π 的蒙特卡洛近似
作为例子,我们将实现一个典型的、显而易见的并行程序——蒙特卡洛逼近π。想象一下,我们有一个边长为 2 个单位的正方形;其面积为 4 个单位。现在,我们在正方形内画一个半径为 1 个单位的圆;圆的面积将是 π * r²。通过将 r 的值代入前面的方程,我们得到圆面积的数值为 π * (1)² = π。你可以参考以下图表来获取图形表示。
如果我们在这个图形上随机射击很多点,一些点将落在圆内,我们将它们称为击中,而剩余的点,未击中,将位于圆外。圆的面积将与击中的数量成正比,而正方形的面积将与射击的总数成正比。为了得到 π 的值,只需将圆的面积(等于 π)除以正方形的面积(等于 4)即可:
hits/total = area_circle/area_square = pi/4
pi = 4 * hits/total

我们在项目中将采用以下策略:
-
在范围(-1,1)内生成大量的均匀随机(x,y)数字
-
通过检查是否 x2 + y2 <= 1 来测试这些数字是否位于圆内
编写并行程序的第一步是编写一个串行版本并验证其是否工作。在实际场景中,你希望将并行化作为优化过程的最后一步。首先,我们需要识别出慢速部分,其次,并行化耗时且只能提供与处理器数量相等的最大加速。串行程序的实现如下:
import random
samples = 1000000
hits = 0
for i in range(samples):
x = random.uniform(-1.0, 1.0)
y = random.uniform(-1.0, 1.0)
if x**2 + y**2 <= 1:
hits += 1
pi = 4.0 * hits/samples
随着样本数量的增加,我们的近似精度将提高。你可以注意到每个循环迭代都是独立的——这个问题是显而易见的并行问题。
为了并行化这段代码,我们可以编写一个名为 sample 的函数,它对应于一次击中-未击中的检查。如果样本击中圆,函数将返回 1;否则,它将返回 0。通过多次运行 sample 并汇总结果,我们将得到总的击中次数。我们可以使用 apply_async 在多个处理器上运行 sample 并以以下方式获取结果:
def sample():
x = random.uniform(-1.0, 1.0)
y = random.uniform(-1.0, 1.0)
if x**2 + y**2 <= 1:
return 1
else:
return 0
pool = multiprocessing.Pool()
results_async = [pool.apply_async(sample) for i in range(samples)]
hits = sum(r.get() for r in results_async)
我们可以将这两个版本包裹在 pi_serial 和 pi_apply_async 函数中(你可以在 pi.py 文件中找到它们的实现)并比较执行速度,如下所示:
$ time python -c 'import pi; pi.pi_serial()'
real 0m0.734s
user 0m0.731s
sys 0m0.004s
$ time python -c 'import pi; pi.pi_apply_async()'
real 1m36.989s
user 1m55.984s
sys 0m50.386
如前所述的基准测试所示,我们的第一个并行版本实际上削弱了我们的代码。原因是实际计算所需的时间与发送和分配任务给工作者的开销相比很小。
为了解决这个问题,我们必须使开销与计算时间相比可以忽略不计。例如,我们可以要求每个工作进程一次处理多个样本,从而减少任务通信开销。我们可以编写一个sample_multiple函数,它处理多个命中并修改我们的并行版本,通过将问题分成 10 份;更密集的任务如下面的代码所示:
def sample_multiple(samples_partial):
return sum(sample() for i in range(samples_partial))
n_tasks = 10
chunk_size = samples/n_tasks
pool = multiprocessing.Pool()
results_async = [pool.apply_async(sample_multiple, chunk_size)
for i in range(n_tasks)]
hits = sum(r.get() for r in results_async)
我们可以将这个功能封装在一个名为pi_apply_async_chunked的函数中,并按如下方式运行:
$ time python -c 'import pi; pi.pi_apply_async_chunked()'
real 0m0.325s
user 0m0.816s
sys 0m0.008s
结果要好得多;我们的程序速度提高了不止一倍。你还可以注意到user指标大于real;总 CPU 时间大于总时间,因为同时有多个 CPU 在工作。如果你增加样本数量,你会注意到通信与计算的比率降低,从而提供更好的加速。
当处理令人尴尬的并行问题时,一切都很简单。然而,有时你必须在进程之间共享数据。
同步和锁
即使multiprocessing使用进程(它们有自己独立的内存),它也允许你定义某些变量和数组作为共享内存。你可以使用multiprocessing.Value定义一个共享变量,将数据类型作为字符串传递(i表示整数,d表示双精度,f表示浮点数等)。你可以通过value属性更新变量的内容,如下面的代码片段所示:
shared_variable = multiprocessing.Value('f')
shared_variable.value = 0
当使用共享内存时,你应该意识到并发访问。想象一下,你有一个共享的整数变量,每个进程多次增加其值。你可以定义一个进程类如下:
class Process(multiprocessing.Process):
def __init__(self, counter):
super(Process, self).__init__()
self.counter = counter
def run(self):
for i in range(1000):
self.counter.value += 1
你可以在主程序中初始化共享变量,并将其传递给4个进程,如下面的代码所示:
def main():
counter = multiprocessing.Value('i', lock=True)
counter.value = 0
processes = [Process(counter) for i in range(4)]
[p.start() for p in processes]
[p.join() for p in processes] # processes are done
print(counter.value)
如果你运行这个程序(代码目录中的shared.py),你会注意到counter的最终值不是 4000,而是有随机值(在我的机器上,它们在 2000 到 2500 之间)。如果我们假设算术是正确的,我们可以得出结论,并行化存在问题。
发生的情况是多个进程同时尝试访问相同的共享变量。这种情况最好通过查看以下图表来解释。在串行执行中,第一个进程读取(数字0),增加它,并写入新值(1);第二个进程读取新值(1),增加它,并再次写入(2)。
在并行执行中,两个进程同时读取(0),增加它,并写入值(1),导致错误答案:

为了解决这个问题,我们需要同步对这个变量的访问,以确保一次只有一个进程可以访问、增加和写入共享变量的值。这个功能由 multiprocessing.Lock 类提供。锁可以通过 acquire 方法或 release 以及将锁用作上下文管理器来获取和释放。由于锁一次只能被一个进程获取,这种方法防止了多个进程同时执行受保护的代码段。
我们可以定义一个全局锁,并使用它作为上下文管理器来限制对计数器的访问,如下代码片段所示:
lock = multiprocessing.Lock()
class Process(multiprocessing.Process):
def __init__(self, counter):
super(Process, self).__init__()
self.counter = counter
def run(self):
for i in range(1000):
with lock: # acquire the lock
self.counter.value += 1
# release the lock
同步原语,如锁,对于解决许多问题是必不可少的,但它们应该保持最小,以提高程序的性能。
multiprocessing 模块还包括其他通信和同步工具;您可以参考官方文档docs.python.org/3/library/multiprocessing.html以获取完整参考。
Parallel Cython with OpenMP
Cython 提供了一个方便的接口,通过 OpenMP 执行共享内存并行处理。这允许您直接在 Cython 中编写非常高效的并行代码,而无需创建 C 包装器。
OpenMP 是一个规范和一个 API,旨在编写多线程、并行程序。OpenMP 规范包括一系列 C 预处理器指令,用于管理线程,并提供通信模式、负载均衡和其他同步功能。几个 C/C++ 和 Fortran 编译器(包括 GCC)实现了 OpenMP API。
我们可以通过一个小示例引入 Cython 并行功能。Cython 在 cython.parallel 模块中提供了一个基于 OpenMP 的简单 API。实现并行化的最简单方法是使用 prange,这是一个自动在多个线程中分配循环操作的构造。
首先,我们可以在 hello_parallel.pyx 文件中编写一个程序的串行版本,该程序计算 NumPy 数组中每个元素的平方。我们定义了一个函数 square_serial,它接受一个缓冲区作为输入,并将输入数组元素的平方填充到输出数组中;square_serial 如下代码片段所示:
import numpy as np
def square_serial(double[:] inp):
cdef int i, size
cdef double[:] out
size = inp.shape[0]
out_np = np.empty(size, 'double')
out = out_np
for i in range(size):
out[i] = inp[i]*inp[i]
return out_np
实现对数组元素进行循环的并行版本涉及用 prange 替换 range 调用。有一个注意事项——要使用 prange,循环体必须是解释器无关的。如前所述,我们需要释放 GIL,由于解释器调用通常获取 GIL,因此需要避免它们以利用线程。
在 Cython 中,您可以使用 nogil 上下文释放 GIL,如下所示:
with nogil:
for i in prange(size):
out[i] = inp[i]*inp[i]
或者,您可以使用 prange 的 nogil=True 选项,这将自动将循环体包装在 nogil 块中:
for i in prange(size, nogil=True):
out[i] = inp[i]*inp[i]
尝试在 prange 块中调用 Python 代码将产生错误。禁止的操作包括函数调用、对象初始化等。为了在 prange 块中启用此类操作(你可能想这样做以进行调试),你必须使用 with gil 语句重新启用 GIL:
for i in prange(size, nogil=True):
out[i] = inp[i]*inp[i]
with gil:
x = 0 # Python assignment
现在,我们可以通过将其编译为 Python 扩展模块来测试我们的代码。为了启用 OpenMP 支持,需要更改 setup.py 文件,使其包含编译选项 -fopenmp。这可以通过在 distutils 中使用 distutils.extension.Extension 类并传递给 cythonize 来实现。完整的 setup.py 文件如下:
from distutils.core import setup
from distutils.extension import Extension
from Cython.Build import cythonize
hello_parallel = Extension('hello_parallel',
['hello_parallel.pyx'],
extra_compile_args=['-fopenmp'],
extra_link_args=['-fopenmp'])
setup(
name='Hello',
ext_modules = cythonize(['cevolve.pyx', hello_parallel]),
)
使用 prange,我们可以轻松地将我们的 ParticleSimulator 的 Cython 版本并行化。以下代码包含 cevolve.pyx Cython 模块的 c_evolve 函数,该函数在 第四章,C Performance with Cython 中编写:
def c_evolve(double[:, :] r_i,double[:] ang_speed_i,
double timestep,int nsteps):
# cdef declarations
for i in range(nsteps):
for j in range(nparticles):
# loop body
首先,我们将反转循环的顺序,使得最外层的循环将并行执行(每个迭代都是独立的)。由于粒子之间没有相互作用,我们可以安全地改变迭代的顺序,如下面的代码片段所示:
for j in range(nparticles):
for i in range(nsteps):
# loop body
接下来,我们将用 prange 替换外层循环的 range 调用,并移除获取 GIL 的调用。由于我们的代码已经通过静态类型进行了增强,因此可以安全地应用 nogil 选项,如下所示:
for j in prange(nparticles, nogil=True)
现在,我们可以通过将它们包装在基准函数中来比较这些函数,以评估任何性能改进:
In [3]: %timeit benchmark(10000, 'openmp') # Running on 4 processors
1 loops, best of 3: 599 ms per loop
In [4]: %timeit benchmark(10000, 'cython')
1 loops, best of 3: 1.35 s per loop
有趣的是,我们通过编写 prange 的并行版本实现了 2 倍的速度提升。
自动并行化
如我们之前提到的,由于 GIL,普通的 Python 程序在实现线程并行化方面有困难。到目前为止,我们通过使用单独的进程来解决这个问题;然而,启动进程比启动线程花费更多的时间和内存。
我们还看到,绕过 Python 环境使我们能够在已经很快的 Cython 代码上实现 2 倍的速度提升。这种策略使我们能够实现轻量级并行化,但需要单独的编译步骤。在本节中,我们将进一步探讨这种策略,使用能够自动将我们的代码转换为并行版本的专用库,以实现高效的执行。
实现自动并行化的包示例包括现在熟悉的即时编译器 numexpr 和 Numba。其他包已经开发出来,用于自动优化和并行化数组密集型表达式和矩阵密集型表达式,这在特定的数值和机器学习应用中至关重要。
Theano 是一个项目,它允许你在数组(更一般地说,张量)上定义数学表达式,并将它们编译为快速语言,如 C 或 C++。Theano 实现的大多数操作都是可并行化的,并且可以在 CPU 和 GPU 上运行。
Tensorflow 是另一个库,与 Theano 类似,旨在表达密集型数学表达式,但它不是将表达式转换为专门的 C 代码,而是在高效的 C++ 引擎上执行操作。
当手头的问题可以用矩阵和逐元素操作的链式表达来表示时(例如 神经网络),Theano 和 Tensorflow 都是非常理想的。
开始使用 Theano
Theano 在某种程度上类似于编译器,但增加了能够表达、操作和优化数学表达式以及能够在 CPU 和 GPU 上运行代码的额外好处。自 2010 年以来,Theano 在版本更新中不断改进,并被其他几个 Python 项目采用,作为在运行时自动生成高效计算模型的一种方式。
在 Theano 中,你首先通过指定变量和转换来使用纯 Python API 定义 你想要运行的函数。然后,这个规范将被编译成机器代码以执行。
作为第一个例子,让我们看看如何实现一个计算数字平方的函数。输入将由一个标量变量 a 表示,然后我们将对其进行转换以获得其平方,表示为 a_sq。在下面的代码中,我们将使用 T.scalar 函数来定义变量,并使用正常的 ** 运算符来获取一个新的变量:
import theano.tensor as T
import theano as th
a = T.scalar('a')
a_sq = a ** 2
print(a_sq)
# Output:
# Elemwise{pow,no_inplace}.0
如你所见,没有计算特定的值,我们应用的是纯符号转换。为了使用这个转换,我们需要生成一个函数。要编译一个函数,你可以使用 th.function 工具,它将输入变量的列表作为其第一个参数,输出转换(在我们的情况下是 a_sq)作为其第二个参数:
compute_square = th.function([a], a_sq)
Theano 将花费一些时间将表达式转换为高效的 C 代码并编译它,所有这些都是在后台完成的!th.function 的返回值将是一个可用的 Python 函数,其用法在下一行代码中演示:
compute_square(2)
4.0
令人意外的是,compute_square 函数正确地返回了输入值的平方。然而,请注意,返回类型不是整数(与输入类型相同),而是一个浮点数。这是因为 Theano 的默认变量类型是 float64。你可以通过检查 a 变量的 dtype 属性来验证这一点:
a.dtype
# Result:
# float64
与我们之前看到的 Numba 相比,Theano 的行为非常不同。Theano 不编译通用的 Python 代码,也不进行任何类型推断;定义 Theano 函数需要更精确地指定涉及的类型。
Theano 的真正力量来自于其对数组表达式的支持。使用 T.vector 函数可以定义一维向量;返回的变量支持与 NumPy 数组相同的广播操作语义。例如,我们可以取两个向量并计算它们平方的逐元素和,如下所示:
a = T.vector('a')
b = T.vector('b')
ab_sq = a**2 + b**2
compute_square = th.function([a, b], ab_sq)
compute_square([0, 1, 2], [3, 4, 5])
# Result:
# array([ 9., 17., 29.])
想法再次是使用 Theano API 作为迷你语言来组合各种 Numpy 数组表达式,这些表达式将被编译成高效的机器代码。
Theano 的一个卖点是其执行算术简化以及自动梯度计算的能力。有关更多信息,请参阅官方文档(deeplearning.net/software/theano/introduction.html)。
为了演示 Theano 在熟悉的使用场景中的功能,我们可以再次实现我们的π的并行计算。我们的函数将接受两个随机坐标的集合作为输入,并返回π的估计值。输入的随机数将被定义为名为x和y的向量,我们可以使用标准元素级操作来测试它们在圆内的位置,这些操作我们将存储在hit_test变量中:
x = T.vector('x')
y = T.vector('y')
hit_test = x ** 2 + y ** 2 < 1
在这个阶段,我们需要计算hit_test中True元素的数量,这可以通过取其和来完成(它将被隐式转换为整数)。为了获得π的估计值,我们最终需要计算击中次数与总试验次数的比率。计算过程在下面的代码块中展示:
hits = hit_test.sum()
total = x.shape[0]
pi_est = 4 * hits/total
我们可以使用th.function和timeit模块来基准测试 Theano 实现的执行。在我们的测试中,我们将传递两个大小为 30,000 的数组,并使用timeit.timeit实用程序多次执行calculate_pi函数:
calculate_pi = th.function([x, y], pi_est)
x_val = np.random.uniform(-1, 1, 30000)
y_val = np.random.uniform(-1, 1, 30000)
import timeit
res = timeit.timeit("calculate_pi(x_val, y_val)",
"from __main__ import x_val, y_val, calculate_pi", number=100000)
print(res)
# Output:
# 10.905971487998613
此函数的串行执行大约需要 10 秒。Theano 能够通过实现元素级和矩阵操作使用专门的包(如 OpenMP 和基本线性代数子程序(BLAS)线性代数例程)来自动并行化代码。可以通过配置选项启用并行执行。
在 Theano 中,你可以在导入时通过修改theano.config对象中的变量来设置配置选项。例如,你可以发出以下命令来启用 OpenMP 支持:
import theano
theano.config.openmp = True
theano.config.openmp_elemwise_minsize = 10
与 OpenMP 相关的参数如下:
-
openmp_elemwise_minsize:这是一个整数,表示应该启用元素级并行化的数组的最小大小(对于小数组,并行化的开销可能会损害性能) -
openmp:这是一个布尔标志,用于控制 OpenMP 编译的激活(默认情况下应该被激活)
通过在执行代码之前设置OMP_NUM_THREADS环境变量,可以控制分配给 OpenMP 执行的线程数量。
我们现在可以编写一个简单的基准测试来演示实际中的 OpenMP 使用。在一个名为test_theano.py的文件中,我们将放置π估计示例的完整代码:
# File: test_theano.py
import numpy as np
import theano.tensor as T
import theano as th
th.config.openmp_elemwise_minsize = 1000
th.config.openmp = True
x = T.vector('x')
y = T.vector('y')
hit_test = x ** 2 + y ** 2 <= 1
hits = hit_test.sum()
misses = x.shape[0]
pi_est = 4 * hits/misses
calculate_pi = th.function([x, y], pi_est)
x_val = np.random.uniform(-1, 1, 30000)
y_val = np.random.uniform(-1, 1, 30000)
import timeit
res = timeit.timeit("calculate_pi(x_val, y_val)",
"from __main__ import x_val, y_val,
calculate_pi", number=100000)
print(res)
在这个阶段,我们可以从命令行运行代码,并通过设置OMP_NUM_THREADS环境变量来评估线程数量增加时的扩展性:
$ OMP_NUM_THREADS=1 python test_theano.py
10.905971487998613
$ OMP_NUM_THREADS=2 python test_theano.py
7.538279129999864
$ OMP_NUM_THREADS=3 python test_theano.py
9.405846934998408
$ OMP_NUM_THREADS=4 python test_theano.py
14.634153957000308
有趣的是,当使用两个线程时,会有轻微的加速,但随着线程数量的增加,性能会迅速下降。这意味着对于这个输入大小,使用超过两个线程并不有利,因为启动新线程和同步共享数据所付出的代价高于从并行执行中获得的加速。
实现良好的并行性能可能相当棘手,因为它将取决于特定的操作以及它们如何访问底层数据。一般来说,衡量并行程序的性能至关重要,而获得显著的加速则是一项试错的工作。
例如,我们可以看到使用略微不同的代码,并行性能会迅速下降。在我们的击中测试中,我们直接使用了 sum 方法,并依赖于对 hit_tests 布尔数组的显式转换。如果我们进行显式转换,Theano 将生成略微不同的代码,从多个线程中获得的益处较少。我们可以修改 test_theano.py 文件来验证这一效果:
# Older version
# hits = hit_test.sum()
hits = hit_test.astype('int32').sum()
如果我们重新运行我们的基准测试,我们会看到线程数量对运行时间没有显著影响。尽管如此,与原始版本相比,时间有所显著改善:
$ OMP_NUM_THREADS=1 python test_theano.py
5.822126664999814
$ OMP_NUM_THREADS=2 python test_theano.py
5.697357518001809
$ OMP_NUM_THREADS=3 python test_theano.py
5.636914656002773
$ OMP_NUM_THREADS=4 python test_theano.py
5.764030176000233
分析 Theano
考虑到衡量和分析性能的重要性,Theano 提供了强大且信息丰富的分析工具。要生成分析数据,只需在 th.function 中添加 profile=True 选项即可:
calculate_pi = th.function([x, y], pi_est, profile=True)
分析器将在函数运行时收集数据(例如,通过 timeit 或直接调用)。可以通过发出 summary 命令将分析摘要打印到输出,如下所示:
calculate_pi.profile.summary()
要生成分析数据,我们可以在添加 profile=True 选项后重新运行我们的脚本(对于这个实验,我们将 OMP_NUM_THREADS 环境变量设置为 1)。此外,我们将我们的脚本恢复到执行 hit_tests 隐式转换的版本。
您也可以使用 config.profile 选项全局设置分析。
calculate_pi.profile.summary() 打印的输出相当长且信息丰富。其中一部分在下一块文本中报告。输出由三个部分组成,分别按 Class、Ops 和 Apply 排序。在我们的例子中,我们关注的是 Ops,它大致对应于 Theano 编译代码中使用的函数。正如你所见,大约 80% 的时间用于对两个数字进行逐元素平方和求和,其余时间用于计算总和:
Function profiling
==================
Message: test_theano.py:15
... other output
Time in 100000 calls to Function.__call__: 1.015549e+01s
... other output
Class
---
<% time> <sum %> <apply time> <time per call> <type> <#call> <#apply> <Class name>
.... timing info by class
Ops
---
<% time> <sum %> <apply time> <time per call> <type> <#call> <#apply> <Op name>
80.0% 80.0% 6.722s 6.72e-05s C 100000 1 Elemwise{Composite{LT((sqr(i0) + sqr(i1)), i2)}}
19.4% 99.4% 1.634s 1.63e-05s C 100000 1 Sum{acc_dtype=int64}
0.3% 99.8% 0.027s 2.66e-07s C 100000 1 Elemwise{Composite{((i0 * i1) / i2)}}
0.2% 100.0% 0.020s 2.03e-07s C 100000 1 Shape_i{0}
... (remaining 0 Ops account for 0.00%(0.00s) of the runtime)
Apply
------
<% time> <sum %> <apply time> <time per call> <#call> <id> <Apply name>
... timing info by apply
这些信息与我们第一次基准测试的结果一致。当使用两个线程时,代码从大约 11 秒减少到大约 8 秒。从这些数字中,我们可以分析时间是如何被花费的。
在这 11 秒中,80% 的时间(大约 8.8 秒)用于执行元素级操作。这意味着,在完全并行的情况下,增加两个线程将使速度提高 4.4 秒。在这种情况下,理论上的执行时间将是 6.6 秒。考虑到我们获得了大约 8 秒的计时,看起来线程使用有一些额外的开销(1.4 秒)。
Tensorflow
Tensorflow 是另一个用于快速数值计算和自动并行化的库。它于 2015 年由 Google 以开源项目形式发布。Tensorflow 通过构建类似于 Theano 的数学表达式来工作,但计算不是编译成机器代码,而是在用 C++ 编写的外部引擎上执行。Tensorflow 支持在一台或多台 CPU 和 GPU 上执行和部署并行代码。
Tensorflow 的使用方式与 Theano 非常相似。要在 Tensorflow 中创建一个变量,你可以使用 tf.placeholder 函数,该函数接受一个数据类型作为输入:
import tensorflow as tf
a = tf.placeholder('float64')
Tensorflow 的数学表达式可以非常类似于 Theano,除了命名约定略有不同以及对于 NumPy 语义的支持更加有限。
Tensorflow 并不像 Theano 那样将函数编译成 C 语言和机器代码,而是将定义的数学函数(包含变量和转换的数据结构称为 计算图)序列化,并在特定设备上执行。设备配置和上下文可以通过 tf.Session 对象来完成。
一旦定义了所需的表达式,就需要初始化一个 tf.Session,并可以使用 Session.run 方法来执行计算图。在以下示例中,我们展示了如何使用 Tensorflow API 实现一个简单的元素级平方和:
a = tf.placeholder('float64')
b = tf.placeholder('float64')
ab_sq = a**2 + b**2
with tf.Session() as session:
result = session.run(ab_sq, feed_dict={a: [0, 1, 2],
b: [3, 4, 5]})
print(result)
# Output:
# array([ 9., 17., 29.])
Tensorflow 的并行性是通过其智能执行引擎自动实现的,通常无需过多调整就能很好地工作。然而,请注意,它主要适用于涉及定义复杂函数(使用大量矩阵乘法并计算其梯度)的深度学习工作负载。
我们现在可以使用 Tensorflow 的功能来复制 pi 的估计示例,并对其执行速度和并行性进行基准测试,与 Theano 实现进行比较。我们将这样做:
-
定义我们的
x和y变量,并使用广播操作进行碰撞测试。 -
使用
tf.reduce_sum函数计算hit_tests的总和。 -
使用
inter_op_parallelism_threads和intra_op_parallelism_threads配置选项初始化一个Session对象。这些选项控制不同类别的并行操作使用的线程数。请注意,使用这些选项创建的第一个Session将设置整个脚本(甚至未来的Session实例)的线程数。
我们现在可以编写一个名为test_tensorflow.py的脚本,其中包含以下代码。请注意,线程数作为脚本的第一个参数传递(sys.argv[1]):
import tensorflow as tf
import numpy as np
import time
import sys
NUM_THREADS = int(sys.argv[1])
samples = 30000
print('Num threads', NUM_THREADS)
x_data = np.random.uniform(-1, 1, samples)
y_data = np.random.uniform(-1, 1, samples)
x = tf.placeholder('float64', name='x')
y = tf.placeholder('float64', name='y')
hit_tests = x ** 2 + y ** 2 <= 1.0
hits = tf.reduce_sum(tf.cast(hit_tests, 'int32'))
with tf.Session
(config=tf.ConfigProto
(inter_op_parallelism_threads=NUM_THREADS,
intra_op_parallelism_threads=NUM_THREADS)) as sess:
start = time.time()
for i in range(10000):
sess.run(hits, {x: x_data, y: y_data})
print(time.time() - start)
如果我们多次运行脚本,并使用不同的NUM_THREADS值,我们会看到性能与 Theano 相当,并且通过并行化提高的速度提升相当有限:
$ python test_tensorflow.py 1
13.059704780578613
$ python test_tensorflow.py 2
11.938535928726196
$ python test_tensorflow.py 3
12.783955574035645
$ python test_tensorflow.py 4
12.158143043518066
使用 Tensorflow 和 Theano 等软件包的主要优势是支持在机器学习算法中常用到的并行矩阵运算。这非常有效,因为这些操作可以在专门为以高吞吐量执行这些操作而设计的 GPU 硬件上实现令人印象深刻的性能提升。
在 GPU 上运行代码
在本小节中,我们将演示使用 Theano 和 Tensorflow 的 GPU 使用方法。作为一个例子,我们将基准测试 GPU 上非常简单的矩阵乘法执行时间,并将其与 CPU 上的运行时间进行比较。
本小节中的代码需要具备 GPU。为了学习目的,可以使用 Amazon EC2 服务(aws.amazon.com/ec2)来请求一个启用 GPU 的实例。
以下代码使用 Theano 执行简单的矩阵乘法。我们使用T.matrix函数初始化一个二维数组,然后使用T.dot方法执行矩阵乘法:
from theano import function, config
import theano.tensor as T
import numpy as np
import time
N = 5000
A_data = np.random.rand(N, N).astype('float32')
B_data = np.random.rand(N, N).astype('float32')
A = T.matrix('A')
B = T.matrix('B')
f = function([A, B], T.dot(A, B))
start = time.time()
f(A_data, B_data)
print("Matrix multiply ({}) took {} seconds".format(N, time.time() - start))
print('Device used:', config.device)
可以通过设置config.device=gpu选项让 Theano 在 GPU 上执行此代码。为了方便起见,我们可以使用THEANO_FLAGS环境变量从命令行设置配置值,如下所示。在将前面的代码复制到test_theano_matmul.py文件后,我们可以通过以下命令来基准测试执行时间:
$ THEANO_FLAGS=device=gpu python test_theano_gpu.py
Matrix multiply (5000) took 0.4182612895965576 seconds
Device used: gpu
我们可以使用device=cpu配置选项在 CPU 上类似地运行相同的代码:
$ THEANO_FLAGS=device=cpu python test_theano.py
Matrix multiply (5000) took 2.9623231887817383 seconds
Device used: cpu
如您所见,在这个例子中,GPU 比 CPU 版本快 7.2 倍!
为了进行比较,我们可以使用 Tensorflow 基准测试等效代码。Tensorflow 版本的实现将在下一个代码片段中报告。与 Theano 版本的主要区别如下:
-
tf.device配置管理器的使用,用于指定目标设备(/cpu:0或/gpu:0) -
矩阵乘法是通过
tf.matmul运算符来执行的:
import tensorflow as tf
import time
import numpy as np
N = 5000
A_data = np.random.rand(N, N)
B_data = np.random.rand(N, N)
# Creates a graph.
with tf.device('/gpu:0'):
A = tf.placeholder('float32')
B = tf.placeholder('float32')
C = tf.matmul(A, B)
with tf.Session() as sess:
start = time.time()
sess.run(C, {A: A_data, B: B_data})
print('Matrix multiply ({}) took: {}'.format(N, time.time() - start))
如果我们使用适当的tf.device选项运行test_tensorflow_matmul.py脚本,我们将获得以下计时结果:
# Ran with tf.device('/gpu:0')
Matrix multiply (5000) took: 1.417285680770874
# Ran with tf.device('/cpu:0')
Matrix multiply (5000) took: 2.9646761417388916
如您所见,在这个简单案例中,性能提升相当显著(但不如 Theano 版本好)。
实现自动 GPU 计算的另一种方法是现在熟悉的 Numba。使用 Numba,可以将 Python 代码编译成可以在 GPU 上运行的程序。这种灵活性允许进行高级 GPU 编程以及更简化的接口。特别是,Numba 使得编写 GPU 就绪的通用函数变得极其简单。
在下一个示例中,我们将演示如何编写一个通用函数,该函数对两个数字应用指数函数并求和结果。正如我们已经在第五章中看到的,探索编译器,这可以通过使用nb.vectorize函数(我们还将明确指定cpu目标)来实现:
import numba as nb
import math
@nb.vectorize(target='cpu')
def expon_cpu(x, y):
return math.exp(x) + math.exp(y)
使用target='cuda'选项可以将expon_cpu通用函数编译为 GPU 设备。此外,请注意,对于 CUDA 通用函数,必须指定输入类型。expon_gpu的实现如下:
@nb.vectorize(['float32(float32, float32)'], target='cuda')
def expon_gpu(x, y):
return math.exp(x) + math.exp(y)
我们现在可以通过在两个大小为 1,000,000 的数组上应用这两个函数来基准测试这两个函数的执行。请注意,我们在测量时间之前执行函数以触发 Numba 即时编译:
import numpy as np
import time
N = 1000000
niter = 100
a = np.random.rand(N).astype('float32')
b = np.random.rand(N).astype('float32')
# Trigger compilation
expon_cpu(a, b)
expon_gpu(a, b)
# Timing
start = time.time()
for i in range(niter):
expon_cpu(a, b)
print("CPU:", time.time() - start)
start = time.time()
for i in range(niter):
expon_gpu(a, b)
print("GPU:", time.time() - start)
# Output:
# CPU: 2.4762887954711914
# GPU: 0.8668839931488037
多亏了 GPU 执行,我们能够将 CPU 版本的速度提高 3 倍。请注意,在 GPU 上传输数据相当昂贵;因此,只有对于非常大的数组,GPU 执行才具有优势。
摘要
并行处理是提高大数据集性能的有效方法。令人尴尬的并行问题是非常好的并行执行候选者,可以轻松实现以实现良好的性能扩展。
在本章中,我们介绍了 Python 并行编程的基础。我们学习了如何通过使用 Python 标准库中的工具来生成进程来规避 Python 线程限制。我们还探讨了如何使用 Cython 和 OpenMP 实现多线程程序。
对于更复杂的问题,我们学习了如何使用 Theano、Tensorflow 和 Numba 包自动编译针对 CPU 和 GPU 设备并行执行的密集数组表达式。
在下一章中,我们将学习如何使用 dask 和 PySpark 等库在多个处理器和机器上编写和执行并行程序。
第八章:分布式处理
在上一章中,我们介绍了并行处理的概念,并学习了如何利用多核处理器和 GPU。现在,我们可以将游戏提升到一个新的水平,并将注意力转向分布式处理,这涉及到在多台机器上执行任务以解决特定问题。
在本章中,我们将说明在计算机集群上运行代码的挑战、用例和示例。Python 提供了易于使用且可靠的分布式处理包,这将使我们能够相对容易地实现可扩展和容错代码。
本章的主题列表如下:
-
分布式计算和 MapReduce 模型
-
使用 Dask 的定向无环图
-
使用 Dask 的
array、Bag和DataFrame数据结构编写并行代码 -
使用 Dask Distributed 分发并行算法
-
PySpark 简介
-
Spark 的弹性分布式数据集和 DataFrame
-
使用
mpi4py进行科学计算
分布式计算简介
在当今世界,计算机、智能手机和其他设备已成为我们生活的重要组成部分。每天,都会产生大量的数据。数十亿人通过互联网访问服务,公司不断收集数据以了解他们的用户,以便更好地定位产品和提升用户体验。
处理这日益增长的大量数据带来了巨大的挑战。大型公司和组织通常构建机器集群,用于存储、处理和分析大型且复杂的数据集。在数据密集型领域,如环境科学和医疗保健,也产生了类似的数据库。这些大规模数据集最近被称为大数据。应用于大数据的分析技术通常涉及机器学习、信息检索和可视化的结合。
计算集群在科学计算中已经使用了数十年,在研究复杂问题时需要使用在高性能分布式系统上执行的并行算法。对于此类应用,大学和其他组织提供并管理超级计算机用于研究和工程目的。在超级计算机上运行的应用通常专注于高度数值化的工作负载,如蛋白质和分子模拟、量子力学计算、气候模型等。
为分布式系统编程的挑战显而易见,如果我们回顾一下,随着我们将数据和计算任务分散到本地网络中,通信成本是如何增加的。与处理器速度相比,网络传输极其缓慢,在使用分布式处理时,保持网络通信尽可能有限尤为重要。这可以通过使用一些不同的策略来实现,这些策略有利于本地数据处理,并且仅在绝对必要时才进行数据传输。
分布式处理的其他挑战包括计算机网络的一般不可靠性。当你想到在一个计算集群中可能有数千台机器时,很明显(从概率上讲),故障节点变得非常普遍。因此,分布式系统需要能够优雅地处理节点故障,而不会干扰正在进行的工作。幸运的是,公司已经投入了大量资源来开发容错分布式引擎,这些引擎可以自动处理这些方面。
MapReduce 简介
MapReduce是一种编程模型,允许你在分布式系统上高效地表达算法。MapReduce 模型最早由 Google 在 2004 年提出(research.google.com/archive/mapreduce.html),作为一种在多台机器上自动分区数据集、自动本地处理以及集群节点之间通信的方法。
MapReduce 框架与分布式文件系统Google 文件系统(GFS 或 GoogleFS)合作使用,该系统旨在将数据分区并复制到计算集群中。分区对于存储和处理无法适应单个节点的数据集很有用,而复制确保系统能够优雅地处理故障。Google 使用 MapReduce 与 GFS 一起对他们的网页进行索引。后来,MapReduce 和 GFS 概念由 Doug Cutting(当时是雅虎的员工)实现,产生了Hadoop 分布式文件系统(HDFS)和 Hadoop MapReduce 的第一个版本。
MapReduce 暴露的编程模型实际上相当简单。其思想是将计算表达为两个相当通用的步骤的组合:Map和Reduce。一些读者可能熟悉 Python 的map和reduce函数;然而,在 MapReduce 的上下文中,Map 和 Reduce 步骤能够表示更广泛的操作类别。
Map 以一组数据作为输入,并对这些数据进行转换。Map 通常输出一系列键值对,这些键值对可以被传递到 Reduce 步骤。Reduce 步骤将具有相同键的项聚合起来,并对集合应用一个函数,形成一个通常更小的值集合。
在上一章中展示的π的估计可以通过一系列 Map 和 Reduce 步骤轻松转换。在这种情况下,输入是一系列随机数的对。转换(Map 步骤)是击中测试,而 Reduce 步骤是计算击中测试为 True 的次数。
MapReduce 模型的典型示例是实现词频统计;程序接受一系列文档作为输入,并返回每个单词在文档集合中的总出现次数。以下图展示了词频统计程序的 Map 和 Reduce 步骤。在左侧,我们有输入文档。Map 操作将生成一个(键,值)条目,其中第一个元素是单词,第二个元素是 1(这是因为每个单词都对最终计数贡献了 1)。
然后我们执行 reduce 操作,聚合相同键的所有元素,并为每个单词生成全局计数。在图中,我们可以看到所有键为 the 的项的值是如何相加以生成最终的条目(the, 4):

如果我们使用 Map 和 Reduce 操作实现我们的算法,框架实现将确保通过限制节点之间的通信通过巧妙的算法来高效地完成数据生产和聚合。
然而,MapReduce 是如何将通信保持在最低限度的?让我们回顾一下 MapReduce 任务的旅程。想象一下,你有一个包含两个节点的集群,数据分区(这通常在每个节点本地找到)从磁盘加载到每个节点,并准备好处理。在每个节点上创建了一个 mapper 进程,并处理数据以生成中间结果。
接下来,有必要将数据发送到 reducer 进行进一步处理。然而,为了做到这一点,所有具有相同键的项都必须发送到同一个 reducer。这个操作称为 洗牌,是 MapReduce 模型中的主要通信任务:

注意,在数据交换发生之前,有必要将键的子集分配给每个 reducer;这一步称为 分区。一旦 reducer 收到其自己的键分区,它就可以自由地处理数据并在磁盘上写入结果输出。
MapReduce 框架(通过 Apache Hadoop 项目)在其原始形式下已被许多公司和组织广泛使用。最近,一些新的框架被开发出来,以扩展 MapReduce 引入的思想,以创建能够表达更复杂工作流程、更有效地使用内存并支持瘦型和高效分布式任务执行的系统。
在接下来的章节中,我们将描述 Python 分布式领域中两个最常用的库:Dask 和 PySpark。
Dask
Dask 是 Continuum Analytics(负责 Numba 和 conda 软件包管理器的同一家公司)的一个项目,是一个用于并行和分布式计算的纯 Python 库。它在执行数据分析任务方面表现出色,并且与 Python 生态系统紧密结合。
Dask 最初被构想为一个用于单机内存外计算的包。最近,随着 Dask Distributed 项目的推出,其代码已被调整以在具有出色性能和容错能力的集群上执行任务。它支持 MapReduce 风格的任务以及复杂的数值算法。
有向无环图
Dask 背后的理念与我们已经在上一章中看到的 Theano 和 Tensorflow 非常相似。我们可以使用熟悉的 Pythonic API 来构建执行计划,而框架将自动将工作流程拆分成将在多个进程或计算机上传输和执行的任务。
Dask 将它的变量和操作表示为一个有向无环图(DAG),可以通过一个简单的 Python 字典来表示。为了简要说明这是如何工作的,我们将使用 Dask 实现两个数的和。我们将通过在字典中存储输入变量的值来定义我们的计算图。输入变量a和b将被赋予值2:
dsk = {
"a" : 2,
"b" : 2,
}
每个变量代表 DAG 中的一个节点。构建我们的 DAG 的下一步是执行我们刚刚定义的节点上的操作。在 Dask 中,一个任务可以通过在dsk字典中放置一个包含 Python 函数及其位置参数的元组来定义。为了实现求和,我们可以添加一个新的节点,命名为result(实际名称完全任意),包含我们打算执行的函数,后跟其参数。以下代码展示了这一点:
dsk = {
"a" : 2,
"b" : 2,
"result": (lambda x, y: x + y, "a", "b")
}
为了更好的风格和清晰度,我们可以通过替换lambda语句为标准的operator.add库函数来计算和:
from operator import add
dsk = {
"a" : 2,
"b" : 2,
"result": (add, "a", "b")
}
需要注意的是,我们打算传递给函数的参数是"a"和"b"字符串,它们指的是图中的a和b节点。请注意,我们没有使用任何 Dask 特定的函数来定义 DAG;这是框架灵活和精简的第一个迹象,因为所有操作都是在简单且熟悉的 Python 字典上进行的。
任务执行由调度器完成,调度器是一个函数,它接受一个 DAG 以及我们想要执行的任务或任务列表,并返回计算值。默认的 Dask 调度器是dask.get函数,可以使用以下方式使用:
import dask
res = dask.get(dsk, "result")
print(res)
# Output:
# 4
所有复杂性都隐藏在调度器后面,调度器将负责将任务分配到线程、进程甚至不同的机器上。dask.get调度器是一个同步和串行实现,适用于测试和调试目的。
使用简单的字典定义图对于理解 Dask 如何施展魔法以及用于调试目的非常有用。原始字典也可以用来实现 Dask API 未涵盖的更复杂算法。现在,我们将学习 Dask 如何通过熟悉的 NumPy 和 Pandas-like 接口自动生成任务。
Dask 数组
Dask 的主要用例之一是自动生成并行数组操作,这极大地简化了处理无法装入内存的数组。Dask 采取的策略是将数组分割成多个子单元,在 Dask 数组术语中,这些子单元被称为 chunks。
Dask 在 dask.array 模块(我们将简称为 da)中实现了一个类似于 NumPy 的数组接口。可以使用 da.from_array 函数从一个类似于 NumPy 的数组创建数组,该函数需要指定块大小。da.from_array 函数将返回一个 da.array 对象,该对象将处理将原始数组分割成指定块大小的子单元。在以下示例中,我们创建了一个包含 30 个元素的数组,并将其分割成每个块包含 10 个元素的块:
import numpy as np
import dask.array as da
a = np.random.rand(30)
a_da = da.from_array(a, chunks=10)
# Result:
# dask.array<array-4..., shape=(30,), dtype=float64, chunksize=(10,)>
a_da 变量维护一个 Dask 图,可以通过 dask 属性访问。为了了解 Dask 在底层做了什么,我们可以检查其内容。在以下示例中,我们可以看到 Dask 图包含四个节点。其中一个是源数组,用 'array-original-4c76' 键表示,a_da.dask 字典中的其他三个键是用于使用 dask.array.core.getarray 函数访问原始数组子块的任务,如您所见,每个任务提取了 10 个元素的一个切片:
dict(a_da.dask)
# Result
{('array-4c76', 0): (<function dask.array.core.getarray>,
'array-original-4c76',
(slice(0, 10, None),)),
('array-4c76', 2): (<function dask.array.core.getarray>,
'array-original-4c76',
(slice(20, 30, None),)),
('array-4c76', 1): (<function dask.array.core.getarray>,
'array-original-4c76',
(slice(10, 20, None),)),
'array-original-4c76': array([ ... ])
}
如果我们在 a_da 数组上执行操作,Dask 将生成更多子任务来操作更小的子单元,从而打开实现并行化的可能性。da.array 暴露的接口与常见的 NumPy 语义和广播规则兼容。以下代码展示了 Dask 与 NumPy 广播规则、逐元素操作和其他方法的良好兼容性:
N = 10000
chunksize = 1000
x_data = np.random.uniform(-1, 1, N)
y_data = np.random.uniform(-1, 1, N)
x = da.from_array(x_data, chunks=chunksize)
y = da.from_array(y_data, chunks=chunksize)
hit_test = x ** 2 + y ** 2 < 1
hits = hit_test.sum()
pi = 4 * hits / N
可以使用 compute 方法计算 π 的值,也可以通过 get 可选参数来指定不同的调度器(默认情况下,da.array 使用多线程调度器):
pi.compute() # Alternative: pi.compute(get=dask.get)
# Result:
# 3.1804000000000001
即使是表面上简单的算法,如 π 的估计,也可能需要执行大量任务。Dask 提供了可视化计算图的工具。以下图显示了用于估计 π 的 Dask 图的一部分,可以通过执行 pi.visualize() 方法获得。在图中,圆形代表应用于节点的转换,节点以矩形表示。这个例子帮助我们了解 Dask 图的复杂性,并欣赏调度器创建高效执行计划的工作,包括正确排序任务和选择并行执行的任务:

Dask Bag 和 DataFrame
Dask 提供了其他数据结构用于自动生成计算图。在本小节中,我们将探讨 dask.bag.Bag,这是一个通用的元素集合,可用于编写 MapReduce 风格的算法,以及 dask.dataframe.DataFrame,它是 pandas.DataFrame 的分布式版本。
一个 Bag 可以很容易地从 Python 集合中创建。例如,您可以使用 from_sequence 工厂函数从一个列表中创建一个 Bag。可以使用 npartitions 参数指定并行级别(这将把 Bag 内容分布到多个分区中)。在以下示例中,我们创建了一个包含从 0 到 99 的数字的 Bag,分为四个块:
import dask.bag as dab
dab.from_sequence(range(100), npartitions=4)
# Result:
# dask.bag<from_se..., npartitions=4>
在下一个示例中,我们将演示如何使用类似于 MapReduce 的算法对一组字符串进行词频统计。给定我们的序列集合,我们应用 str.split,然后使用 concat 获取文档中的线性单词列表。然后,对于每个单词,我们生成一个包含单词和值 1 的字典(有关说明,请参阅 MapReduce 简介 部分)。然后,我们使用 foldby 操作符编写一个 Reduce 步骤来计算词频。
foldby 转换对于实现不需要在网络中重新排序所有元素即可合并词频的 Reduce 步骤非常有用。想象一下,我们的单词数据集被分为两个分区。计算总计数的一个好策略是首先计算每个分区的单词出现次数之和,然后将这些部分和组合起来得到最终结果。以下图示说明了这个概念。在左侧,我们有我们的输入分区。每个单独分区计算部分和(这是使用二进制操作 binop 完成的),然后通过使用 combine 函数组合部分和来计算最终总和。

以下代码说明了如何使用 Bag 和 foldby 操作符来计算词频。对于 foldby 操作符,我们需要定义两个函数,它们接受五个参数:
-
key: 这是一个返回 reduce 操作键的函数。 -
binop: 这是一个接受两个参数的函数:total和x。给定一个total值(到目前为止累积的值),binop将下一个项目合并到总和中。 -
initial: 这是binop累积的初始值。 -
combine: 这是一个将每个分区的总和合并的函数(在这种情况下是一个简单的求和)。 -
initial_combine: 这是combine累积的初始值。
现在,让我们看看代码:
collection = dab.from_sequence(["the cat sat on the mat",
"the dog sat on the mat"], npartitions=2)
binop = lambda total, x: total + x["count"]
combine = lambda a, b: a + b
(collection
.map(str.split)
.concat()
.map(lambda x: {"word": x, "count": 1})
.foldby(lambda x: x["word"], binop, 0, combine, 0)
.compute())
# Output:
# [('dog', 1), ('cat', 1), ('sat', 2), ('on', 2), ('mat', 2), ('the', 4)]
正如我们刚才看到的,使用Bag以有效的方式表达复杂操作可能会变得繁琐。因此,Dask 提供另一种数据结构,专为分析工作负载设计--dask.dataframe.DataFrame。DataFrame可以在 Dask 中使用多种方法初始化,例如从分布式文件系统上的CSV文件,或直接从Bag。就像da.array提供了一个与 NumPy 功能紧密相似的 API 一样,Dask DataFrame可以用作pandas.DataFrame的分布式版本。
作为演示,我们将使用DataFrame重新实现词频。我们首先加载数据以获得一个单词的Bag,然后使用to_dataframe方法将Bag转换为DataFrame。通过将列名传递给to_dataframe方法,我们可以初始化一个DataFrame,它包含一个名为words的单列:
collection = dab.from_sequence(["the cat sat on the mat",
"the dog sat on the mat"], npartitions=2)
words = collection.map(str.split).concat()
df = words.to_dataframe(['words'])
df.head()
# Result:
# words
# 0 the
# 1 cat
# 2 sat
# 3 on
# 4 the
Dask DataFrame紧密复制了pandas.DataFrame API。要计算词频,我们只需在单词列上调用value_counts方法,Dask 将自动设计一个并行计算策略。要触发计算,只需调用compute方法:
df.words.value_counts().compute()
# Result:
# the 4
# sat 2
# on 2
# mat 2
# dog 1
# cat 1
# Name: words, dtype: int64
一个可能的问题是一个人可能会问:“DataFrame 底层使用的是哪种算法?”。答案可以通过查看生成的 Dask 图的顶部来找到,该图如下所示。底部的前两个矩形代表数据集的两个分区,它们存储为两个pd.Series实例。为了计算总数,Dask 将首先在每个pd.Series上执行value_counts,然后结合value_counts_aggregate步骤:

如您所见,Dask array和DataFrame都利用了 NumPy 和 Pandas 的快速向量化实现,以实现卓越的性能和稳定性。
Dask 分布式
Dask 项目的最初迭代是为了在单台计算机上运行而设计的,使用基于线程或进程的调度器。最近,新分布式后端的实现可以用来在计算机网络上设置和运行 Dask 图。
Dask 分布式不是与 Dask 自动安装的。该库可以通过conda包管理器(使用$ conda install distributed命令)以及pip(使用$ pip install distributed命令)获得。
开始使用 Dask 分布式实际上非常简单。最基本的设置是通过实例化一个Client对象来获得的:
from dask.distributed import Client
client = Client()
# Result:
# <Client: scheduler='tcp://127.0.0.1:46472' processes=4 cores=4>
默认情况下,Dask 将通过Client实例启动一些关键进程(在本地机器上),这些进程对于调度和执行分布式任务是必要的。Dask 集群的主要组件是一个单一的scheduler和一组workers。
调度器是负责在工作器之间分配工作并监控和管理结果的进程。通常,当任务被提交给用户时,调度器会找到一个空闲的工作器并提交一个任务以供执行。一旦工作器完成,调度器就会被告知结果已可用。
工作器是一个接受传入任务并产生结果的进程。工作器可以驻留在网络上的不同机器上。工作器使用ThreadPoolExecutor执行任务。这可以用来在不需要获取 GIL(例如,在nogil块中的 Numpy、Pandas 和 Cython 函数)的函数中使用并行性。当执行纯 Python 代码时,启动许多单线程工作器进程是有利的,因为这将为获取 GIL 的代码启用并行性。
Client类可以用来使用熟悉的异步方法手动将任务提交给调度器。例如,为了在集群上执行一个函数,可以使用Client.map和Client.submit方法。在下面的代码中,我们展示了如何使用Client.map和Client.submit来计算几个数字的平方。Client将向调度器提交一系列任务,我们将为每个任务接收一个Future实例:
def square(x):
return x ** 2
fut = client.submit(square, 2)
# Result:
# <Future: status: pending, key: square-05236e00d545104559e0cd20f94cd8ab>
client.map(square)
futs = client.map(square, [0, 1, 2, 3, 4])
# Result:
# [<Future: status: pending, key: square-d043f00c1427622a694f518348870a2f>,
# <Future: status: pending, key: square-9352eac1fb1f6659e8442ca4838b6f8d>,
# <Future: status: finished, type: int, key:
# square-05236e00d545104559e0cd20f94cd8ab>,
# <Future: status: pending, key:
# square-c89f4c21ae6004ce0fe5206f1a8d619d>,
# <Future: status: pending, key:
# square-a66f1c13e2a46762b092a4f2922e9db9>]
到目前为止,这与我们在前几章中看到的TheadPoolExecutor和ProcessPoolExecutor非常相似。然而,请注意,Dask Distributed 不仅提交任务,还将在工作器内存中缓存计算结果。您可以通过查看前面的代码示例来看到缓存的作用。当我们第一次调用client.submit时,square(2)任务被创建,其状态设置为待处理。当我们随后调用client.map时,square(2)任务被重新提交给调度器,但这次,调度器不是重新计算其值,而是直接从工作器检索结果。因此,map 返回的第三个Future已经处于完成状态。
可以使用Client.gather方法检索来自Future实例集合的结果:
client.gather(futs)
# Result:
# [0, 1, 4, 9, 16]
Client也可以用来运行任意的 Dask 图。例如,我们可以通过将client.get函数作为可选参数传递给pi.compute来简单地运行我们的π近似值:
pi.compute(get=client.get)
这个特性使得 Dask 具有极高的可扩展性,因为它可以在本地机器上使用其中一个较简单的调度器开发和运行算法,如果性能不满意,还可以在由数百台机器组成的集群上重用相同的算法。
手动集群设置
要手动实例化调度器和工作者,可以使用dask-scheduler和dask-worker命令行工具。首先,我们可以使用dask-scheduler命令初始化调度器:
$ dask-scheduler
distributed.scheduler - INFO - -----------------------------------------------
distributed.scheduler - INFO - Scheduler at: tcp://192.168.0.102:8786
distributed.scheduler - INFO - bokeh at: 0.0.0.0:8788
distributed.scheduler - INFO - http at: 0.0.0.0:9786
distributed.bokeh.application - INFO - Web UI: http://127.0.0.1:8787/status/
distributed.scheduler - INFO - -----------------------------------------------
这将为调度器提供一个地址和一个可以用来监控集群状态的 Web UI 地址。现在,我们可以将一些工作线程分配给调度器;这可以通过使用 dask-worker 命令并将调度器的地址传递给工作线程来实现。这将自动启动一个拥有四个线程的工作线程:
$ dask-worker 192.168.0.102:8786
distributed.nanny - INFO - Start Nanny at: 'tcp://192.168.0.102:45711'
distributed.worker - INFO - Start worker at: tcp://192.168.0.102:45928
distributed.worker - INFO - bokeh at: 192.168.0.102:8789
distributed.worker - INFO - http at: 192.168.0.102:46154
distributed.worker - INFO - nanny at: 192.168.0.102:45711
distributed.worker - INFO - Waiting to connect to: tcp://192.168.0.102:8786
distributed.worker - INFO - -------------------------------------------------
distributed.worker - INFO - Threads: 4
distributed.worker - INFO - Memory: 4.97 GB
distributed.worker - INFO - Local Directory: /tmp/nanny-jh1esoo7
distributed.worker - INFO - -------------------------------------------------
distributed.worker - INFO - Registered to: tcp://192.168.0.102:8786
distributed.worker - INFO - -------------------------------------------------
distributed.nanny - INFO - Nanny 'tcp://192.168.0.102:45711' starts worker process 'tcp://192.168.0.102:45928'
Dask 调度器在容错方面相当强大,这意味着如果我们添加和删除一个工作线程,它能够追踪哪些结果不可用,并按需重新计算它们。最后,为了从 Python 会话中使用初始化的调度器,只需初始化一个 Client 实例并提供调度器的地址即可:
client = Client(address='192.168.0.102:8786')
# Result:
# <Client: scheduler='tcp://192.168.0.102:8786' processes=1 cores=4>
Dask 还提供了一个方便的诊断 Web UI,可以用来监控集群上每个任务的状态和耗时。在下一张图中,任务流显示了执行 pi 估计所花费的时间。在图表中,每一条水平灰色线对应一个工作线程(在我们的例子中,我们有一个拥有四个线程的工作线程,也称为工作核心),每个矩形框对应一个任务,颜色相同以表示相同的任务类型(例如,加法、幂或指数)。从这个图表中,你可以观察到所有方块都非常小且彼此距离很远。这意味着与通信开销相比,任务相当小。
在这种情况下,块大小的增加,意味着与通信时间相比,每个任务运行所需的时间增加,这将是有益的。

使用 PySpark
现在,Apache Spark 是分布式计算中最受欢迎的项目之一。Spark 使用 Scala 开发,于 2014 年发布,与 HDFS 集成,并在 Hadoop MapReduce 框架之上提供了几个优势和改进。
与 Hadoop MapReduce 不同,Spark 被设计为可以交互式处理数据,并支持 Java、Scala 和 Python 编程语言的 API。鉴于其不同的架构,特别是 Spark 将结果保留在内存中的事实,Spark 通常比 Hadoop MapReduce 快得多。
设置 Spark 和 PySpark
从头开始设置 PySpark 需要安装 Java 和 Scala 运行时,从源代码编译项目,并配置 Python 和 Jupyter notebook,以便它们可以与 Spark 安装一起使用。设置 PySpark 的一个更简单且错误更少的方法是使用通过 Docker 容器提供的已配置 Spark 集群。
您可以从 www.docker.com/ 下载 Docker。如果您对容器还不太熟悉,可以阅读下一章以获取介绍。
要设置 Spark 集群,只需进入本章的代码文件(其中有一个名为 Dockerfile 的文件)并执行以下命令:
$ docker build -t pyspark
此命令将自动在隔离环境中下载、安装和配置 Spark、Python 和 Jupyter 笔记本。要启动 Spark 和 Jupyter 笔记本会话,您可以执行以下命令:
$ docker run -d -p 8888:8888 -p 4040:4040 pyspark
22b9dbc2767c260e525dcbc562b84a399a7f338fe1c06418cbe6b351c998e239
命令将打印一个唯一的 ID(称为容器 ID),您可以使用它来引用应用程序容器,并将 Spark 和 Jupyter 笔记本在后台启动。-p选项确保我们可以从本地机器访问 SparkUI 和 Jupyter 网络端口。在发出命令后,您可以通过打开浏览器访问http://127.0.0.1:8888来访问 Jupyter 笔记本会话。您可以通过创建一个新的笔记本并在单元格中执行以下内容来测试 Spark 的正确初始化:
import pyspark
sc = pyspark.SparkContext('local[*]')
rdd = sc.parallelize(range(1000))
rdd.first()
# Result:
# 0
这将初始化一个SparkContext并获取集合中的第一个元素(这些新术语将在稍后详细解释)。一旦SparkContext初始化完成,我们还可以访问127.0.0.1:4040来打开 Spark Web UI。
现在设置完成,我们将了解 Spark 的工作原理以及如何使用其强大的 API 实现简单的并行算法。
Spark 架构
Spark 集群是在不同机器上分布的一组进程。驱动程序是一个进程,例如 Scala 或 Python 解释器,用户用它来提交要执行的任务。
用户可以使用特殊的 API 构建类似于 Dask 的任务图,并将这些任务提交给集群管理器,该管理器负责将这些任务分配给执行器,即负责执行任务的进程。在多用户系统中,集群管理器还负责按用户分配资源。
用户通过驱动程序与集群管理器交互。负责用户与 Spark 集群之间通信的类被称为SparkContext。这个类能够根据用户可用的资源连接和配置集群上的执行器。
对于其最常见的使用场景,Spark 通过一种称为弹性分布式数据集(RDD)的数据结构来管理其数据,它表示一组项目。RDDs 通过将它们的元素分割成分区并在并行操作这些分区(注意,这种机制主要对用户隐藏)来处理大规模数据集。RDDs 还可以存储在内存中(可选,且在适当的时候)以实现快速访问和缓存昂贵的中间结果。
使用 RDDs,可以定义任务和转换(类似于我们在 Dask 中自动生成计算图的方式),当请求时,集群管理器将自动将任务调度和执行到可用的执行器上。
执行器将从集群管理器接收任务,执行它们,并在需要时保留结果。请注意,一个执行器可以有多个核心,集群中的每个节点可能有多个执行器。一般来说,Spark 对执行器的故障具有容错性。
在以下图中,我们展示了上述组件如何在 Spark 集群中交互。驱动程序与集群管理器交互,集群管理器负责管理不同节点上的执行器实例(每个执行器实例也可以有多个线程)。请注意,即使驱动程序不直接控制执行器,存储在执行器实例中的结果也会直接在执行器和驱动程序之间传输。因此,确保驱动程序可以从执行器进程网络访问是很重要的:

一个自然的问题就是:Spark,一个用 Scala 编写的软件,是如何执行 Python 代码的?集成是通过Py4J库完成的,该库在底层维护一个 Python 进程并通过套接字(一种进程间通信形式)与之通信。为了运行任务,执行器维护一系列 Python 进程,以便它们能够并行处理 Python 代码。
驱动程序程序中的 Python 进程定义的 RDD 和变量会被序列化,集群管理器和执行器之间的通信(包括洗牌)由 Spark 的 Scala 代码处理。Python 和 Scala 之间交换所需的额外序列化步骤,所有这些都增加了通信的开销;因此,当使用 PySpark 时,必须格外小心,确保使用的结构被有效地序列化,并且数据分区足够大,以便通信的成本与执行的成本相比可以忽略不计。
以下图展示了 PySpark 执行所需的额外 Python 进程。这些额外的 Python 进程伴随着相关的内存成本和额外的间接层,这增加了错误报告的复杂性:

尽管有这些缺点,PySpark 仍然是一个广泛使用的工具,因为它将生动的 Python 生态系统与 Hadoop 基础设施的工业强度连接起来。
弹性分布式数据集
在 Python 中创建 RDD 的最简单方法是使用SparkContext.parallelize方法。这个方法之前也被用来并行化一个介于0和1000之间的整数集合:
rdd = sc.parallelize(range(1000))
# Result:
# PythonRDD[3] at RDD at PythonRDD.scala:48
rdd集合将被分成多个分区,在这种情况下,对应于默认值四(默认值可以通过配置选项更改)。要显式指定分区数,可以向parallelize传递额外的参数:
rdd = sc.parallelize(range(1000), 2)
rdd.getNumPartitions() # This function will return the number of partitions
# Result:
# 2
RDD 支持许多函数式编程操作符,类似于我们在第六章“实现并发”中使用的,与反应式编程和数据流(尽管在这种情况下,操作符是为处理随时间推移的事件而不是普通集合而设计的)。例如,我们可以展示基本的map函数,到现在应该已经很熟悉了。在以下代码中,我们使用map来计算一系列数字的平方:
square_rdd = rdd.map(lambda x: x**2)
# Result:
# PythonRDD[5] at RDD at PythonRDD.scala:48
map函数将返回一个新的 RDD,但不会立即计算任何内容。为了触发执行,你可以使用collect方法,它将检索集合中的所有元素,或者使用take,它将只返回前十个元素:
square_rdd.collect()
# Result:
# [0, 1, ... ]
square_rdd.take(10)
# Result:
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
为了比较 PySpark、Dask 以及我们在前几章中探索的其他并行编程库,我们将重新实现π的近似值。在 PySpark 实现中,我们首先使用parallelize创建两个包含随机数的 RDD,然后使用zip函数(这相当于 Python 的zip)合并数据集,最后测试随机点是否在圆内:
import numpy as np
N = 10000
x = np.random.uniform(-1, 1, N)
y = np.random.uniform(-1, 1, N)
rdd_x = sc.parallelize(x)
rdd_y = sc.parallelize(y)
hit_test = rdd_x.zip(rdd_y).map(lambda xy: xy[0] ** 2 + xy[1] ** 2 < 1)
pi = 4 * hit_test.sum()/N
需要注意的是,zip和map操作都会生成新的 RDD,实际上并不在底层数据上执行指令。在先前的例子中,代码执行是在我们调用hit_test.sum函数时触发的,该函数返回一个整数。这种行为与 Dask API 不同,在 Dask API 中,整个计算(包括最终结果pi)并没有触发执行。
现在我们可以继续到一个更有趣的应用,以展示更多的 RDD 方法。我们将学习如何计算网站每个用户每天访问的次数。在现实世界的场景中,数据已经被收集到数据库中,或者存储在分布式文件系统,如 HDFS 中。然而,在我们的例子中,我们将生成一些数据,然后进行分析。
在以下代码中,我们生成一个包含字典的列表,每个字典包含一个user(从二十个用户中选择)和一个timestamp。生成数据集的步骤如下:
-
创建一个包含 20 个用户的池(
users变量)。 -
定义一个函数,该函数返回两个日期之间的随机时间。
-
对于 10,000 次,我们从
users池中随机选择一个用户,并从 2017 年 1 月 1 日到 2017 年 1 月 7 日之间的随机时间戳。
import datetime
from uuid import uuid4
from random import randrange, choice
# We generate 20 users
n_users = 20
users = [uuid4() for i in range(n_users)]
def random_time(start, end):
'''Return a random timestamp between start date and end
date'''
# We select a number of seconds
total_seconds = (end - start).total_seconds()
return start +
datetime.timedelta(seconds=randrange(total_seconds))
start = datetime.datetime(2017, 1, 1)
end = datetime.datetime(2017, 1, 7)
entries = []
N = 10000
for i in range(N):
entries.append({
'user': choice(users),
'timestamp': random_time(start, end)
})
使用手头的数据集,我们可以开始提问并使用 PySpark 来找到答案。一个常见的问题是“给定用户访问网站了多少次?”。计算这个结果的一个简单方法是通过使用 groupBy 操作符按用户分组(使用 groupBy 操作符)并计算每个用户有多少项。在 PySpark 中,groupBy 接收一个函数作为参数,用于提取每个元素的分组键,并返回一个新的 RDD,其中包含 (key, group) 形式的元组。在下面的示例中,我们使用用户 ID 作为 groupBy 的键,并使用 first 检查第一个元素:
entries_rdd = sc.parallelize(entries)
entries_rdd.groupBy(lambda x: x['user']).first()
# Result:
# (UUID('0604aab5-c7ba-4d5b-b1e0-16091052fb11'),
# <pyspark.resultiterable.ResultIterable at 0x7faced4cd0b8>)
groupBy 的返回值包含一个 ResultIterable(基本上是一个列表),用于每个用户 ID。为了计算每个用户的访问次数,计算每个 ResultIterable 的长度就足够了:
(entries_rdd
.groupBy(lambda x: x['user'])
.map(lambda kv: (kv[0], len(kv[1])))
.take(5))
# Result:
# [(UUID('0604aab5-c7ba-4d5b-b1e0-16091052fb11'), 536),
# (UUID('d72c81c1-83f9-4b3c-a21a-788736c9b2ea'), 504),
# (UUID('e2e125fa-8984-4a9a-9ca1-b0620b113cdb'), 498),
# (UUID('b90acaf9-f279-430d-854f-5df74432dd52'), 561),
# (UUID('00d7be53-22c3-43cf-ace7-974689e9d54b'), 466)]
尽管这个算法在小数据集中可能效果很好,但 groupBy 要求我们将每个用户的整个条目集收集并存储在内存中,这可能会超过单个节点的内存容量。由于我们不需要列表,只需要计数,因此有更好的方法来计算这个数字,而无需在内存中保留每个用户的访问列表。
当处理 (key, value) 对的 RDD 时,可以使用 mapValues 仅对值应用函数。在前面的代码中,我们可以将 map(lambda kv: (kv[0], len(kv[1]))) 调用替换为 mapValues(len) 以提高可读性。
为了更高效的计算,我们可以利用 reduceByKey 函数,它将执行类似于我们在 MapReduce 简介 部分中看到的 Reduce 步骤。reduceByKey 函数可以从包含键作为第一个元素和值作为第二个元素的元组的 RDD 中调用,并接受一个作为其第一个参数的函数,该函数将计算减少。以下是对 reduceByKey 函数的一个简单示例。我们有一些与整数数字关联的字符串键,我们想要获取每个键的值的总和;这个减少操作,用 lambda 表达式表示,对应于元素的总和:
rdd = sc.parallelize([("a", 1), ("b", 2), ("a", 3), ("b", 4), ("c", 5)])
rdd.reduceByKey(lambda a, b: a + b).collect()
# Result:
# [('c', 5), ('b', 6), ('a', 4)]
reduceByKey 函数比 groupBy 更高效,因为它的减少操作是可并行的,并且不需要在内存中存储组;此外,它还限制了在 Executors 之间传输的数据量(它执行的操作类似于前面解释过的 Dask 的 foldby)。在这个阶段,我们可以使用 reduceByKey 重新编写我们的访问次数计算:
(entries_rdd
.map(lambda x: (x['user'], 1))
.reduceByKey(lambda a, b: a + b)
.take(3))
# Result:
# [(UUID('0604aab5-c7ba-4d5b-b1e0-16091052fb11'), 536),
# (UUID('d72c81c1-83f9-4b3c-a21a-788736c9b2ea'), 504),
# (UUID('e2e125fa-8984-4a9a-9ca1-b0620b113cdb'), 498)]
使用 Spark 的 RDD API,回答诸如“每天网站接收了多少次访问?”等问题也很容易。这可以通过使用 reduceByKey 并提供适当的键(即从时间戳中提取的日期)来计算。在下面的示例中,我们展示了计算过程。同时,请注意 sortByKey 操作符的使用,它按日期对计数进行排序:
(entries_rdd
.map(lambda x: (x['timestamp'].date(), 1))
.reduceByKey(lambda a, b: a + b)
.sortByKey()
.collect())
# Result:
# [(datetime.date(2017, 1, 1), 1685),
# (datetime.date(2017, 1, 2), 1625),
# (datetime.date(2017, 1, 3), 1663),
# (datetime.date(2017, 1, 4), 1643),
# (datetime.date(2017, 1, 5), 1731),
# (datetime.date(2017, 1, 6), 1653)]
Spark DataFrame
对于数值和分析任务,Spark 通过 pyspark.sql 模块(也称为 SparkSQL)提供了一个方便的接口。该模块包括一个 spark.sql.DataFrame 类,可以用于类似于 Pandas 的有效 SQL 样式查询。通过 SparkSession 类提供对 SQL 接口的访问:
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
SparkSession 可以通过 createDataFrame 函数创建一个 DataFrame。createDataFrame 函数接受 RDD、列表或 pandas.DataFrame。
在以下示例中,我们将通过将包含 Row 实例集合的 RDD rows 转换为 spark.sql.DataFrame 来创建一个 spark.sql.DataFrame。Row 实例代表一组列名和一组值之间的关联,就像 pd.DataFrame 中的行一样。在这个例子中,我们有两个列--x 和 y--我们将与随机数相关联:
# We will use the x_rdd and y_rdd defined previously.
rows = rdd_x.zip(rdd_y).map(lambda xy: Row(x=float(xy[0]), y=float(xy[1])))
rows.first() # Inspect the first element
# Result:
# Row(x=0.18432163061239137, y=0.632310101419016)
在获得我们的 Row 实例集合后,我们可以将它们组合成一个 DataFrame,如下所示。我们还可以使用 show 方法检查 DataFrame 的内容:
df = spark.createDataFrame(rows)
df.show(5)
# Output:
# +-------------------+--------------------+
# | x| y|
# +-------------------+--------------------+
# |0.18432163061239137| 0.632310101419016|
# | 0.8159145525577987| -0.9578448778029829|
# |-0.6565050226033042| 0.4644773453129496|
# |-0.1566191476553318|-0.11542211978216432|
# | 0.7536730082381564| 0.26953055476074717|
# +-------------------+--------------------+
# only showing top 5 rows
spark.sql.DataFrame 支持使用方便的 SQL 语法对分布式数据集进行转换。例如,您可以使用 selectExpr 方法使用 SQL 表达式计算一个值。在以下代码中,我们使用 x 和 y 列以及 pow SQL 函数计算碰撞测试:
hits_df = df.selectExpr("pow(x, 2) + pow(y, 2) < 1 as hits")
hits_df.show(5)
# Output:
# +-----+
# | hits|
# +-----+
# | true|
# |false|
# | true|
# | true|
# | true|
# +-----+
# only showing top 5 rows
为了展示 SQL 的表达能力,我们还可以使用单个表达式计算 pi 的估计值。该表达式涉及使用 SQL 函数,如 sum、pow、cast 和 count:
result = df.selectExpr('4 * sum(cast(pow(x, 2) +
pow(y, 2) < 1 as int))/count(x) as pi')
result.first()
# Result:
# Row(pi=3.13976)
Spark SQL 与基于 Hadoop 的分布式数据集的 SQL 引擎 Hive 使用相同的语法。有关完整的语法参考,请参阅 cwiki.apache.org/confluence/display/Hive/LanguageManual。
DataFrame 是一种利用 Scala 的强大功能和优化,同时使用 Python 接口的好方法。主要原因在于查询由 SparkSQL 符号解释,并且执行直接在 Scala 中发生,无需通过 Python 传递中间结果。这大大减少了序列化开销,并利用了 SparkSQL 执行的查询优化。优化和查询规划允许使用 SQL 操作符,如 GROUP BY,而不会产生性能惩罚,就像我们直接在 RDD 上使用 groupBy 时所经历的那样。
使用 mpi4py 进行科学计算
尽管 Dask 和 Spark 是在 IT 行业广泛使用的优秀技术,但它们在学术研究中并没有得到广泛的应用。几十年来,学术界一直使用拥有数千个处理器的超级计算机来运行密集型数值应用。因此,超级计算机通常使用一个非常不同的软件栈进行配置,该软件栈专注于在低级语言(如 C、Fortran 或甚至汇编)中实现的计算密集型算法。
在这些系统上用于并行执行的主要库是消息传递接口(MPI),虽然它不如 Dask 或 Spark 方便或复杂,但完全能够表达并行算法并实现出色的性能。请注意,与 Dask 和 Spark 不同,MPI 不遵循 MapReduce 模型,并且最好用于运行成千上万的进程,它们之间发送的数据非常少。
与我们迄今为止所看到的不同,MPI 的工作方式相当不同。MPI 中的并行性是通过在多个进程中运行相同的脚本(这些进程可能存在于不同的节点上)来实现的;进程之间的通信和同步由一个指定的进程处理,通常称为根,通常由0 ID 标识。
在本节中,我们将简要演示使用其mpi4py Python 接口的 MPI 的主要概念。在以下示例中,我们展示了使用 MPI 的最简单的并行代码。代码导入 MPI 模块并检索COMM_WORLD,这是一个可以用来与其他 MPI 进程交互的接口。Get_rank函数将返回当前进程的整数标识符:
from mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
print("This is process", rank)
我们可以将前面的代码放入一个名为mpi_example.py的文件中,并执行它。正常运行此脚本不会做任何特别的事情,因为它只涉及单个进程的执行:
$ python mpi_example.py
This is process 0
MPI 作业旨在使用mpiexec命令执行,该命令通过-n选项来指定并行进程的数量。使用以下命令运行脚本将生成四个独立的相同脚本执行,每个执行都有一个不同的 ID:
$ mpiexec -n 4 python mpi_example.py
This is process 0
This is process 2
This is process 1
This is process 3
通过资源管理器(如 TORQUE)自动在网络中分配进程。通常,超级计算机由系统管理员配置,系统管理员还将提供有关如何运行 MPI 软件的说明。
为了了解 MPI 程序的样子,我们将重新实现π的近似。完整的代码如下。程序将执行以下操作:
-
为每个进程创建一个大小为
N / n_procs的随机数组,以便每个进程将测试相同数量的样本(n_procs通过Get_size函数获得) -
在每个单独的进程中,计算击中测试的总和并将其存储在
hits_counts中,这将代表每个进程的部分计数 -
使用
reduce函数计算部分计数的总和。在调用 reduce 时,我们需要指定root参数来指定哪个进程将接收结果 -
只在根进程对应的进程中打印最终结果:
from mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
import numpy as np
N = 10000
n_procs = comm.Get_size()
print("This is process", rank)
# Create an array
x_part = np.random.uniform(-1, 1, int(N/n_procs))
y_part = np.random.uniform(-1, 1, int(N/n_procs))
hits_part = x_part**2 + y_part**2 < 1
hits_count = hits_part.sum()
print("partial counts", hits_count)
total_counts = comm.reduce(hits_count, root=0)
if rank == 0:
print("Total hits:", total_counts)
print("Final result:", 4 * total_counts/N)
我们现在可以将前面的代码放入一个名为mpi_pi.py的文件中,并使用mpiexec执行它。输出显示了四个进程执行如何交织在一起,直到我们到达reduce调用:
$ mpiexec -n 4 python mpi_pi.py
This is process 3
partial counts 1966
This is process 1
partial counts 1944
This is process 2
partial counts 1998
This is process 0
partial counts 1950
Total hits: 7858
Final result: 3.1432
摘要
分布式处理可以用来实现能够处理大规模数据集的算法,通过在计算机集群中分配更小的任务来实现。多年来,许多软件包,如 Apache Hadoop,已经被开发出来以实现分布式软件的高效和可靠执行。
在本章中,我们学习了 Python 包的架构和使用方法,例如 Dask 和 PySpark,它们提供了强大的 API 来设计和执行能够扩展到数百台机器的程序。我们还简要介绍了 MPI,这是一个已经用于数十年的库,用于在为学术研究设计的超级计算机上分配工作。
在整本书中,我们探讨了多种提高我们程序性能的技术,以及增加我们程序处理数据集速度和规模的方法。在下一章中,我们将描述编写和维护高性能代码的策略和最佳实践。
第九章:为高性能设计
在前面的章节中,我们学习了如何使用 Python 标准库和第三方包中可用的各种工具来评估和改进 Python 应用程序的性能。在本章中,我们将提供一些关于如何处理不同类型应用程序的一般性指南,并展示一些被多个 Python 项目普遍采用的优秀实践。
在本章中,我们将学习以下内容:
-
为通用、数值计算和大数据应用选择正确的性能技术
-
结构化 Python 项目
-
使用虚拟环境和容器化隔离 Python 安装
-
使用 Travis CI 设置持续集成
选择合适的策略
许多软件包可用于提高程序的性能,但我们如何确定我们程序的最佳优化策略?多种因素决定了使用哪种方法的决策。在本节中,我们将尽可能全面地回答这个问题,基于广泛的应用类别。
首先要考虑的是应用程序的类型。Python 是一种服务于多个非常多样化的社区的语言,这些社区包括网络服务、系统脚本、游戏、机器学习等等。这些不同的应用程序将需要对程序的不同部分进行优化努力。
例如,一个网络服务可以被优化以拥有非常短的反应时间。同时,它必须能够尽可能少地使用资源来回答尽可能多的请求(也就是说,它将尝试实现更低的延迟),而数值代码可能需要几周时间才能运行。即使有显著的启动开销(在这种情况下,我们感兴趣的是吞吐量),提高系统可能处理的数据量也很重要。
另一个方面是我们正在开发的平台和架构。虽然 Python 支持许多平台和架构,但许多第三方库可能对某些平台的支持有限,尤其是在处理绑定到 C 扩展的包时。因此,有必要检查目标平台和架构上库的可用性。
此外,一些架构,如嵌入式系统和小型设备,可能存在严重的 CPU 和内存限制。这是一个需要考虑的重要因素,例如,一些技术(如多进程)可能会消耗太多内存或需要执行额外的软件。
最后,业务需求同样重要。很多时候,软件产品需要快速迭代和快速更改代码的能力。一般来说,您希望将软件栈保持尽可能简单,以便修改、测试、部署以及引入额外的平台支持在短时间内变得容易且可行。这也适用于团队——安装软件栈和开始开发应该尽可能顺利。因此,通常应优先选择纯 Python 库而不是扩展,除非是经过良好测试的库,如 NumPy。此外,各种业务方面将有助于确定哪些操作需要首先优化(始终记住,过早优化是万恶之源)。
通用应用程序
通用应用程序,例如 Web 应用程序或移动应用程序后端,通常涉及对远程服务和数据库的调用。对于此类情况,利用异步框架可能很有用,例如在第六章中介绍的框架,实现并发;这将提高应用程序逻辑、系统设计、响应性,并且,它还将简化网络故障的处理。
异步编程的使用也使得实现和使用微服务变得更加容易。虽然没有标准定义,但可以将微服务视为专注于应用程序特定方面(例如,认证)的远程服务。
微服务的理念是您可以通过组合通过简单协议(例如 gRPC、REST 调用或通过专用消息队列)通信的不同微服务来构建应用程序。这种架构与所有服务都由同一 Python 进程处理的单体应用程序形成对比。
微服务的优势包括应用程序不同部分之间的强解耦。小型、简单的服务可以由不同的团队实现和维护,并且可以在不同时间更新和部署。这也使得微服务可以轻松复制,以便处理更多用户。此外,由于通信是通过简单的协议进行的,因此微服务可以用更适合特定应用程序的语言实现。
如果服务的性能不满意,应用程序通常可以在不同的 Python 解释器上执行,例如 PyPy(前提是所有第三方扩展都兼容)以实现足够的速度提升。否则,算法策略以及将瓶颈迁移到 Cython 通常足以实现令人满意的表现。
数值代码
如果你的目标是编写数值代码,一个很好的策略是直接从 NumPy 实现开始。使用 NumPy 是一个安全的赌注,因为它在许多平台上都可用且经过测试,并且,如我们在前面的章节中看到的,许多其他包将 NumPy 数组视为一等公民。
当正确编写时(例如,通过利用我们在第二章中学习的广播和其他技术,纯 Python 优化),NumPy 的性能已经非常接近由 C 代码实现的本地性能,并且不需要进一步优化。尽管如此,某些算法使用 NumPy 的数据结构和方法难以高效表达。当这种情况发生时,两个非常好的选择可以是 Numba 或 Cython。
Cython 是一个非常成熟的工具,被许多重要项目广泛使用,例如 scipy 和 scikit-learn。Cython 代码通过其明确的静态类型声明,使其非常易于理解,大多数 Python 程序员都不会在掌握其熟悉的语法上遇到问题。此外,没有“魔法”和良好的检查工具使得程序员可以轻松预测其性能,并对如何进行更改以实现最佳性能做出有根据的猜测。
然而,Cython 也有一些缺点。Cython 代码在执行之前需要编译,这打破了 Python 编辑-运行周期的便利性。这也要求目标平台上有兼容的 C 编译器。这还使得分发和部署变得复杂,因为需要为每个目标平台测试多个平台、架构、配置和编译器。
另一方面,Numba API 只需要定义纯 Python 函数,这些函数会即时编译,保持快速的 Python 编辑-运行周期。一般来说,Numba 需要在目标平台上安装 LLVM 工具链。请注意,截至版本 0.30,Numba 函数的即时编译(AOT)有一些有限的支持,这样 Numba 编译的函数就可以打包和部署,而无需安装 Numba 和 LLVM。
注意,Numba 和 Cython 通常都预包装了所有依赖项(包括编译器),可以在 conda 包管理器的默认通道中找到。因此,在 conda 包管理器可用的平台上,Cython 的部署可以大大简化。
如果 Cython 和 Numba 仍然不够用怎么办?虽然这通常不是必需的,但另一种策略是实现一个纯 C 模块(可以使用编译器标志或手动调整进行进一步优化),然后使用 cffi 包(cffi.readthedocs.io/en/latest/) 或 Cython 从 Python 模块中调用它。
使用 NumPy、Numba 和 Cython 是在串行代码上获得近似最优性能的有效策略。对于许多应用来说,串行代码当然足够了,即使最终计划是拥有并行算法,仍然非常值得为调试目的而工作在串行参考实现上,因为串行实现在小数据集上可能更快。
并行实现根据特定应用的复杂性有很大差异。在许多情况下,程序可以很容易地表示为一系列独立的计算,随后进行某种形式的聚合,并可以使用简单的基于进程的接口进行并行化,例如 multiprocessing.Pool 或 ProcessPoolExecutor,这些接口的优点是能够在不费太多周折的情况下并行执行通用 Python 代码。
为了避免启动多个进程的时间和内存开销,可以使用线程。NumPy 函数通常释放 GIL,是线程并行化的良好候选者。此外,Cython 和 Numba 提供特殊的 nogil 语句以及自动并行化,这使得它们适合简单的、轻量级的并行化。
对于更复杂的用例,你可能需要显著改变算法。在这些情况下,Dask 数组是一个不错的选择,它们几乎可以无缝替换标准的 NumPy。Dask 的进一步优势是操作非常透明,并且易于调整。
专门的应用程序,如深度学习和计算机图形学,它们大量使用线性代数例程,可能从 Theano 和 Tensorflow 等软件包中受益,这些软件包能够实现高度性能的自动并行化,并内置 GPU 支持。
最后,可以使用 mpi4py 在基于 MPI 的超级计算机上部署并行 Python 脚本(通常大学的研究人员可以访问)。
大数据
大型数据集(通常大于 1 TB)变得越来越普遍,大量资源已经投入到了开发能够收集、存储和分析这些数据的技术中。通常,选择使用哪个框架取决于数据最初是如何存储的。
许多时候,即使完整的数据集不适合单个机器,仍然可以制定策略来提取答案,而无需探测整个数据集。例如,经常可以通过提取一个小的、有趣的数据子集来回答问题,这些数据子集可以轻松加载到内存中,并使用高度方便和高效的库(如 Pandas)进行分析。通过过滤或随机采样数据点,通常可以找到足够好的答案来回答业务问题,而无需求助于大数据工具。
如果公司的软件主体是用 Python 编写的,并且你有自由选择软件栈的权限,那么使用 Dask 分布式是有意义的。这个软件包的设置非常简单,并且与 Python 生态系统紧密集成。使用 Dask 的 array 和 DataFrame,通过适配 NumPy 和 Pandas 代码,可以非常容易地扩展你现有的 Python 算法。
很常见,一些公司可能已经设置了一个 Spark 集群。在这种情况下,PySpark 是最佳选择,并且鼓励使用 SparkSQL 以获得更高的性能。Spark 的一个优点是它允许使用其他语言,例如 Scala 和 Java。
组织你的源代码
典型的 Python 项目的仓库结构至少包括一个包含 README.md 文件的目录、一个包含应用程序或库源代码的 Python 模块或包,以及一个 setup.py 文件。项目可能采用不同的约定来遵守公司政策或使用的特定框架。在本节中,我们将说明一些在社区驱动的 Python 项目中常见的实践,这些实践可能包括我们在前面章节中介绍的一些工具。
一个名为 myapp 的 Python 项目的典型目录结构可以看起来像这样。现在,我们将阐述每个文件和目录的作用:
myapp/
README.md
LICENSE
setup.py
myapp/
__init__.py
module1.py
cmodule1.pyx
module2/
__init__.py
src/
module.c
module.h
tests/
__init__.py
test_module1.py
test_module2.py
benchmarks/
__init__.py
test_module1.py
test_module2.py
docs/
tools/
README.md 是一个包含有关软件的一般信息的文本文件,例如项目范围、安装、快速入门和有用的链接。如果软件公开发布,则使用 LICENSE 文件来指定其使用的条款和条件。
Python 软件通常使用 setup.py 文件中的 setuptools 库进行打包。正如我们在前面的章节中看到的,setup.py 也是编译和分发 Cython 代码的有效方式。
myapp 包包含应用程序的源代码,包括 Cython 模块。有时,除了它们的 Cython 优化版本之外,维护纯 Python 实现也很方便。通常,模块的 Cython 版本以 c 前缀命名(例如,前一个示例中的 cmodule1.pyx)。
如果需要外部 .c 和 .h 文件,这些文件通常存储在顶级(myapp)项目目录下的一个额外的 src/ 目录中。
tests/ 目录包含应用程序的测试代码(通常以单元测试的形式),可以使用测试运行器(如 unittest 或 pytest)运行。然而,一些项目更喜欢将 tests/ 目录放置在 myapp 包内部。由于高性能代码是持续调整和重写的,因此拥有一个可靠的测试套件对于尽早发现错误和通过缩短测试-编辑-运行周期来提高开发者体验至关重要。
基准测试可以放在benchmarks目录中;将基准测试与测试分离的优势在于,基准测试可能需要更多的时间来执行。基准测试也可以在构建服务器上运行(见持续集成部分),作为一种简单的方法来比较不同版本的性能。虽然基准测试通常比单元测试运行时间更长,但最好将它们的执行时间尽可能缩短,以避免资源浪费。
最后,docs/目录包含用户和开发者文档以及 API 参考。这通常还包括文档工具的配置文件,例如sphinx。其他工具和脚本可以放在tools/目录中。
隔离、虚拟环境和容器
在代码测试和执行时拥有隔离环境的重要性,通过观察当你请求朋友运行你的一个 Python 脚本时会发生什么,就会变得非常明显。发生的情况是,你提供安装 Python 版本 X 及其依赖包Y、X的指令,并要求他们在自己的机器上复制并执行该脚本。
在许多情况下,你的朋友会继续操作,为其平台下载 Python 以及依赖库,并尝试执行脚本。然而,可能会发生(很多时候都会发生)脚本会失败,因为他们的计算机操作系统与你不同,或者安装的库版本与你机器上安装的版本不同。在其他时候,可能会有不正确删除的先前安装,这会导致难以调试的冲突和很多挫败感。
避免这种情况的一个非常简单的方法是使用虚拟环境。虚拟环境通过隔离 Python、相关可执行文件和第三方包来创建和管理多个 Python 安装。自 Python 3.3 版本以来,标准库包括了venv模块(之前称为virtualenv),这是一个用于创建和管理简单隔离环境的工具。基于venv的虚拟环境中的 Python 包可以使用setup.py文件或通过pip进行安装。
在处理高性能代码时,提供精确和具体的库版本至关重要。库在发布之间不断进化,算法的变化可能会显著影响性能。例如,流行的库,如scipy和scikit-learn,通常会将其部分代码和数据结构移植到 Cython,因此用户安装正确的版本以获得最佳性能非常重要。
使用 conda 环境
大多数情况下,使用venv是一个不错的选择。然而,当编写高性能代码时,通常会发生一些高性能库也需要安装非 Python 软件的情况。这通常涉及额外的设置编译器和高性能本地库(在 C、C++或 Fortran 中),Python 软件包会链接到这些库。由于venv和pip旨在仅处理 Python 软件包,因此这些工具对这种场景的支持不佳。
conda软件包管理器是专门为处理此类情况而创建的。可以使用conda create命令创建虚拟环境。该命令接受-n参数(-n代表--name),用于指定新创建的环境和我们要安装的软件包。如果我们想创建一个使用 Python 版本3.5和最新版 NumPy 的环境,我们可以使用以下命令:
$ conda create -n myenv Python=3.5 numpy
Conda 将负责从其存储库获取相关软件包并将它们放置在隔离的 Python 安装中。要启用虚拟环境,可以使用source activate命令:
$ source activate myenv
执行此命令后,默认 Python 解释器将切换到我们之前指定的版本。你可以使用which命令轻松验证 Python 可执行文件的位置,该命令返回可执行文件的全路径:
(myenv) $ which python
/home/gabriele/anaconda/envs/myenv/bin/python
到目前为止,你可以自由地在虚拟环境中添加、删除和修改软件包,而不会影响全局 Python 安装。可以使用conda install <package name>命令或通过pip安装更多软件包。
虚拟环境的优点在于,你可以以良好的隔离方式安装或编译你想要的任何软件。这意味着,如果由于某种原因,你的环境被损坏,你可以将其擦除并从头开始。
要删除myenv环境,首先需要将其停用,然后使用conda env remove命令,如下所示:
(myenv) $ source deactivate
$ conda env remove -n myenv
如果软件包在标准conda存储库中不可用怎么办?一个选项是查看它是否在conda-forge社区频道中可用。要搜索conda-forge中的软件包,可以在conda search命令中添加-c选项(代表--channel):
$ conda search -c conda-forge scipy
该命令将列出与scipy查询字符串匹配的一系列软件包和版本。另一个选项是在Anaconda Cloud上托管公共频道中搜索该软件包。可以通过安装anaconda-client软件包来下载 Anaconda Cloud 的命令行客户端:
$ conda install anaconda-client
客户端安装完成后,你可以使用anaconda命令行客户端来搜索软件包。在以下示例中,我们演示了如何查找chemview软件包:
$ anaconda search chemview
Using Anaconda API: https://api.anaconda.org
Run 'anaconda show <USER/PACKAGE>' to get more details:
Packages:
Name | Version | Package Types | Platforms
------------------------- | ------ | --------------- | ---------------
cjs14/chemview | 0.3 | conda | linux-64, win-64, osx-64
: WebGL Molecular Viewer for IPython notebook.
gabrielelanaro/chemview | 0.7 | conda | linux-64, osx-64
: WebGL Molecular Viewer for IPython notebook.
然后,可以通过指定适当的频道(使用-c选项)轻松执行安装:
$ conda install -c gabrielelanaro chemlab
虚拟化和容器
虚拟化作为一种在同一台机器上运行多个操作系统以更好地利用物理资源的方法,已经存在很长时间了。
实现虚拟化的一种方法是通过使用虚拟机。虚拟机通过创建虚拟硬件资源,例如 CPU、内存和设备,并使用这些资源在同一台机器上安装和运行多个操作系统。通过在操作系统(称为宿主)上安装虚拟化软件(称为管理程序),可以实现虚拟化。管理程序能够创建、管理和监控虚拟机及其相应的操作系统(称为客户机)。
重要的是要注意,尽管名为虚拟环境,但它们与虚拟机无关。虚拟环境是 Python 特定的,通过 shell 脚本设置不同的 Python 解释器来实现。
容器是一种通过创建与宿主操作系统分离的环境并仅包含必要依赖项来隔离应用程序的方法。容器是操作系统功能,允许您共享由操作系统内核提供的硬件资源(多个实例)。与虚拟机不同,容器并不抽象硬件资源,而只是共享操作系统的内核。
容器在利用硬件资源方面非常高效,因为它们通过内核直接访问。因此,它们是高性能应用的绝佳解决方案。它们也易于创建和销毁,可以快速在隔离环境中测试应用程序。容器还用于简化部署(尤其是微服务)以及开发构建服务器,如前文所述。
在第八章“分布式处理”中,我们使用了docker来轻松设置 PySpark 安装。Docker 是目前最受欢迎的容器化解决方案之一。安装 Docker 的最佳方式是遵循官方网站上的说明(www.docker.com/)。安装后,可以使用 docker 命令行界面轻松创建和管理容器。
您可以使用docker run命令启动一个新的容器。在下面的示例中,我们将演示如何使用docker run在 Ubuntu 16.04 容器中执行 shell 会话。为此,我们需要指定以下参数:
-
-i指定我们正在尝试启动一个交互式会话。也可以在不交互的情况下执行单个 docker 命令(例如,当启动 Web 服务器时)。 -
-t <image name>指定要使用哪个系统镜像。在下面的示例中,我们使用ubuntu:16.04镜像。 -
/bin/bash,这是在容器内运行的命令,如下所示:
$ docker run -i -t ubuntu:16.04 /bin/bash
root@585f53e77ce9:/#
此命令将立即带我们进入一个独立的、隔离的 shell,我们可以在其中与系统互动并安装软件,而不会触及主机操作系统。使用容器是测试不同 Linux 发行版上的安装和部署的非常好的方法。在完成交互式 shell 的工作后,我们可以输入 exit 命令返回到主机系统。
在上一章中,我们也使用了端口和分离选项 -p 和 -d 来运行可执行文件 pyspark。-d 选项只是要求 Docker 在后台运行命令。而 -p <host_port>:<guest_port> 选项则是必要的,用于将主机操作系统的网络端口映射到客户系统;没有这个选项,Jupyter Notebook 就无法从运行在主机系统中的浏览器访问。
我们可以使用 docker ps 监控容器的状态,如下面的片段所示。-a 选项(代表 all)用于输出有关所有容器的信息,无论它们当前是否正在运行:
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
585f53e77ce9 ubuntu:16.04 "/bin/bash" 2 minutes ago Exited (0) 2 minutes ago pensive_hamilton
docker ps 提供的信息包括一个十六进制标识符 585f53e77ce9 以及一个可读名称 pensive_hamilton,这两个都可以用于在其他 Docker 命令中指定容器。它还包括有关执行命令、创建时间和执行当前状态的其他信息。
您可以使用 docker start 命令恢复已退出的容器的执行。要获取对容器的 shell 访问权限,您可以使用 docker attach。这两个命令都可以跟容器 ID 或其可读名称:
$ docker start pensive_hamilton
pensive_hamilton
$ docker attach pensive_hamilton
root@585f53e77ce9:/#
您可以使用 docker run 命令后跟容器标识符轻松地删除容器:
$ docker rm pensive_hamilton
如您所见,您可以在不到一秒的时间内自由执行命令、运行、停止和恢复容器。使用 Docker 容器进行交互式操作是测试新包和进行实验的好方法,而不会干扰主机操作系统。由于您可以同时运行多个容器,Docker 还可以用来模拟分布式系统(用于测试和学习目的),而无需拥有昂贵的计算集群。
Docker 还允许您创建自己的系统镜像,这对于分发、测试、部署和文档用途非常有用。这将是下一小节的主题。
创建 Docker 镜像
Docker 镜像是现成的、预配置的系统。可以使用 docker run 命令访问和安装可在 DockerHub (hub.docker.com/) 上找到的 Docker 镜像,这是一个维护者上传现成镜像以测试和部署各种应用程序的在线服务。
创建 Docker 镜像的一种方法是在现有的容器上使用 docker commit 命令。docker commit 命令接受容器引用和输出镜像名称作为参数:
$ docker commit <container_id> <new_image_name>
使用这种方法可以保存特定容器的快照,但如果图像从系统中删除,重新创建图像的步骤也会丢失。
创建图像的更好方法是使用Dockerfile构建。Dockerfile 是一个文本文件,它提供了从另一个图像开始构建图像的指令。例如,我们将展示我们在上一章中用于设置带有 Jupyter 笔记本支持的 PySpark 的 Dockerfile 的内容。完整的文件如下所示。
每个 Dockerfile 都需要一个起始图像,可以使用FROM命令声明。在我们的例子中,起始图像是jupyter/scipy-notebook,它可以通过 DockerHub (hub.docker.com/r/jupyter/scipy-notebook/)获取。
一旦我们定义了起始图像,我们就可以开始使用一系列RUN和ENV命令来发出 shell 命令,安装包和执行其他配置。在下面的示例中,你可以识别出 Java 运行时环境(openjdk-7-jre-headless)的安装,以及下载 Spark 和设置相关环境变量。USER指令可以用来指定执行后续命令的用户:
FROM jupyter/scipy-notebook
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
USER root
# Spark dependencies
ENV APACHE_SPARK_VERSION 2.0.2
RUN apt-get -y update &&
apt-get install -y --no-install-recommends
openjdk-7-jre-headless &&
apt-get clean &&
rm -rf /var/lib/apt/lists/*
RUN cd /tmp &&
wget -q http://d3kbcqa49mib13.cloudfront.net/spark-
${APACHE_SPARK_VERSION}-bin-hadoop2.6.tgz &&
echo "ca39ac3edd216a4d568b316c3af00199
b77a52d05ecf4f9698da2bae37be998a
*spark-${APACHE_SPARK_VERSION}-bin-hadoop2.6.tgz" |
sha256sum -c - &&
tar xzf spark-${APACHE_SPARK_VERSION}
-bin-hadoop2.6.tgz -C /usr/local &&
rm spark-${APACHE_SPARK_VERSION}-bin-hadoop2.6.tgz
RUN cd /usr/local && ln -s spark-${APACHE_SPARK_VERSION}
-bin-hadoop2.6 spark
# Spark and Mesos config
ENV SPARK_HOME /usr/local/spark
ENV PYTHONPATH $SPARK_HOME/python:$SPARK_HOME/python/lib/
py4j-0.10.3-src.zip
ENV SPARK_OPTS --driver-java-options=-Xms1024M
--driver-java-options=-
Xmx4096M --driver-java-options=-Dlog4j.logLevel=info
USER $NB_USER
可以使用以下命令从 Dockerfile 所在的目录创建图像。-t选项可以用来指定存储图像时使用的标签。以下行可以创建名为pyspark的图像,该图像来自前面的 Dockerfile:
$ docker build -t pyspark .
命令将自动检索起始图像jupyter/scipy-notebook,并生成一个新图像,命名为pyspark。
持续集成
持续集成是确保应用程序在每次开发迭代中保持无错误的好方法。持续集成背后的主要思想是频繁地运行项目的测试套件,通常在一个单独的构建服务器上,该服务器直接从主项目仓库拉取代码。
通过在机器上手动设置 Jenkins (jenkins.io/)、Buildbot (buildbot.net/)和 Drone (github.com/drone/drone)等软件来设置构建服务器可以完成。这是一个方便且成本低的解决方案,特别是对于小型团队和私人项目。
大多数开源项目都利用了 Travis CI (travis-ci.org/),这是一个能够从你的仓库自动构建和测试你的代码的服务,因为它与 GitHub 紧密集成。截至今天,Travis CI 为开源项目提供免费计划。许多开源 Python 项目利用 Travis CI 来确保程序在多个 Python 版本和平台上正确运行。
通过包含一个包含项目构建说明的 .travis.yml 文件,并注册账户后激活 Travis CI 网站上的构建(travis-ci.org/),可以从 GitHub 仓库轻松设置 Travis CI。
这里展示了一个高性能应用的 .travis.yml 示例。该文件包含使用 YAML 语法编写的几个部分,用于指定构建和运行软件的说明。
python 部分指定了要使用的 Python 版本。install 部分将下载并设置 conda 以进行测试、安装依赖项和设置项目。虽然这一步不是必需的(可以使用 pip 代替),但 conda 是高性能应用的优秀包管理器,因为它包含有用的本地包。
script 部分包含测试代码所需的代码。在这个例子中,我们限制自己只运行测试和基准测试:
language: python
python:
- "2.7"
- "3.5"
install: # Setup miniconda
- sudo apt-get update
- if [[ "$TRAVIS_PYTHON_VERSION" == "2.7" ]]; then
wget https://repo.continuum.io/miniconda/
Miniconda2-latest-Linux-x86_64.sh -O miniconda.sh;
else
wget https://repo.continuum.io/miniconda/
Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh;
fi
- bash miniconda.sh -b -p $HOME/miniconda
- export PATH="$HOME/miniconda/bin:$PATH"
- hash -r
- conda config --set always_yes yes --set changeps1 no
- conda update -q conda
# Installing conda dependencies
- conda create -q -n test-environment python=
$TRAVIS_PYTHON_VERSION numpy pandas cython pytest
- source activate test-environment
# Installing pip dependencies
- pip install pytest-benchmark
- python setup.py install
script:
pytest tests/
pytest benchmarks/
每次将新代码推送到 GitHub 仓库(以及其他可配置的事件)时,Travis CI 将启动一个容器,安装依赖项,并运行测试套件。在开源项目中使用 Travis CI 是一种很好的实践,因为它是对项目状态的一种持续反馈,同时也通过持续测试的 .travis.yml 文件提供最新的安装说明。
摘要
决定优化软件的策略是一个复杂且微妙的工作,它取决于应用程序类型、目标平台和业务需求。在本章中,我们提供了一些指导方针,以帮助你思考和选择适合你自己的应用程序的适当软件堆栈。
高性能数值应用有时需要管理第三方包的安装和部署,这些包可能需要处理外部工具和本地扩展。在本章中,我们介绍了如何构建你的 Python 项目,包括测试、基准测试、文档、Cython 模块和 C 扩展。此外,我们还介绍了持续集成服务 Travis CI,它可以用于为托管在 GitHub 上的项目启用持续测试。
最后,我们还学习了可以使用虚拟环境和 docker 容器来测试应用程序,这可以极大地简化部署并确保多个开发者可以访问相同的平台。


浙公网安备 33010602011771号