Python-高性能编程第二版-全-

Python 高性能编程第二版(全)

原文:annas-archive.org/md5/14cdae864340f5ff491653bf5aaa6c9c

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

Python 易于学习。您可能来到这里是因为现在您的代码可以正确运行,但您希望它运行得更快。您喜欢您的代码易于修改,并且可以快速迭代想法的事实。在“易于开发”和“能以我需要的速度运行”之间的权衡是一个众所周知并经常抱怨的现象。这里有解决方案。

有些人有必须运行更快的串行进程。其他人有可能能够利用多核架构、集群或图形处理单元解决问题。有些人需要可伸缩的系统,可以根据需要以更高效或更少的方式处理,而不会失去可靠性。其他人会意识到,他们的编码技术,通常是从其他语言借鉴而来,也许并不像他们从其他人那里看到的示例那样自然。

在本书中,我们将涵盖所有这些主题,并为理解瓶颈和生成更快、更可扩展解决方案提供实用指导。我们还包括一些前辈的战斗经历,他们为你走过的路,以便您无需重复。

Python 非常适合快速开发、生产部署和可扩展系统。生态系统中有许多人正在努力使其能够按照您的意愿进行扩展,从而使您能够更多地专注于周围更具挑战性的任务。

本书适合谁

你已经使用 Python 足够长时间,对于为什么某些事情很慢有所了解,并且见过像 Cython、numpy和 PyPy 这样的技术被讨论为可能的解决方案。你可能还使用其他语言编程,因此知道解决性能问题的方法不止一种。

虽然本书主要面向 CPU 绑定问题的人群,我们也关注数据传输和内存绑定的解决方案。通常,这些问题会遇到科学家、工程师、量化分析师和学术界人士。

我们还会探讨 Web 开发人员可能面临的问题,包括数据传输和像 PyPy 这样的即时编译器(JIT),以及异步 I/O 以获取易胜利的性能提升。

如果您具备 C(或 C++,或者可能是 Java)的背景,则可能会有所帮助,但这并非必要条件。Python 最常见的解释器(CPython——通常在命令行中输入python后得到的标准解释器)是用 C 编写的,因此所有的钩子和库都暴露了内部的 C 机制。我们还介绍了许多不需要任何 C 知识的技术。

您可能还对 CPU、内存架构和数据总线有较低层次的了解,但同样,这并非绝对必要。

本书不适合谁

本书适用于中高级 Python 程序员。有动力的初学者 Python 程序员也许也能跟上,但我们建议具备扎实的 Python 基础。

我们不涵盖存储系统优化。如果您有 SQL 或 NoSQL 瓶颈问题,那么这本书可能不会对您有帮助。

你将会学到什么

你的作者们多年来在工业界和学术界一直在处理大量数据,这是 我想更快得到答案! 的需求和可扩展架构的必要性。我们将尽力将我们的艰辛经验传授给你,以免你犯我们犯过的错误。

在每章的开头,我们将列出以下文本应该回答的问题。(如果没有,请告诉我们,我们将在下一个修订版中进行修正!)

我们涵盖以下主题:

  • 计算机背后的机器原理,让你了解幕后发生的一切

  • 列表和元组——这些基础数据结构中微妙的语义和速度差异

  • 字典和集合——这些重要数据结构中的内存分配策略和访问算法

  • 迭代器——如何以更符合 Python 风格的方式编写,并利用迭代开启无限数据流的大门

  • 纯 Python 方法——如何有效地使用 Python 及其模块

  • 使用 numpy 的矩阵——如何像专家一样使用深受喜爱的 numpy

  • 编译和即时计算——通过编译成机器码来加速处理,确保你根据分析结果来引导

  • 并发性——高效移动数据的方式

  • multiprocessing——使用内置的 multiprocessing 库进行并行计算的各种方法,并有效地共享 numpy 矩阵,以及进程间通信(IPC)的一些成本和效益

  • 集群计算——将你的 multiprocessing 代码转换为本地或远程集群上运行,适用于研究和生产系统

  • 使用更少的 RAM——解决大问题的方法,而不是购买一台巨大的计算机

  • 从实战中汲取的教训——从那些曾经吃过苦头的人身上学到的教训,让你不必再吃苦头

Python 3

Python 3 自 2020 年起成为 Python 的标准版本,Python 2.7 经过 10 年的迁移过程已被废弃。如果你仍在使用 Python 2.7,那么你做错了——许多库已不再支持此版本,支持成本将随时间推移而增加。请为社区着想,迁移到 Python 3,并确保所有新项目使用 Python 3。

在本书中,我们使用 64 位 Python。虽然支持 32 位 Python,但在科学工作中并不常见。我们预期所有的库都能正常工作,但数值精度(取决于可用于计数的位数)可能会有所变化。64 位在这个领域占主导地位,通常与 nix 环境(通常是 Linux 或 Mac)一起使用。64 位让你能够处理更大量的 RAM。nix 让你构建可以以被理解的方式部署和配置的应用程序,具有被理解的行为。

如果你是 Windows 用户,你就得做好准备。我们展示的大部分内容都可以正常工作,但有些东西是特定于操作系统的,你需要研究 Windows 的解决方案。Windows 用户可能面临的最大困难是模块的安装:在 Stack Overflow 等网站上进行研究应该会给你提供所需的解决方案。如果你使用 Windows,在运行 Linux 安装的虚拟机(例如,使用 VirtualBox)上可以帮助你更自由地进行实验。

Windows 用户应该绝对考虑像 Anaconda、Canopy、Python(x,y) 或 Sage 这样的打包解决方案。这些相同的发行版也将使 Linux 和 Mac 用户的生活变得更加简单。

与 Python 2.7 的更改

如果你从 Python 2.7 升级过来,可能不知道一些相关的变化:

  • / 在 Python 2.7 中表示整数除法,而在 Python 3 中表示浮点数除法。

  • 在 Python 2.7 中,strunicode 用于表示文本数据;在 Python 3 中,一切都是 str,而这些始终是 Unicode。为了清晰起见,如果我们使用未编码的字节序列,将使用 bytes 类型。

如果您正在升级您的代码,两个很好的指南是“将 Python 2 代码移植到 Python 3”“支持 Python 3:深入指南”。使用 Anaconda 或 Canopy 这样的发行版,您可以同时运行 Python 2 和 Python 3 —— 这将简化您的移植。

许可证

本书根据知识共享署名-非商业性使用-禁止演绎 3.0许可。

您可以自由使用本书进行非商业目的,包括非商业教学。许可证仅允许完整复制;对于部分复制,请联系 O’Reilly(请参阅“如何联系我们”)。请如下部分所示归属本书。

我们商定本书应具有知识共享许可,以便内容能够在全球传播。如果此决定对您有所帮助,我们会非常高兴收到一瓶啤酒。我们怀疑 O’Reilly 的工作人员对啤酒也会有类似的看法。

如何进行归属

根据知识共享许可证,您应该表明您使用了本书的一部分。表明只是指您应该写一些其他人可以遵循以找到本书的内容。以下方式是合理的:“《高性能 Python》,第二版,作者 Micha Gorelick 和 Ian Ozsvald(O’Reilly)。版权所有 2020 Micha Gorelick 和 Ian Ozsvald,978-1-492-05502-0。”

补记和反馈

我们鼓励您在像亚马逊这样的公共网站上审查本书,以帮助其他人了解他们是否会从本书中受益!您还可以通过邮件与我们联系 feedback@highperformancepython.com。

我们特别希望听到有关本书中错误的反馈、本书帮助您成功的使用案例以及应在下一版中覆盖的高性能技术。您可以访问本书的网页,地址为https://oreil.ly/high-performance-python-2e

投诉可以通过即时投诉传输服务 > /dev/null 进行欢迎。

本书使用的约定

本书中使用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序列表,以及段落内引用程序元素如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应直接输入的命令或其他文本。

常量宽度斜体

显示应由用户提供值或由上下文确定值的文本。

提示

此元素表示提示、建议或批判性思考问题。

注意

此元素表示一般注释。

警告

此元素表示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可在https://github.com/mynameisfiber/high_performance_python_2e下载。

如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。通常情况下,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您要复制大量代码,否则无需联系我们寻求许可。例如,编写一个使用本书多个代码片段的程序不需要许可。出售或分发 O'Reilly 书籍中的示例需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

如果您认为您使用的代码示例超出了合理使用范围或上述授权,请随时通过permissions@oreilly.com联系我们。

O’Reilly 在线学习

注意

超过 40 年来,O’Reilly Media 提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台让您随时访问现场培训课程、深入学习路径、交互式编码环境以及来自奥莱利和其他 200 多家出版商的大量文本和视频。更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题寄给出版商:

  • 奥莱利传媒公司

  • 1005 Gravenstein Highway North

  • 加利福尼亚州塞巴斯托波尔市,95472

  • 800-998-9938(美国或加拿大境内)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

发送电子邮件至bookquestions@oreilly.com以评论或提问关于本书的技术问题。

欲了解更多有关我们书籍和课程的消息,请访问我们的网站http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

观看我们的 YouTube 频道:http://youtube.com/oreillymedia

致谢

希拉里·梅森为我们的序言写了一篇感谢她为我们的书写了如此精彩的开场述评。吉尔斯·韦弗和迪米特里·德尼索诺克在本版本中提供了宝贵的技术反馈;干得好,伙计们。

感谢帕特里克·库珀、凯伦·戴尔、丹·福尔曼-麦基、卡尔文·吉尔斯、布莱恩·格兰杰、杰米·马修斯、约翰·蒙哥马利、克里斯蒂安·施奥·奥克斯维格、马特“蛇”雷弗森、巴尔萨扎·鲁伯洛尔、迈克尔·斯基尔潘、卢克·安德伍德、杰克·范德普拉斯和威廉·温特为宝贵的反馈和贡献。

伊恩感谢他的妻子艾米莉,让他又消失了八个月来写这第二版(幸运的是,她非常理解)。伊恩向他的狗道歉,因为他坐着写作,而不是像她希望的那样多在树林里散步。

米卡感谢玛丽安和他的其他朋友和家人,在他学会写作的过程中表现得如此耐心。

奥莱利的编辑们非常愉快地与他们合作;如果你想写自己的书,强烈考虑与他们交流。

我们在“实战经验教训”章节的贡献者非常友好地分享了他们的时间和艰辛经验。我们感谢索莱达德·加利、琳达·乌鲁丘尔图、瓦伦丁·哈内尔和文森特·D·瓦默达姆为这一版本作出的贡献,以及本·杰克逊、拉迪姆·雷胡雷克、塞巴斯蒂安·特雷普卡、亚历克斯·凯利、马尔科·塔西奇和安德鲁·戈德温在之前版本中的付出。

第一章:理解高性能 Python

编程计算机可以被认为是移动数据位并以特定方式转换它们以达到特定结果。然而,这些操作都需要时间成本。因此,高性能编程可以被看作是通过减少这些操作的开销(即编写更高效的代码)或者改变执行操作的方式来使每个操作更有意义(即找到更合适的算法)的行为。

让我们专注于减少代码中的开销,以便更深入地了解我们在其中移动这些位的实际硬件。这可能看起来像是一个徒劳的练习,因为 Python 很努力地抽象出直接与硬件的交互。然而,通过了解在实际硬件中位移的最佳方式以及 Python 抽象如何强制您的位移动,您可以在编写 Python 高性能程序方面取得进展。

基本计算机系统

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

使用这些构建块,我们可以在多个复杂程度的级别上讨论标准工作站。例如,标准工作站可以被视为具有中央处理单元(CPU)作为计算单元,连接到随机存取存储器(RAM)和硬盘作为两个单独的存储单元(每个具有不同的容量和读写速度),最后有一条总线连接所有这些部件。然而,我们也可以更详细地了解到 CPU 本身有几个内存单元:L1、L2,有时甚至是 L3 和 L4 缓存,这些缓存容量虽小但速度非常快(从几 KB 到数十 MB)。此外,新的计算机架构通常配备新的配置(例如,Intel 的 SkyLake CPU 用 Intel Ultra Path Interconnect 替换了前端总线并重构了许多连接)。最后,在这两种工作站的近似中,我们忽略了网络连接,这实际上是与潜在的许多其他计算和内存单元连接的非常慢的连接!

为了帮助理清这些复杂的细节,让我们简要描述一下这些基本组件。

计算单元

计算机的计算单元是其实用性的中心 - 它提供了将其接收的任何位转换为其他位或更改当前进程状态的能力。 CPU 是最常用的计算单元; 但是,图形处理单元(GPU)作为辅助计算单元正在增加其流行度。 它们最初用于加速计算机图形,但由于其固有的并行特性而越来越适用于数值应用程序。 这允许许多计算同时进行。 无论其类型如何,计算单元接收一系列位(例如,表示数字的位)并输出另一组位(例如,表示这些数字之和的位)。 除了对整数和实数的基本算术运算以及对二进制数的按位操作之外,某些计算单元还提供非常专门化的操作,例如“融合乘加”操作,该操作接收三个数字ABC,并返回值A * B + C

计算单元的主要关注属性是它可以在一个周期内执行的操作数以及它可以在一秒内执行的周期数。 第一个值由其每周期指令数(IPC)[1](ch01_split_001.xhtml#idm46122429262840)衡量,而后一个值由其时钟速度衡量。 这两个度量值在制造新计算单元时总是互相竞争。 例如,Intel Core 系列具有非常高的 IPC 但较低的时钟速度,而 Pentium 4 芯片则相反。 另一方面,GPU 具有非常高的 IPC 和时钟速度,但它们遭受了其他问题的困扰,例如我们在“通信层”中讨论的慢通信。

此外,虽然增加时钟速度几乎立即加快了所有在计算单元上运行的程序(因为它们能够每秒执行更多计算),但更高的 IPC 也可以通过改变可能的向量化水平,从而 drasticaly 影响计算。 向量化发生在 CPU 一次提供多个数据片段并且能够同时对所有数据执行操作时。 这种 CPU 指令被称为单指令,多数据(SIMD)。

总体而言,过去十年来计算单元的进展相当缓慢(参见图 1-1)。 由于使晶体管变得越来越小的物理限制,时钟速度和 IPC 都停滞不前。 因此,芯片制造商一直依赖其他方法来获得更多速度,包括同时多线程(多个线程可以同时运行),更巧妙的乱序执行和多核架构。

超线程向主机操作系统(OS)提供了一个虚拟的第二个 CPU,并且聪明的硬件逻辑尝试将两个指令线程交错到单个 CPU 的执行单元中。成功时,可以比单线程获得高达 30% 的增益。通常情况下,当两个线程的工作单元使用不同类型的执行单元时,这种方法效果很好——例如,一个执行浮点运算,另一个执行整数运算。

乱序执行使编译器能够发现线性程序序列的某些部分不依赖于前一个工作的结果,因此这两个工作可以以任何顺序或同时发生。只要按时呈现顺序结果,程序就可以继续正确执行,即使工作片段是按照非编程顺序计算的。这使得一些指令可以在其他指令可能被阻塞(例如,等待内存访问)时执行,从而允许更大的可用资源的整体利用率。

对于高级程序员来说,最后也是最重要的是多核架构的普及。这些架构在同一单元内包含多个 CPU,这增加了总体能力,而不会遇到使每个单元变得更快的障碍。这就是为什么目前很难找到少于两个核心的任何机器——在这种情况下,计算机有两个物理计算单元彼此连接。虽然这增加了每秒钟可以完成的总操作数量,但这可能会使编写代码变得更加困难!

图 1-1. CPU 随时间的时钟速度(来自CPU 数据库

简单地向 CPU 添加更多核心并不总是会加快程序的执行时间。这是因为有一种被称为安达尔定律的东西。简单来说,安达尔定律是这样的:如果一个设计为在多个核心上运行的程序有一些子程序必须在一个核心上运行,这将限制通过分配更多核心来实现的最大加速度。

例如,如果我们有一个想让一百人填写的调查问卷,而且每份问卷需要 1 分钟完成,如果我们只有一个人问问题(即这个人去参与者 1,问问题,等待回答,然后移动到参与者 2),我们可以在 100 分钟内完成这个任务。这种一个人问问题并等待回答的方法类似于串行过程。在串行过程中,我们有操作一个接一个地满足,每个操作等待前一个操作完成。

然而,如果我们有两个人来提问,我们可以并行进行调查,这样就能在短短的 50 分钟内完成整个过程。这是可能的,因为每个提问者都不需要知道其他提问者的信息。因此,这个任务可以轻松地分割,而不会有任何依赖关系。

添加更多提问者将会带来更多的加速,直到我们有一百个人提问。在这一点上,过程将花费 1 分钟,并且仅仅受到参与者回答问题所花费的时间的限制。添加更多提问者不会进一步加速,因为这些额外的人将没有任务可执行 —— 所有参与者已经在被提问!在这一点上,减少运行调查的总时间的唯一方法是减少个体调查的时间,即问题的串行部分,以完成。同样地,对于 CPU,我们可以添加更多的核心,根据需要执行各种计算的块,直到我们达到一个核心完成其任务所需的时间的瓶颈点。换句话说,任何并行计算中的瓶颈始终是正在分布的较小的串行任务。

此外,在 Python 中利用多个核心的一个主要障碍是 全局解释器锁(GIL)。GIL 确保 Python 进程一次只能运行一个指令,而不管它当前使用多少个核心。这意味着即使某些 Python 代码同时可以访问多个核心,但任何时候都只有一个核心在运行 Python 指令。使用调查的前述例子,这意味着即使我们有 100 个提问者,也只能有一个人提问并听取答案。这实际上消除了拥有多个提问者的任何好处!虽然这可能看起来是一个相当大的障碍,特别是如果当前计算的趋势是拥有多个计算单元而不是更快的计算单元,但可以通过使用其他标准库工具来避免这个问题,比如 multiprocessing(第九章),像 numpynumexpr(第六章),Cython(第七章),或计算的分布式模型(第十章)。

注意

Python 3.2 还进行了 全局解释器锁(GIL)的重大改写,这使得系统更加灵活,缓解了关于单线程性能的许多担忧。尽管它仍然将 Python 锁定在一次只能运行一个指令的状态,但现在 GIL 在这些指令之间更加高效地切换,并且开销更小。

存储单位

计算机中的内存单元用于存储位。这些位可以是表示程序中变量的位,也可以是表示图像像素的位。因此,内存单元的抽象适用于主板上的寄存器以及 RAM 和硬盘驱动器。所有这些类型的内存单元之间的一个主要区别在于它们读/写数据的速度。为了让事情变得更加复杂,读/写速度严重依赖于数据读取的方式。

例如,大多数内存单元在读取一大块数据时表现得更好,而不是许多小块数据(这称为顺序读随机数据)。如果将这些内存单元中的数据视为大书中的页面,这意味着大多数内存单元在一页一页地翻书时具有更好的读/写速度,而不是不断地从一个随机页面翻到另一个页面。虽然这个事实通常适用于所有类型的内存单元,但这种影响每种类型的程度却是截然不同的。

除了读/写速度之外,内存单元还有延迟,这可以被描述为设备找到正在使用的数据所需的时间。对于旋转硬盘来说,这种延迟可能很高,因为磁盘需要物理上旋转到速度,并且读取头必须移动到正确的位置。另一方面,对于 RAM 来说,这种延迟可以相当小,因为一切都是固态的。以下是标准工作站内常见的各种内存单元的简短描述,按照读/写速度的顺序排列:²

旋转硬盘

长期存储,即使计算机关闭也会保持。由于必须物理旋转和移动磁盘,因此通常具有较慢的读/写速度。随机访问模式下性能下降,但容量非常大(10 TB 范围)。

固态硬盘

类似于旋转硬盘,具有更快的读/写速度但较小的容量(1 TB 范围)。

RAM

用于存储应用程序代码和数据(如正在使用的任何变量)。具有快速读/写特性,并且在随机访问模式下表现良好,但通常容量有限(64 GB 范围)。

L1/L2 缓存

非常快的读/写速度。数据传输到 CPU 必须 经过这里。非常小的容量(兆字节范围)。

图 1-2 通过查看当前可用消费类硬件的特性,给出了这些类型内存单元之间差异的图形表示。

显而易见的趋势是,读/写速度和容量成反比—我们试图增加速度时,容量就会减少。因此,许多系统采用分层存储的方法:数据最初以完整状态存储在硬盘中,部分数据移至 RAM,然后其中的一小部分再移至 L1/L2 缓存。这种分层存储的方法使得程序可以根据访问速度的要求将内存保留在不同的位置。当试图优化程序的内存模式时,我们只是在优化数据放置在哪里、如何布局(以增加连续读取的次数)以及在各个位置之间移动多少次。此外,诸如异步 I/O 和抢占式缓存等方法提供了确保数据始终位于需要的位置的方式,而无需浪费计算时间——大多数这些过程可以独立进行,而其他计算正在执行!

内存特性

图 1-2. 不同类型内存单元的特征值(值来自 2014 年 2 月)

通信层

最后,让我们看看所有这些基本模块是如何相互通信的。存在许多通信模式,但所有这些模式都是对一种称为总线的东西的变体。

例如,前端总线是 RAM 和 L1/L2 缓存之间的连接。它将准备好被处理器转换的数据移入到准备进行计算的暂存区,并将已完成的计算移出。还有其他总线,例如外部总线,它是从硬件设备(如硬盘驱动器和网络卡)到 CPU 和系统内存的主要路由。这个外部总线通常比前端总线慢。

实际上,L1/L2 缓存的许多好处都归因于更快的总线。在缓慢的总线上(从 RAM 到缓存)可以排队等待计算所需数据的大块数据,然后在非常快速的缓存行(从缓存到 CPU)中可以访问这些数据,这使得 CPU 在无需等待太长时间的情况下可以进行更多计算。

类似地,使用 GPU 的许多缺点来自其连接的总线:由于 GPU 通常是一个外围设备,它通过 PCI 总线进行通信,而 PCI 总线比前端总线慢得多。因此,将数据传输到 GPU 和从 GPU 传输数据可能是一项相当耗费精力的操作。异构计算的出现,或者在前端总线上同时具有 CPU 和 GPU 的计算块,旨在降低数据传输成本,并使 GPU 计算成为更可行的选项,即使在必须传输大量数据时也是如此。

除了计算机内部的通信块之外,网络也可以被视为另一个通信块。然而,与先前讨论过的通信块不同,这个通信块更加灵活;网络设备可以连接到存储设备,比如网络附加存储(NAS)设备,或者连接到集群中的计算节点。然而,网络通信通常比先前提到的其他类型的通信慢得多。虽然前端总线可以每秒传输几十个千兆位,但网络的传输速度仅限于数十兆位的数量级。

总之,巴士的主要属性显而易见:它的速度,即在一定时间内能够传输多少数据。这一属性由两个量合成:一次传输可以移动多少数据(巴士宽度)和巴士每秒可以进行多少次传输(巴士频率)。需要注意的是,一次传输的数据总是顺序的:从内存中读取一块数据并移动到另一个地方。因此,巴士的速度被分解为这两个量,因为它们各自可以影响计算的不同方面:较大的巴士宽度可以帮助矢量化代码(或任何顺序读取内存的代码),因为它可以在一次传输中移动所有相关数据;另一方面,虽然巴士宽度较小,但传输频率非常高,可以帮助那些必须从内存的随机部分进行多次读取的代码。有趣的是,这些属性被计算机设计师改变的一种方式是通过主板的物理布局:当芯片彼此靠近放置时,连接它们的物理导线长度较短,这可以实现更快的传输速度。此外,导线的数量本身决定了巴士的宽度(给“巴士宽度”这一术语赋予了真实的物理意义!)。

由于接口可以调整以提供特定应用程序所需的正确性能,因此不足为奇,会有数百种类型存在。图 1-3 显示了几种常见接口的比特率。请注意,这并没有涉及连接的延迟,后者决定了数据请求的响应时间(尽管延迟非常依赖于计算机本身,但某些基本限制是固有于所使用的接口的)。

连接速度

图 1-3. 各种常见接口的连接速度³

将基本元素组合在一起

仅了解计算机的基本组件是不足以完全理解高性能编程问题的。所有这些组件之间的相互作用以及它们如何共同解决问题,引入了额外的复杂性。在本节中,我们将探讨一些玩具问题,说明理想解决方案将如何工作以及 Python 如何处理它们。

警告:这一部分可能看起来暗淡无光 —— 这一部分的大多数备注似乎都在说 Python 本质上无法处理性能问题。这是不正确的,有两个原因。首先,在所有“高性能计算组件”中,我们忽略了一个非常重要的组件:开发人员。原生 Python 在性能上可能缺乏,但它立即以开发速度弥补了这一点。此外,在整本书中,我们将介绍可以相对轻松地缓解许多在此描述的问题的模块和理念。通过结合这两个方面,我们将保持 Python 的快速开发思维方式,同时消除许多性能限制。

理想化计算与 Python 虚拟机

为了更好地理解高性能编程的组成部分,让我们看一个简单的代码示例,检查一个数是否为素数:

import math

def check_prime(number):
    sqrt_number = math.sqrt(number)
    for i in range(2, int(sqrt_number) + 1):
        if (number / i).is_integer():
            return False
    return True

print(f"check_prime(10,000,000) = {check_prime(10_000_000)}")
# check_prime(10,000,000) = False
print(f"check_prime(10,000,019) = {check_prime(10_000_019)}")
# check_prime(10,000,019) = True

让我们使用我们的抽象计算模型来分析这段代码,然后将其与 Python 运行此代码时发生的情况进行比较。与任何抽象一样,我们将忽略理想化计算机和 Python 运行代码的许多微妙之处。然而,在解决问题之前进行这种抽象思考通常是一个好方法:思考算法的一般组件以及计算组件共同解决问题的最佳方式是什么。通过理解这种理想情况,并了解 Python 底层实际发生的情况,我们可以迭代地将我们的 Python 代码接近最佳代码。

理想化计算

当代码启动时,我们在 RAM 中存储了number的值。为了计算sqrt_number,我们需要将number的值发送到 CPU。理想情况下,我们可以只发送一次值;它将被存储在 CPU 的 L1/L2 缓存中,并且 CPU 将进行计算,然后将值发送回 RAM 以进行存储。这种情况是理想的,因为我们最小化了从 RAM 读取number值的次数,而是选择了从 L1/L2 缓存读取,后者速度要快得多。此外,我们通过使用直接连接到 CPU 的 L1/L2 缓存,最小化了通过前端总线传输数据的次数。

提示

在优化方面,保持数据在需要的地方并尽量少移动数据的概念非常重要。“重数据”的概念指的是移动数据所需的时间和精力,这是我们希望避免的。

对于代码中的循环,我们希望将number值和多个i的值一起发送到 CPU,而不是每次仅发送一个i值。这是可能的,因为 CPU 可以向量化操作而不会增加额外的时间成本,这意味着它可以在同一时钟周期内对多个独立的计算进行操作。因此,我们希望将number发送到 CPU 缓存,以及尽可能多的i值,CPU 缓存可以容纳多少就发送多少。对于每个number/i对,我们将进行除法运算并检查结果是否为整数;然后我们将发送一个信号返回,指示是否确实有任何值是整数。如果是,则函数结束。如果不是,则重复上述过程。通过这种方式,我们只需为许多i的值之一返回一个结果,而不是为每个值都依赖慢速总线。这充分利用了 CPU 在一个时钟周期内向量化计算或在多个数据上运行一条指令的能力。

这个向量化的概念可以通过以下代码进行说明:

import math

def check_prime(number):
    sqrt_number = math.sqrt(number)
    numbers = range(2, int(sqrt_number)+1)
    for i in range(0, len(numbers), 5):
      # the following line is not valid Python code
        result = (number / numbers[i:(i + 5)]).is_integer()
        if any(result):
            return False
    return True

在这里,我们设置处理方式,使得每次处理五个i的值时进行除法运算和整数检查。如果正确进行向量化,CPU 可以一次完成这一行,而不是对每个i进行单独的计算。理想情况下,any(result)操作也应在 CPU 上进行,而不必将结果传输回 RAM。我们将在第六章更详细地讨论向量化的工作原理以及何时有益于你的代码。

Python 的虚拟机

Python 解释器会尽力抽象出正在使用的底层计算元素。程序员在任何时候都不需要担心为数组分配内存、如何安排内存或发送到 CPU 的顺序。这是 Python 的一个优点,因为它让你专注于正在实现的算法。然而,这也导致了巨大的性能成本。

重要的是要意识到,Python 在其核心确实运行一组高度优化的指令。然而,关键在于让 Python 按照正确的顺序执行它们以获得更好的性能。例如,在下面的例子中,很容易看出search_fastsearch_slow运行得更快,仅仅是因为它跳过了不必要的计算,尽管这两种解决方案的运行时间都是O(n)。然而,当涉及到派生类型、特殊的 Python 方法或第三方模块时,情况可能会变得复杂。例如,你能立即判断哪个函数会更快:search_unknown1还是search_unknown2

def search_fast(haystack, needle):
    for item in haystack:
        if item == needle:
            return True
    return False

def search_slow(haystack, needle):
    return_value = False
    for item in haystack:
        if item == needle:
            return_value = True
    return return_value

def search_unknown1(haystack, needle):
    return any((item == needle for item in haystack))

def search_unknown2(haystack, needle):
    return any([item == needle for item in haystack])

通过分析性能并找到更高效的计算方式来识别代码中的慢速区域,类似于发现这些无用操作并将其删除;最终结果相同,但计算和数据传输的数量大大减少。

这种抽象层的一个影响是无法立即实现向量化。我们初始的素数判断例程将针对每个i的值运行一次循环迭代,而不是结合多次迭代。然而,观察抽象化的向量化示例,我们发现它并不是有效的 Python 代码,因为我们不能将浮点数除以列表。外部库如numpy将通过添加执行向量化数学运算的能力来帮助解决这种情况。

此外,Python 的抽象性损害了依赖于保持 L1/L2 缓存填充下一个计算所需相关数据的任何优化。这源于多种因素,首先是 Python 对象在内存中的布局并不是最优的。这是 Python 是一种自动进行垃圾回收的语言的一个后果——内存会在需要时自动分配和释放。这会导致内存碎片化,可能会损害到传输到 CPU 缓存的效果。此外,在任何时候,都没有机会直接在内存中更改数据结构的布局,这意味着总线上的一个传输可能不包含计算所需的所有相关信息,即使它们可能全部适合总线宽度内。⁴

第二个更根本的问题来自于 Python 的动态类型和语言的非编译特性。正如许多 C 程序员多年来所学到的,编译器通常比人更聪明。在编译静态代码时,编译器可以通过许多技巧改变事物的布局以及 CPU 执行某些指令的方式,以进行优化。然而,Python 并不是编译的:更糟糕的是,它具有动态类型,这意味着在运行时可以更改代码功能,从而在算法上推断出任何可能的优化机会变得极其困难。有许多方法可以缓解这个问题,最重要的是使用 Cython,它允许将 Python 代码编译,并允许用户创建“提示”,告诉编译器代码实际上有多动态。

最后,前面提到的 GIL 如果尝试并行化此代码会影响性能。例如,假设我们将代码更改为使用多个 CPU 核心,以便每个核心获得从 2 到 sqrtN 的一部分数字。每个核心可以为其数字块执行计算,然后在所有计算完成后,核心可以比较其计算结果。虽然我们失去了循环的早期终止,因为每个核心不知道是否找到解决方案,但我们可以减少每个核心需要执行的检查数量(如果我们有 M 个核心,则每个核心必须执行 sqrtN / M 次检查)。然而,由于 GIL 的存在,一次只能使用一个核心。这意味着我们实际上正在运行与未并行化版本相同的代码,但我们不再具有早期终止功能。我们可以通过使用多个进程(使用 multiprocessing 模块)而不是多个线程,或者使用 Cython 或外部函数来避免这个问题。

那么为什么要使用 Python?

Python 非常表达丰富且易于学习——新程序员很快发现他们可以在短时间内做很多事情。许多 Python 库封装了用其他语言编写的工具,以便调用其他系统;例如,scikit-learn 机器学习系统封装了 LIBLINEAR 和 LIBSVM(两者均为用 C 编写的),而 numpy 库包括 BLAS 和其他 C 和 Fortran 库。因此,正确使用这些模块的 Python 代码确实可以与相似的 C 代码一样快。

Python 被描述为“电池包含”,因为许多重要的工具和稳定的库都内置在其中。这些包括以下内容:

unicodebytes

内置到核心语言中

array

用于原始类型的内存高效数组

math

基本数学运算,包括一些简单的统计操作

sqlite3

围绕主流 SQL 文件存储引擎 SQLite3 的包装器

collections

包括 deque、counter 和字典变体在内的各种对象

asyncio

使用异步和等待语法支持 I/O 密集型任务的并发

核心语言之外可以找到大量的库,包括这些:

numpy

数值计算 Python 库(与矩阵有关的基础库)

scipy

大量受信任的科学库集合,通常封装了备受尊敬的 C 和 Fortran 库

pandas

用于数据分析的库,类似于 R 的数据框或 Excel 电子表格,构建在 scipynumpy

scikit-learn

快速成为默认的机器学习库,基于 scipy 构建

tornado

提供易于绑定并发的库

PyTorch 和 TensorFlow

来自 Facebook 和 Google 的深度学习框架,具有强大的 Python 和 GPU 支持

NLTKSpaCyGensim

具有深度 Python 支持的自然语言处理库

数据库绑定

用于与几乎所有数据库通信,包括 Redis、MongoDB、HDF5 和 SQL

Web 开发框架

创建网站的高效系统,例如 aiohttpdjangopyramidflasktornado

OpenCV

计算机视觉的绑定

API 绑定

便于访问流行的 Web API,例如 Google、Twitter 和 LinkedIn

有大量托管环境和 Shell 可供选择,以适应各种部署场景,包括以下内容:

  • 标准发行版,可在 http://python.org 获取

  • pipenvpyenvvirtualenv,用于简单、轻量和可移植的 Python 环境

  • Docker,用于简化启动和重现开发或生产环境的环境

  • Anaconda Inc. 的 Anaconda,一个专注于科学的环境

  • Sage,类似 Matlab 的环境,包括集成开发环境(IDE)

  • IPython,科学家和开发人员广泛使用的交互式 Python shell

  • Jupyter Notebook,基于浏览器的 IPython 扩展,广泛用于教学和演示

Python 的主要优势之一是能够快速原型化一个想法。由于有多种支持库的支持,即使第一次实现可能有些不稳定,也很容易测试一个想法是否可行。

如果您想使您的数学例程更快,请考虑 numpy。如果您想尝试机器学习,请试试 scikit-learn。如果您正在清理和操作数据,那么 pandas 是一个很好的选择。

一般来说,提出问题“如果我们的系统运行得更快,我们作为一个团队长远来看会运行得更慢吗?”是明智的。如果投入足够的工时,总是可以从系统中挤取更多的性能,但这可能导致脆弱且理解不足的优化,最终会使团队陷入困境。

一个例子可能是引入 Cython(见 “Cython”),这是一种基于编译器的方法,用于使用类似 C 的类型注释 Python 代码,以便转换后的代码可以使用 C 编译器编译。虽然速度提升可能令人印象深刻(通常可以达到类似 C 的速度,而付出的努力相对较少),但支持此类代码的成本将会增加。特别是,支持这个新模块可能更加困难,因为团队成员需要在编程能力上具备一定的成熟度,以理解离开引入性能提升的 Python 虚拟机时发生的某些权衡。

如何成为一名高效的程序员

编写高性能代码只是长期成功项目中高效性的一部分。整体团队速度比加速和复杂解决方案更为重要。几个关键因素包括良好的结构、文档、调试能力和共享标准。

假设您创建了一个原型。您没有彻底测试它,也没有让您的团队审核它。它似乎“够好”,于是被推送到生产环境。由于从未以结构化的方式编写,它缺乏测试和文档。突然间,这成为一个惯性代码片段,需要其他人支持,而管理层往往无法量化对团队的成本。

由于这种解决方案难以维护,它往往保持不受欢迎——它从未重构过,没有测试帮助团队重构它,也没有其他人喜欢接触它,因此落到一个开发人员手中来维护。这在压力时期可能会引起严重的瓶颈,并提高了一个重大风险:如果那位开发人员离开了项目会发生什么?

典型地,这种开发风格发生在管理团队不理解由难以维护的代码造成的惯性时。在长期来看,测试和文档的证明可以帮助团队保持高效率,并说服管理者分配时间来“清理”这些原型代码。

在研究环境中,通常会使用不良编码实践创建许多 Jupyter Notebooks,以迭代思路和不同数据集。意图始终是在稍后的阶段“正确地写出来”,但这一阶段从未出现。最终得到了一个工作结果,但缺少复现、测试和信任结果的基础设施。再次高风险因素,结果的信任度将会很低。

有一个通用的方法会给您带来很大帮助:

让它运行起来

首先构建一个足够好的解决方案。建议先“建立一个用来丢弃的”原型解决方案,作为第二版本使用更好结构的可能性。在编码之前进行一些前期规划总是明智的;否则,您会发现“我们通过整个下午编码省了一个小时的思考时间。”在某些领域,这更为人熟知为“量一次,切一次”。

做对

接下来,您添加一个强大的测试套件,支持文档和清晰的可复现性说明,以便其他团队成员接手。

做快

最后,我们可以专注于分析和编译,或并行化和使用现有的测试套件来确认新的更快解决方案仍然按预期工作。

良好的工作实践

有几个“必须具备”的要素——文档、良好的结构和测试至关重要。

一些项目级文档将帮助您坚持清晰的结构。这也将帮助您和您的同事未来。如果您跳过这一部分,没有人会感谢您(包括您自己)。在顶层编写一个README文件是一个明智的起点;如果需要的话,随后可以扩展为docs/文件夹。

解释项目的目的,文件夹中包含什么内容,数据的来源,哪些文件至关重要,以及如何运行所有内容,包括如何运行测试。

Micha 还推荐使用 Docker。一个顶级的 Dockerfile 将准确解释需要从操作系统获取哪些库才能使这个项目成功运行。它还消除了在其他机器上运行此代码或部署到云环境中的难度。

添加一个 tests/ 文件夹并添加一些单元测试。我们推荐 pytest 作为现代测试运行器,它建立在 Python 内置的 unittest 模块之上。先从几个测试开始,然后逐步增加。进而使用 coverage 工具,它将报告你的代码有多少行实际被测试覆盖,有助于避免令人不快的惊喜。

如果你继承了旧代码且缺乏测试,那么一个高价值的活动就是首先添加一些测试。一些“集成测试”可以检查项目的整体流程,并确认使用特定输入数据时能够获得特定输出结果,这将有助于你在随后进行修改时保持理智。

每当代码中的某些东西“咬”到你时,添加一个测试。被同一个问题“咬”两次是毫无价值的。

在你的代码中为每个函数、类和模块添加文档字符串将总是对你有帮助。努力提供有用的描述说明函数实现了什么,并在可能时包含一个简短的示例以展示预期的输出。如果需要灵感,可以看看 numpy 和 scikit-learn 中的文档字符串。

每当你的代码变得过长——比如函数超过一个屏幕长度——要习惯性地重构代码使其变得更短。较短的代码更易于测试和维护。

提示

在开发测试时,考虑遵循测试驱动开发方法论。当你确切知道需要开发什么,并且手头有可测试的例子时,这种方法将变得非常高效。

编写测试,运行它们,观察它们失败,然后添加函数和必要的最小逻辑来支持你编写的测试。当你的测试全部通过时,你完成了。通过提前确定函数的预期输入和输出,你将会发现实现函数逻辑相对简单。

如果你无法提前定义你的测试,这自然会引发一个问题:你真的了解你的函数需要做什么吗?如果不是,你能高效地正确编写它吗?如果你正处于创造性过程中并正在研究尚未完全理解的数据,这种方法可能效果不佳。

总是使用源代码控制——当你在不方便的时刻覆盖了一些关键内容时,你只会感谢自己。养成频繁提交(每天或甚至每 10 分钟提交一次)并每天推送到你的代码库的习惯。

遵循标准的 PEP8 编码规范。更好的做法是在预提交源控制钩子上采用 black(一种有见解的代码格式化工具),这样它会自动将你的代码重写为标准格式。使用 flake8 来检查你的代码,避免其他错误。

创建与操作系统隔离的环境将使你的生活更轻松。Ian 偏爱 Anaconda,而 Micha 则喜欢pipenv与 Docker 结合使用。两者都是明智的解决方案,显著优于使用操作系统的全局 Python 环境!

记住自动化是你的朋友。做更少的手动工作意味着出错的机会更少。自动构建系统、与自动化测试套件运行器的持续集成以及自动化部署系统将单调且容易出错的任务转变为任何人都可以运行和支持的标准流程。

最后,请记住可读性远比聪明更重要。复杂且难以阅读的短代码片段将使你和你的同事难以维护,因此人们会害怕触碰这些代码。相反,写一个更长、更易读的函数,并配以有用的文档显示它将返回什么,并通过测试来确认它确实按照你的预期工作。

关于良好 Notebook 实践的一些想法

如果你正在使用 Jupyter Notebooks,它们非常适合视觉交流,但也容易使人变得懒惰。如果你发现自己在 Notebooks 中留下了长函数,请放心地将它们提取到一个 Python 模块中,然后添加测试。

考虑在 IPython 或 QTConsole 中原型化你的代码;将代码行转换为 Notebook 中的函数,然后将它们提升到一个模块中,并通过测试来补充。最后,如果封装和数据隐藏是有用的,考虑将代码包装在一个类中。

在 Notebook 中大量使用assert语句来检查你的函数是否按预期行为。在将函数重构到单独的模块之前,assert检查是添加某种验证级别的简单方法。在将其提取到模块并编写合理的单元测试之前,你不应该信任这段代码。

使用assert语句来检查代码中的数据应该受到批评。这是一种断言某些条件是否满足的简单方法,但它并不符合 Python 的惯用法。为了让其他开发人员更容易阅读你的代码,检查你的预期数据状态,如果检查失败,则引发适当的异常。如果函数遇到意外值,常见的异常可能是ValueErrorBulwark 库是一个以 Pandas 为重点的测试框架示例,用于检查你的数据是否符合指定的约束条件。

你可能还想在 Notebook 的末尾添加一些健全性检查——一些逻辑检查和raise以及print语句的混合,这些语句表明你刚生成了确实需要的内容。当你在六个月后回到这段代码时,你会感谢自己让它易于看出它始终正确工作!

使用 Notebooks 时的一个困难是与源代码控制系统共享代码。nbdime是一组新工具中的一员,它可以让你比较你的 Notebooks。它是一个救命稻草,能够与同事进行协作。

让工作再次充满乐趣

生活可能很复杂。在我们作者撰写本书第一版五年来,我们通过朋友和家人共同经历了许多生活事件,包括抑郁、癌症、家庭搬迁、成功的企业退出和失败,以及职业方向的转变。不可避免地,这些外部事件会影响任何人的工作和对生活的看法。

记得继续寻找新活动中的乐趣。一旦你开始深入探索,总能找到有趣的细节或要求。你可能会问:“为什么他们做出那个决定?”以及“我会怎么做得更好?”突然间,你就准备好开始探讨如何改变或改进事物了。

记录值得庆祝的事情。忘记成就并被日常琐事所困扰是很容易的。人们之所以会烧尽自己,是因为他们总是为了跟上而奔波,并忘记了自己所取得的多少进步。

我们建议你建立一个值得庆祝的事项清单,并记录你如何庆祝它们。伊恩就保留了这样一个清单——当他去更新这个清单时,他会惊喜地发现在过去的一年里发生了多少酷炫的事情(这些事情本来可能会被遗忘!)。这些庆祝不仅限于工作里的里程碑;包括爱好、运动,以及你取得的各种成就。米卡确保优先处理个人生活,每天远离电脑,专注于非技术项目的工作。保持发展你的技能集团至关重要,但烧尽自己并非必要!

编程,特别是在性能方面,靠的是一种好奇心和深入技术细节的意愿。不幸的是,当你烧尽了激情时,这种好奇心往往是第一个消失的;所以,请花些时间确保你享受这个旅程,并保持快乐和好奇心。

¹ 不要与进程间通信搞混,虽然它们共用同一个缩写——我们将在第九章中探讨这个主题。

² 本节的速度来自https://oreil.ly/pToi7

³ 数据来自https://oreil.ly/7SC8d

⁴ 在第六章中,我们将看到如何重新获得控制权,并调整我们的代码,一直到内存利用模式。

第二章:找出瓶颈的性能分析

性能分析能帮助我们找出瓶颈,使我们可以尽最少的努力获得最大的实际性能提升。虽然我们希望能够在速度上取得巨大进步并在资源使用上做出大幅度减少,但实际上,您会为了使代码“足够快”和“足够精简”来满足您的需求而努力。性能分析将帮助您为最少的总体工作量做出最为务实的决策。

任何可测量的资源都可以进行性能分析(不仅仅是 CPU!)。在这一章中,我们将研究 CPU 时间和内存使用情况。您也可以应用类似的技术来测量网络带宽和磁盘 I/O。

如果程序运行太慢或者使用了太多 RAM,您将希望修复您代码中负责这些问题的部分。当然,您可以跳过性能分析并修复您认为可能是问题的地方,但要小心,因为您往往会“修复”错误的东西。与其依赖您的直觉,最明智的做法是首先进行性能分析,定义了一个假设,然后再对代码结构进行更改。

有时候偷懒是件好事。通过先进行性能分析,您可以快速识别需要解决的瓶颈,然后您只需解决这些问题中的足够部分以达到所需的性能。如果您避免性能分析而直接进行优化,长远来看您很可能会做更多的工作。始终根据性能分析的结果来驱动您的工作。

高效的性能分析

性能分析的第一个目标是测试一个代表性系统,以确定什么地方慢(或者使用了太多 RAM,或者导致了过多的磁盘 I/O 或网络 I/O)。性能分析通常会增加开销(典型情况下可以是 10 倍到 100 倍的减速),而您仍希望您的代码在尽可能接近实际情况的情况下使用。提取一个测试用例,并隔离需要测试的系统部分。最好情况下,它们已经被编写为独立的模块集。

本章首先介绍的基本技术包括 IPython 中的%timeit魔术命令,time.time()和一个计时装饰器。您可以使用这些技术来理解语句和函数的行为。

然后,我们将介绍cProfile(“使用 cProfile 模块”),向您展示如何使用这个内置工具来理解代码中哪些函数运行时间最长。这将为您提供一个高层次的视图,以便您可以将注意力集中在关键函数上。

接下来,我们将介绍line_profiler(“使用 line_profiler 进行逐行测量”),它将逐行地对您选择的函数进行性能分析。结果将包括每行被调用的次数和每行所花费的时间百分比。这正是您需要了解什么正在运行缓慢以及为什么的信息。

有了line_profiler的结果,您将拥有继续使用编译器所需的信息(第七章)。

在第六章中,您将学习如何使用 perf stat 来了解在 CPU 上执行的指令数量以及 CPU 缓存的有效利用情况。这允许您对矩阵操作进行高级调优。完成本章后,您应该看看示例 6-8。

在使用 line_profiler 后,如果您正在处理长时间运行的系统,那么您可能会对 py-spy 感兴趣,以窥视已经运行的 Python 进程。

为了帮助您理解为什么 RAM 使用量很高,我们将展示 memory_profiler(“使用 memory_profiler 诊断内存使用情况”)。它特别适用于在标记的图表上跟踪 RAM 使用情况,以便您向同事解释为什么某些函数使用的 RAM 比预期多。

警告

无论您采取何种方法来分析您的代码,您必须记住在您的代码中拥有充分的单元测试覆盖率。单元测试帮助您避免愚蠢的错误,并保持您的结果可重现性。不要因为它们而忽视它们。

在编译或重写算法之前始终对您的代码进行分析。您需要证据来确定使您的代码运行更快的最有效方法。

接下来,我们将向您介绍 Python 字节码在 CPython 中的使用(“使用 dis 模块检查 CPython 字节码”),以便您了解“底层”发生了什么。特别是,了解 Python 基于栈的虚拟机如何运行将帮助您理解为什么某些编码风格运行速度较慢。

在本章结束之前,我们将回顾如何在优化过程中集成单元测试以保持代码正确性(“在优化过程中进行单元测试以保持正确性”)。

我们最后将讨论性能分析策略(“成功分析代码的策略”),以便您可以可靠地分析您的代码并收集正确的数据来测试您的假设。在这里,您将了解到动态 CPU 频率缩放和 Turbo Boost 等功能如何影响您的分析结果,以及如何禁用它们。

要走完所有这些步骤,我们需要一个易于分析的函数。下一节介绍了朱利亚集。它是一个对 RAM 需求较高的 CPU 绑定函数;它还表现出非线性行为(因此我们不能轻易预测结果),这意味着我们需要在运行时而不是离线分析它。

介绍朱利亚集

朱利亚集 是一个我们开始的有趣的 CPU 绑定问题。它是一个产生复杂输出图像的分形序列,以加斯顿·朱利亚命名。

接下来的代码比你自己可能写的版本稍长一些。它包含一个 CPU 绑定的组件和一个非常明确的输入集。这种配置使我们能够分析 CPU 和 RAM 的使用情况,从而理解我们的代码中哪些部分消耗了我们稀缺的计算资源。这个实现故意地不够优化,这样我们可以识别出消耗内存的操作和执行缓慢的语句。在本章的后面,我们将修复一个执行缓慢的逻辑语句和一个消耗内存的语句,在第七章中,我们将显著加快此函数的总执行时间。

我们将分析一个生成假灰度图(图 2-1)和朱利亚集的纯灰度变体(图 2-3)的代码块,复数点为c=-0.62772-0.42193j。朱利亚集通过独立计算每个像素来生成;这是一个“尴尬并行问题”,因为点之间没有共享数据。

Julia set at -0.62772-0.42193i

图 2-1. 朱利亚集的绘图,使用灰度假色以突出细节

如果我们选择了不同的c,我们会得到不同的图像。我们选择的位置有些区域计算迅速,而其他区域计算缓慢;这对我们的分析很有用。

这个问题很有趣,因为我们通过应用可能无数次的循环来计算每个像素。在每次迭代中,我们测试这个坐标的值是否朝无穷远处逃逸,或者是否被某个吸引子吸引住。导致少量迭代的坐标在图 2-1 中被着深色,而导致大量迭代的坐标则被着白色。白色区域的计算更复杂,因此生成时间更长。

我们定义了一组将要测试的z坐标。我们计算的函数对复数z进行平方,并加上c

f ( z ) = z 2 + c

我们在测试时迭代这个函数,同时使用abs检测是否满足逃逸条件。如果逃逸函数为False,我们跳出循环并记录在这个坐标处执行的迭代次数。如果逃逸函数从未为False,我们将在maxiter次迭代后停止。我们随后会将这个z的结果转换为一个表示这个复数位置的彩色像素。

伪代码可能是这样的:

for z in coordinates:
    for iteration in range(maxiter):  # limited iterations per point
        if abs(z) < 2.0:  # has the escape condition been broken?
            z = z*z + c
        else:
            break
    # store the iteration count for each z and draw later

要解释这个函数,让我们尝试两个坐标。

我们将使用绘图左上角的坐标-1.8-1.8j。在我们尝试更新规则之前,我们必须测试abs(z) < 2

z = -1.8-1.8j
print(abs(z))
2.54558441227

我们可以看到,对于左上角的坐标,abs(z)测试在零次迭代时将为False,因为2.54 >= 2.0,所以我们不执行更新规则。这个坐标的output值为0

现在让我们跳到绘图的中心,z = 0 + 0j,并尝试几次迭代:

c = -0.62772-0.42193j
z = 0+0j
for n in range(9):
    z = z*z + c
    print(f"{n}: z={z: .5f}, abs(z)={abs(z):0.3f}, c={c: .5f}")
0: z=-0.62772-0.42193j, abs(z)=0.756, c=-0.62772-0.42193j
1: z=-0.41171+0.10778j, abs(z)=0.426, c=-0.62772-0.42193j
2: z=-0.46983-0.51068j, abs(z)=0.694, c=-0.62772-0.42193j
3: z=-0.66777+0.05793j, abs(z)=0.670, c=-0.62772-0.42193j
4: z=-0.18516-0.49930j, abs(z)=0.533, c=-0.62772-0.42193j
5: z=-0.84274-0.23703j, abs(z)=0.875, c=-0.62772-0.42193j
6: z= 0.02630-0.02242j, abs(z)=0.035, c=-0.62772-0.42193j
7: z=-0.62753-0.42311j, abs(z)=0.757, c=-0.62772-0.42193j
8: z=-0.41295+0.10910j, abs(z)=0.427, c=-0.62772-0.42193j

我们可以看到,对于这些首次迭代的每次更新z,它的值都满足abs(z) < 2True。对于这个坐标,我们可以迭代 300 次,测试仍为True。我们无法确定在条件变为False之前必须执行多少次迭代,这可能是一个无限序列。最大迭代(maxiter)中断子句将阻止我们无限迭代。

在图 2-2 中,我们看到了前 50 次迭代的序列。对于0+0j(实线与圆形标记),序列似乎每 8 次迭代重复一次,但每个 7 次计算序列与前一个序列有轻微偏差——我们无法确定这个点是否会在边界条件内永远迭代,或者长时间迭代,或者只是再迭代几次。虚线cutoff线显示在+2处的边界。

julia non convergence

图 2-2. Julia 集的两个坐标示例演变

对于-0.82+0j(虚线与菱形标记),我们可以看到在第九次更新后,绝对结果超过了+2截止值,因此我们停止更新此值。

计算完整的 Julia 集

在本节中,我们将分解生成 Julia 集的代码。我们将在本章的各个部分进行多方面的分析。如示例 2-1 所示,在模块的开头,我们导入time模块以进行第一个性能分析,并定义一些坐标常量。

示例 2-1. 为坐标空间定义全局常量
"""Julia set generator without optional PIL-based image drawing"""
import time

# area of complex space to investigate
x1, x2, y1, y2 = -1.8, 1.8, -1.8, 1.8
c_real, c_imag = -0.62772, -.42193

为了生成图形,我们创建两个输入数据列表。第一个是zs(复数z坐标),第二个是cs(复杂初始条件)。这两个列表都不变,我们可以将cs优化为单个c值作为常量。构建两个输入列表的原因是,当我们在本章后面分析 RAM 使用情况时,我们有一些合理的数据进行性能分析。

要构建zscs列表,我们需要知道每个z的坐标。在示例 2-2 中,我们使用xcoordycoord以及指定的x_stepy_step来构建这些坐标。这种设置有点冗长,但在将代码移植到其他工具(如numpy)和其他 Python 环境时很有用,因为它有助于为调试清晰地定义一切。

示例 2-2. 将坐标列表建立为我们计算函数的输入
def calc_pure_python(desired_width, max_iterations):
    """Create a list of complex coordinates (zs) and complex parameters (cs),
 build Julia set"""
    x_step = (x2 - x1) / desired_width
    y_step = (y1 - y2) / desired_width
    x = []
    y = []
    ycoord = y2
    while ycoord > y1:
        y.append(ycoord)
        ycoord += y_step
    xcoord = x1
    while xcoord < x2:
        x.append(xcoord)
        xcoord += x_step
    # build a list of coordinates and the initial condition for each cell.
    # Note that our initial condition is a constant and could easily be removed,
    # we use it to simulate a real-world scenario with several inputs to our
    # function
    zs = []
    cs = []
    for ycoord in y:
        for xcoord in x:
            zs.append(complex(xcoord, ycoord))
            cs.append(complex(c_real, c_imag))

    print("Length of x:", len(x))
    print("Total elements:", len(zs))
    start_time = time.time()
    output = calculate_z_serial_purepython(max_iterations, zs, cs)
    end_time = time.time()
    secs = end_time - start_time
    print(calculate_z_serial_purepython.__name__ + " took", secs, "seconds")

    # This sum is expected for a 1000² grid with 300 iterations
    # It ensures that our code evolves exactly as we'd intended
    assert sum(output) == 33219980

构建了zscs列表后,我们输出了关于列表大小的一些信息,并通过calculate_z_serial_purepython计算了output列表。最后,我们对output的内容进行了sumassert其与预期输出值匹配。Ian 在这里使用它来确认书中没有错误。

因为代码是确定性的,我们可以通过对所有计算出的值求和来验证函数按预期工作。这在检查代码中的数值变化时非常有用,非常明智,以确保算法没有出错。理想情况下,我们会使用单元测试,并测试问题的多种配置。

接下来,在示例 2-3 中,我们定义了calculate_z_serial_purepython函数,该函数扩展了我们之前讨论的算法。值得注意的是,我们还在开头定义了一个与输入zscs列表长度相同的output列表。

示例 2-3. 我们的 CPU 绑定计算函数
def calculate_z_serial_purepython(maxiter, zs, cs):
    """Calculate output list using Julia update rule"""
    output = [0] * len(zs)
    for i in range(len(zs)):
        n = 0
        z = zs[i]
        c = cs[i]
        while abs(z) < 2 and n < maxiter:
            z = z * z + c
            n += 1
        output[i] = n
    return output

现在我们在示例 2-4 中调用计算例程。通过将其包装在__main__检查中,我们可以安全地导入模块,而不会启动某些分析方法的计算。这里我们不展示用于绘制输出的方法。

示例 2-4. 我们代码的__main__
if __name__ == "__main__":
    # Calculate the Julia set using a pure Python solution with
    # reasonable defaults for a laptop
    calc_pure_python(desired_width=1000, max_iterations=300)

一旦我们运行代码,就会看到有关问题复杂性的一些输出:

# running the above produces:
Length of x: 1000
Total elements: 1000000
calculate_z_serial_purepython took 8.087012767791748 seconds

在错误的灰度图(图例 2-1)中,高对比度的颜色变化使我们了解了函数成本变化缓慢或迅速的位置。在这里,我们使用线性颜色映射的图例 2-3 中,黑色计算速度快,白色计算费时。

通过展示相同数据的两种表示,我们可以看到线性映射中丢失了大量细节。在调查函数成本时,有时记住多种表示形式是有用的。

Julia 集合在-0.62772-0.42193i 处

图 2-3. 使用纯灰度绘制的 Julia 图例

时间测量的简单方法——打印和装饰器

在示例 2-4 之后,我们看到了代码中几个print语句生成的输出。在 Ian 的笔记本电脑上,此代码使用 CPython 3.7 运行大约需要 8 秒。需要注意的是,执行时间总是会有所变化。在计时代码时,必须注意正常的变化,否则可能会错误地将代码改进归因为执行时间的随机变化。

在运行代码时,您的计算机会执行其他任务,例如访问网络、磁盘或 RAM,这些因素会导致程序执行时间的变化。

Ian 的笔记本电脑是戴尔 9550,配有 Intel Core I7 6700HQ 处理器(2.6 GHz,6 MB 缓存,四核支持超线程)和 32 GB RAM,运行 Linux Mint 19.1(Ubuntu 18.04)。

calc_pure_python中(示例 2-2),我们可以看到几个print语句。这是测量函数内部代码执行时间的最简单方法。虽然这是一种快速而不太精确的方法,但在初次查看代码时非常有用。

在调试和性能分析代码时,使用print语句很常见。它快速变得难以管理,但对于短期调查非常有用。在完成时,请尽量整理print语句,否则它们会杂乱你的stdout

一种稍微更清晰的方法是使用装饰器——在这里,我们在我们关心的函数上面添加一行代码。我们的装饰器可以非常简单,只需复制print语句的效果。稍后,我们可以使其更为高级。

在示例 2-5 中,我们定义了一个新的函数timefn,它将一个函数作为参数:内部函数measure_time接受*args(可变数量的位置参数)和**kwargs(可变数量的关键字参数),并将它们传递给fn以执行。在执行fn周围,我们捕获time.time()并且print结果以及fn.__name__。使用这个装饰器的开销很小,但如果你调用fn数百万次,这种开销可能会变得明显。我们使用@wraps(fn)来将函数名和文档字符串暴露给装饰函数的调用者(否则,我们将会看到装饰器的函数名和文档字符串,而不是它装饰的函数)。

示例 2-5. 定义一个装饰器来自动化计时测量
from functools import wraps

def timefn(fn):
    @wraps(fn)
    def measure_time(*args, **kwargs):
        t1 = time.time()
        result = fn(*args, **kwargs)
        t2 = time.time()
        print(f"@timefn: {fn.__name__} took {t2 - t1} seconds")
        return result
    return measure_time

@timefn
def calculate_z_serial_purepython(maxiter, zs, cs):
    ...

当我们运行这个版本(我们保留了以前的print语句)时,我们可以看到装饰版本的执行时间略微比从calc_pure_python调用快。这是由于调用函数的开销(差异非常微小)造成的。

Length of x: 1000
Total elements: 1000000
@timefn:calculate_z_serial_purepython took 8.00485110282898 seconds
calculate_z_serial_purepython took 8.004898071289062 seconds
注意

添加分析信息无疑会减慢代码执行速度——某些分析选项非常详细,并且会导致严重的速度惩罚。分析详细程度和速度之间的权衡是你必须考虑的事情。

我们可以使用timeit模块作为另一种获取 CPU 绑定函数执行速度粗略测量的方法。更典型的情况下,你会在尝试解决问题的不同方式时,使用它来计时不同类型的简单表达式。

警告

timeit模块暂时禁用垃圾回收器。如果垃圾回收器通常会被你的操作调用,那么这可能会影响你在实际操作中看到的速度。请参阅Python 文档获取相关帮助。

从命令行,你可以像下面这样运行timeit

python -m timeit -n 5 -r 1 -s "import julia1" \
 "julia1.calc_pure_python(desired_width=1000, max_iterations=300)"

注意,你必须使用-s将模块导入为设置步骤,因为calc_pure_python就在那个模块里。对于长时间运行的函数,timeit有一些合理的默认设置,但对于更长时间的函数,可以明智地指定循环次数(-n 5)和重复次数(-r 5)来重复实验。所有重复实验中的最佳结果将作为答案给出。添加详细标志(-v)会显示每个重复实验中所有循环的累积时间,这有助于了解结果的变化。

默认情况下,如果我们在这个函数上运行 timeit 而不指定 -n-r,它会运行 10 次循环,每次重复 5 次,完成时间大约为 6 分钟。如果你想更快地获得结果,覆盖默认设置可能是有意义的。

我们只对最佳情况的结果感兴趣,因为其他结果可能已经受到其他进程的影响:

5 loops, best of 1: 8.45 sec per loop

尝试多次运行基准测试以检查是否得到不同的结果——你可能需要更多的重复次数来获得稳定的最快结果时间。没有“正确”的配置,所以如果你看到计时结果有很大的变化,请增加重复次数,直到最终结果稳定。

我们的结果显示,调用 calc_pure_python 的总耗时为 8.45 秒(作为最佳情况),而单个调用 calculate_z_serial_purepython@timefn 装饰器测量的时间为 8.0 秒。差异主要是用于创建 zscs 列表的时间。

在 IPython 中,我们可以以同样的方式使用魔术 %timeit。如果你在 IPython 或 Jupyter Notebook 中交互式地开发代码,可以使用这个:

In [1]: import julia1
In [2]: %timeit julia1.calc_pure_python(desired_width=1000, max_iterations=300)
警告

要注意,“最佳”是由 timeit.py 方法和 Jupyter 和 IPython 中 %timeit 方法不同计算得出的。timeit.py 使用最小值。2016 年起,IPython 改为使用均值和标准差。两种方法都有其缺陷,但总体上它们都“相当不错”;尽管如此,不能直接比较它们。选择一种方法使用,不要混用。

值得考虑的是,普通计算机的负载变化。许多后台任务正在运行(例如 Dropbox、备份),可能会随机影响 CPU 和磁盘资源。网页中的脚本也可能导致不可预测的资源使用。图 2-4 显示此计算机上单个 CPU 在我们刚刚执行的某些计时步骤中使用了 100%,而该机器上的其他核心则轻度工作于其他任务。

Ubuntu 中的系统监视器显示定时期间的后台 CPU 使用情况

图 2-4. Ubuntu 上的系统监视器显示我们计时函数时后台 CPU 使用变化

有时,系统监视器显示此计算机上的活动突然增加。检查系统监视器以确保没有其他东西干扰你的关键资源(CPU、磁盘、网络)是明智的做法。

使用 Unix 时间命令进行简单计时

我们可以暂时离开 Python,使用 Unix-like 系统上的标准系统实用程序。以下内容将记录程序的执行时间的各种视图,并且不会关心代码的内部结构:

$ /usr/bin/time -p python julia1_nopil.py
Length of x: 1000
Total elements: 1000000
calculate_z_serial_purepython took 8.279886722564697 seconds
real 8.84
user 8.73
sys 0.10

注意,我们特别使用 /usr/bin/time 而不是 time,以便获取系统的 time 而不是我们的 shell 中较简单(且不太有用)的版本。如果你尝试 time --verbose 时出现错误,你可能正在看 shell 的内置 time 命令,而不是系统命令。

使用 -p 可移植性标志,我们得到三个结果:

  • real 记录了实际经过的墙上或经过的时间。

  • user 记录了 CPU 在执行您的任务时在内核函数之外所花费的时间。

  • sys 记录了在内核级别函数中花费的时间。

通过添加 usersys,您可以了解 CPU 花费了多少时间。这与 real 的差异可能告诉您有多少时间花在等待 I/O 上;这也可能表明您的系统正在忙于运行其他扭曲您测量的任务。

time 非常有用,因为它不特定于 Python。它包括启动 python 可执行文件所花费的时间,如果您启动了大量的新进程(而不是运行长时间的单个进程),这可能是重要的。如果您经常运行短暂的脚本,启动时间可能是整体运行时间的一个重要部分,那么 time 可以是一个更有用的度量。

我们可以添加 --verbose 标志来获得更多输出:

$ /usr/bin/time --verbose python julia1_nopil.py
Length of x: 1000
Total elements: 1000000
calculate_z_serial_purepython took 8.477287530899048 seconds
 Command being timed: "python julia1_nopil.py"
 User time (seconds): 8.97
 System time (seconds): 0.05
 Percent of CPU this job got: 99%
 Elapsed (wall clock) time (h:mm:ss or m:ss): 0:09.03
 Average shared text size (kbytes): 0
 Average unshared data size (kbytes): 0
 Average stack size (kbytes): 0
 Average total size (kbytes): 0
 Maximum resident set size (kbytes): 98620
 Average resident set size (kbytes): 0
 Major (requiring I/O) page faults: 0
 Minor (reclaiming a frame) page faults: 26645
 Voluntary context switches: 1
 Involuntary context switches: 27
 Swaps: 0
 File system inputs: 0
 File system outputs: 0
 Socket messages sent: 0
 Socket messages received: 0
 Signals delivered: 0
 Page size (bytes): 4096
 Exit status: 0

这里可能最有用的指标是 Major (requiring I/O) page faults,因为这表明操作系统是否不得不从磁盘加载数据页,因为数据不再驻留在 RAM 中。这将导致速度降低。

在我们的示例中,代码和数据要求很小,因此没有页面错误发生。如果您有一个内存绑定的进程,或者有几个使用变量和大量 RAM 的程序,您可能会发现这为您提供了一个线索,即由于它的部分已经被交换到磁盘而减慢了操作系统级别的磁盘访问速度。

使用 cProfile 模块

cProfile 是标准库中内置的性能分析工具。它连接到 CPython 虚拟机,测量每个函数的运行时间。这会引入更大的开销,但您将得到更多对应的信息。有时,额外的信息可以带来对代码的惊人洞察。

cProfile 是标准库中的两个性能分析器之一,另一个是 profileprofile 是原始的纯 Python 性能分析器,速度较慢;cProfile 具有与 profile 相同的接口,但由于用 C 编写,开销较低。如果您对这些库的历史感兴趣,请参见 Armin Rigo's 2005 request 来包含 cProfile 在标准库中。

在分析性能时一个好的实践是在分析之前对您的代码的速度生成一个假设。Ian 喜欢打印出相关的代码片段并加以注释。提前形成假设意味着您可以测量您的错误程度(而您将会!)并改进您对某些编码风格的直觉。

警告

您绝不应该避免使用性能分析来取代直觉(我们警告您——您肯定会错!)。提前形成假设以帮助您学会识别代码中可能慢的选择是绝对值得的,您应该始终用证据支持您的选择。

始终依据你测量到的结果进行驱动,并始终从一些快速而粗糙的分析开始,以确保你正在解决正确的问题。没有比聪明地优化代码的一个部分,然后意识到(数小时或数天后)你错过了最慢的部分,实际上根本没有解决底层问题更令人感到羞愧的事情了。

假设calculate_z_serial_purepython是代码中最慢的部分。在这个函数中,我们进行了大量的解引用操作,并调用了许多基本算术运算符和abs函数。这些可能会显示为 CPU 资源的消耗者。

在这里,我们将使用cProfile模块来运行代码的一个变体。输出很简单,但帮助我们找出需要进一步分析的地方。

-s cumulative标志告诉cProfile按累积时间对每个函数进行排序;这使我们能够查看代码部分中最慢的部分。cProfile的输出直接写入屏幕,紧随我们通常的print结果之后。

$ python -m cProfile -s cumulative julia1_nopil.py
...
Length of x: 1000
Total elements: 1000000
calculate_z_serial_purepython took 11.498265266418457 seconds
         36221995 function calls in 12.234 seconds

  Ordered by: cumulative time

  ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       1    0.000    0.000   12.234   12.234 {built-in method builtins.exec}
       1    0.038    0.038   12.234   12.234 julia1_nopil.py:1(<module>)
       1    0.571    0.571   12.197   12.197 julia1_nopil.py:23
                                              (calc_pure_python)
       1    8.369    8.369   11.498   11.498 julia1_nopil.py:9
                                              (calculate_z_serial_purepython)
34219980    3.129    0.000    3.129    0.000 {built-in method builtins.abs}
 2002000    0.121    0.000    0.121    0.000 {method 'append' of 'list' objects}
       1    0.006    0.006    0.006    0.006 {built-in method builtins.sum}
       3    0.000    0.000    0.000    0.000 {built-in method builtins.print}
       2    0.000    0.000    0.000    0.000 {built-in method time.time}
       4    0.000    0.000    0.000    0.000 {built-in method builtins.len}
       1    0.000    0.000    0.000    0.000 {method 'disable' of
                                              '_lsprof.Profiler' objects}

按累积时间排序使我们对代码中大部分执行时间都花在哪里有了一个概念。这个结果显示,在 12 秒多的时间里,执行了 36221995 次函数调用(这个时间包括使用cProfile的开销)。以前,我们的代码执行大约需要 8 秒钟,现在我们通过测量每个函数执行的时间增加了 4 秒的惩罚。

我们可以看到代码的入口点julia1_nopil.py在第 1 行上总共花费了 12 秒钟。这只是对calc_pure_python__main__调用。ncalls为 1,表示这行代码只执行了一次。

calc_pure_python内部,调用calculate_z_serial_purepython耗时 11 秒。这两个函数只被调用一次。我们可以推断,在calc_pure_python内部的代码行大约花费了 1 秒钟的时间,与调用 CPU 密集型的calculate_z_serial_purepython函数是分开的。然而,我们无法通过cProfile确定在函数内部哪些行花费了时间。

calculate_z_serial_purepython内部,代码行的时间(不调用其他函数)是 8 秒。这个函数调用了 34219980 次abs,总共耗时 3 秒,还有其他调用时间不多的函数。

那么{abs}调用呢?这行代码测量了calculate_z_serial_purepython内部调用abs函数的次数。虽然每次调用的成本微不足道(记录为 0.000 秒),但 34219980 次调用的总时间为 3 秒。我们无法预测到abs会被调用多少次,因为 Julia 函数具有不可预测的动态特性(这也是我们感兴趣的原因)。

我们最好可以说,它将至少被调用 100 万次,因为我们正在计算1000*1000像素。在最坏的情况下,它将被调用 3 亿次,因为我们计算 100 万像素最多 300 次迭代。因此,3420 万次调用大约占最坏情况的 10%。

如果我们查看原始的灰度图像(图 2-3)并将白色部分压缩在一起并挤入一个角落,我们可以估计昂贵的白色区域大约占了图像的其余部分的 10%。

分析输出中的下一行,{method 'append' of 'list' objects},详细描述了创建了 2,002,000 个列表项。

提示

为什么是 2,002,000 个项目?在继续阅读之前,请思考一下有多少个列表项正在被构建。

在设置阶段,正在发生 2,002,000 个项目的创建,这发生在 calc_pure_python 中。

zscs 列表将分别为 1000*1000 个项目(生成 1,000,000 * 2 次调用),这些列表是从 1,000 x 和 1,000 y 坐标的列表构建的。总共,这是 2,002,000 次调用 append

重要的是要注意,这个 cProfile 输出不是按父函数排序的;它在执行的代码块中总结了所有函数的开销。使用 cProfile 逐行了解发生的情况非常困难,因为我们只获取函数调用本身的概要信息,而不是函数内的每一行。

calculate_z_serial_purepython 中,我们可以考虑 {abs},总体而言,这个函数的成本约为 3.1 秒。我们知道 calculate_z_serial_purepython 总共花费了 11.4 秒。

分析输出的最后一行提到了 lsprof;这是演变为 cProfile 的工具的原始名称,可以忽略不计。

要更好地控制 cProfile 的结果,我们可以编写一个统计文件,然后在 Python 中进行分析:

$ python -m cProfile -o profile.stats julia1.py

我们可以按照以下方式将其加载到 Python 中,它会给我们与之前相同的累积时间报告:

In [1]: import pstats
In [2]: p = pstats.Stats("profile.stats")
In [3]: p.sort_stats("cumulative")
Out[3]: <pstats.Stats at 0x7f77088edf28>

In [4]: p.print_stats()
Fri Jun 14 17:59:28 2019    profile.stats

         36221995 function calls in 12.169 seconds

  Ordered by: cumulative time

  ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       1    0.000    0.000   12.169   12.169 {built-in method builtins.exec}
       1    0.033    0.033   12.169   12.169 julia1_nopil.py:1(<module>)
       1    0.576    0.576   12.135   12.135 julia1_nopil.py:23
                                              (calc_pure_python)
       1    8.266    8.266   11.429   11.429 julia1_nopil.py:9
                                              (calculate_z_serial_purepython)
34219980    3.163    0.000    3.163    0.000 {built-in method builtins.abs}
 2002000    0.123    0.000    0.123    0.000 {method 'append' of 'list' objects}
       1    0.006    0.006    0.006    0.006 {built-in method builtins.sum}
       3    0.000    0.000    0.000    0.000 {built-in method builtins.print}
       4    0.000    0.000    0.000    0.000 {built-in method builtins.len}
       2    0.000    0.000    0.000    0.000 {built-in method time.time}
       1    0.000    0.000    0.000    0.000 {method 'disable' of
                                              '_lsprof.Profiler' objects}

要追踪我们正在分析的函数,我们可以打印调用者信息。在以下两个列表中,我们可以看到 calculate_z_serial_purepython 是最昂贵的函数,它只从一个地方调用。如果它被多处调用,这些列表可能会帮助我们缩小最昂贵父函数的位置:

In [5]: p.print_callers()
   Ordered by: cumulative time

Function                                          was called by...
                                                ncalls  tottime cumtime
{built-in method builtins.exec}       <-
julia1_nopil.py:1(<module>)           <-       1    0.033   12.169
                                               {built-in method builtins.exec}
julia1_nopil.py:23(calc_pure_python)  <-       1    0.576   12.135
                                               :1(<module>)
julia1_nopil.py:9(...)                <-       1    8.266   11.429
                                               :23(calc_pure_python)
{built-in method builtins.abs}        <- 34219980   3.163    3.163
                                               :9(calculate_z_serial_purepython)
{method 'append' of 'list' objects}   <- 2002000    0.123    0.123
                                               :23(calc_pure_python)
{built-in method builtins.sum}        <-       1    0.006    0.006
                                               :23(calc_pure_python)
{built-in method builtins.print}      <-       3    0.000    0.000
                                               :23(calc_pure_python)
{built-in method builtins.len}        <-       2    0.000    0.000
                                               :9(calculate_z_serial_purepython)
                                               2    0.000    0.000
                                               :23(calc_pure_python)
{built-in method time.time}           <-       2    0.000    0.000
                                               :23(calc_pure_python)

我们可以将这个过程反过来,显示哪些函数调用其他函数:

In [6]: p.print_callees()
   Ordered by: cumulative time

Function                                          called...
                                              ncalls  tottime  cumtime
{built-in method builtins.exec}      ->       1    0.033   12.169
                                              julia1_nopil.py:1(<module>)
julia1_nopil.py:1(<module>)          ->       1    0.576   12.135
                                              julia1_nopil.py:23
                                                (calc_pure_python)
julia1_nopil.py:23(calc_pure_python) ->       1    8.266   11.429
                                              julia1_nopil.py:9
                                                (calculate_z_serial_purepython)
                                              2    0.000    0.000
                                              {built-in method builtins.len}
                                              3    0.000    0.000
                                              {built-in method builtins.print}
                                              1    0.006    0.006
                                              {built-in method builtins.sum}
                                              2    0.000    0.000
                                              {built-in method time.time}
                                        2002000    0.123    0.123
                                              {method 'append' of 'list' objects}
julia1_nopil.py:9(...)              -> 34219980    3.163    3.163
                                              {built-in method builtins.abs}
					                          2    0.000    0.000
                                              {built-in method builtins.len}

cProfile 相当冗长,如果不进行很多换行,你需要一个侧屏才能看到它。然而,由于它是内置的,因此它是一个快速识别瓶颈的方便工具。像 line_profilermemory_profiler 这样的工具,我们稍后在本章中讨论,将帮助您深入到您应该关注的特定行。

使用 SnakeViz 可视化 cProfile 输出

snakeviz 是一个可视化工具,它将 cProfile 的输出绘制为一个图表,其中较大的框表示运行时间较长的代码区域。它取代了较旧的 runsnake 工具。

使用 snakeviz 可以对 cProfile 统计文件进行高层次的理解,特别是当你在调查一个你对其了解较少的新项目时。该图表将帮助你可视化系统的 CPU 使用行为,并且可能突显出你未曾预料到的开销较大的部分。

要安装 SnakeViz,请使用 $ pip install snakeviz

在 图 2-5 中,我们有刚刚生成的 profile.stats 文件的视觉输出。图表顶部显示了程序的入口点。每个向下的层级表示从上面的函数调用的函数。

图表的宽度代表程序执行所需的整体时间。第四层显示大部分时间花费在 calculate_z_serial_purepython 中。第五层进一步分解了这一点——右侧未注释的区块大约占据了该层的 25%,代表在 abs 函数中花费的时间。快速看到这些较大的块可以迅速理解程序内部时间的分配情况。

snakeviz 可视化 profile.stats

图 2-5. snakeviz 可视化 profile.stats

下一节显示的表格是我们刚刚查看过的统计数据的漂亮打印版本,你可以按 cumtime(累计时间)、percall(每次调用成本)或 ncalls(总调用次数)等分类进行排序。从 cumtime 开始将告诉你哪些函数总体成本最高。它们是开始调查的一个很好的起点。

如果你习惯查看表格,cProfile 的控制台输出可能已经足够了。为了与他人交流,我们强烈建议使用图表,比如来自 snakeviz 的此类输出,以帮助其他人快速理解你的观点。

使用 line_profiler 进行逐行测量

依据伊恩的观点,Robert Kern 的 line_profiler 是识别 Python 代码中 CPU 绑定问题原因的最强工具。它通过逐行分析单个函数的性能来工作,因此你应该从 cProfile 开始,并使用高层次视图指导选择要使用 line_profiler 进行分析的函数。

当你修改代码时,值得打印并注释此工具输出的版本,这样你就能记录下(成功或失败的)更改,以便快速查阅。在逐行更改代码时,不要依赖于你的记忆。

要安装 line_profiler,请输入命令 pip install line_profiler

用装饰器 (@profile) 标记选择的函数。使用 kernprof 脚本来执行你的代码,并记录选定函数每行的 CPU 时间和其他统计信息。

参数-l表示按行(而不是函数级别)进行分析,-v表示详细输出。如果没有-v,你将得到一个.lprof输出,稍后可以使用line_profiler模块进行分析。在示例 2-6 中,我们将对我们的 CPU 绑定函数进行全面运行。

示例 2-6. 运行kernprof以记录装饰函数的逐行 CPU 执行成本
$ kernprof -l -v julia1_lineprofiler.py
...
Wrote profile results to julia1_lineprofiler.py.lprof
Timer unit: 1e-06 s

Total time: 49.2011 s
File: julia1_lineprofiler.py
Function: calculate_z_serial_purepython at line 9

Line #      Hits  Per Hit   % Time  Line Contents
==============================================================
     9                              @profile
    10                              def calculate_z_serial_purepython(maxiter,
                                                                      zs, cs):
    11                            """Calculate output list using Julia update rule"""
    12         1   3298.0      0.0      output = [0] * len(zs)
    13   1000001      0.4      0.8      for i in range(len(zs)):
    14   1000000      0.4      0.7          n = 0
    15   1000000      0.4      0.9          z = zs[i]
    16   1000000      0.4      0.8          c = cs[i]
    17  34219980      0.5     38.0          while abs(z) < 2 and n < maxiter:
    18  33219980      0.5     30.8              z = z * z + c
    19  33219980      0.4     27.1              n += 1
    20   1000000      0.4      0.8          output[i] = n
    21         1      1.0      0.0      return output

引入kernprof.py会显著增加运行时间。在这个例子中,calculate_z_serial_purepython花费了 49 秒;这比简单使用print语句的 8 秒和使用cProfile的 12 秒要长。好处在于我们可以得到函数内部时间分布的逐行详细信息。

% Time列是最有帮助的——我们可以看到 38%的时间用于while测试上。我们不知道第一个语句(abs(z) < 2)是否比第二个语句(n < maxiter)更昂贵。在循环内部,我们还看到对z的更新也相当昂贵。甚至n += 1也很昂贵!Python 的动态查找机制在每个循环中都在工作,即使我们在每个循环中使用相同的类型来定义每个变量——这就是编译和类型专门化(第七章)给我们带来巨大优势的地方。与while循环的成本相比,output列表的创建和第 20 行的更新相对较便宜。

如果你之前没有考虑过 Python 动态机制的复杂性,请考虑一下n += 1操作会发生什么。Python 必须检查n对象是否有__add__函数(如果没有,它会沿袭任何继承类来查看它们是否提供此功能),然后将另一个对象(在本例中为1)传入,以便__add__函数可以决定如何处理操作。请记住,第二个参数可能是一个float或其他可能与之兼容或不兼容的对象。所有这些都是动态发生的。

进一步分析while语句的明显方法是将其分解。虽然在 Python 社区中已经有一些关于重写.pyc文件以提供更详细的多部分单行语句信息的讨论,但我们不知道任何生产工具比line_profiler提供更精细的分析。

在示例 2-7 中,我们将while逻辑分解为几个语句。这种额外的复杂性将增加函数的运行时间,因为我们需要执行更多的代码行,但这可能也有助于我们理解代码的这部分所产生的成本。

提示

在查看代码之前,你认为我们能通过这种方式学习到基本操作的成本吗?其他因素可能会使分析复杂化吗?

示例 2-7. 将复合while语句分解为单独的语句,记录原始语句的每个部分的成本
$ kernprof -l -v julia1_lineprofiler2.py
...
Wrote profile results to julia1_lineprofiler2.py.lprof
Timer unit: 1e-06 s

Total time: 82.88 s
File: julia1_lineprofiler2.py
Function: calculate_z_serial_purepython at line 9

Line #      Hits  Per Hit   % Time  Line Contents
==============================================================
     9                              @profile
    10                              def calculate_z_serial_purepython(maxiter,
                                                                      zs, cs):
    11                            """Calculate output list using Julia update rule"""
    12         1   3309.0      0.0      output = [0] * len(zs)
    13   1000001      0.4      0.5      for i in range(len(zs)):
    14   1000000      0.4      0.5          n = 0
    15   1000000      0.5      0.5          z = zs[i]
    16   1000000      0.4      0.5          c = cs[i]
    17   1000000      0.4      0.5          while True:
    18  34219980      0.6     23.1            not_yet_escaped = abs(z) < 2
    19  34219980      0.4     18.3            iterations_left = n < maxiter
    20  34219980      0.4     17.3            if not_yet_escaped and iterations_left:
    21  33219980      0.5     20.5                z = z * z + c
    22  33219980      0.4     17.3                n += 1
    23                                        else:
    24   1000000      0.4      0.5                break
    25   1000000      0.4      0.5          output[i] = n
    26         1      0.0      0.0      return output

这个版本的执行时间为 82 秒,而之前的版本为 49 秒。其他因素确实使分析变得复杂。在这种情况下,必须执行的额外语句次数为 34,219,980 次,导致代码变慢。如果我们没有使用kernprof.py逐行调查这一变化的影响,我们可能会对减速原因得出其他结论,因为我们缺乏必要的证据。

现在回到之前使用timeit技术来测试单个表达式的成本是有意义的:

Python 3.7.3 (default, Mar 27 2019, 22:11:17)
Type 'copyright', 'credits', or 'license' for more information
IPython 7.5.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: z = 0+0j
In [2]: %timeit abs(z) < 2
97.6 ns ± 0.138 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
In [3]: n = 1
In [4]: maxiter = 300
In [5]: %timeit n < maxiter
42.1 ns ± 0.0355 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

从这个简单的分析中看来,对于 Python 语句的逻辑测试来说,n的逻辑测试速度要比调用abs快两倍以上。由于 Python 语句的评估顺序是从左到右且机会主义的,因此将最便宜的测试放在等式的左侧是有道理的。对于每个坐标的 301 次测试中的 1 次,n < maxiter测试将为False,因此 Python 不需要评估and运算符的另一侧。

我们在评估abs(z) < 2之前永远不知道它是否为False,并且我们对复平面这一区域的早期观察表明,在 300 次迭代中,大约 10%的时间是True。如果我们想对代码这部分的时间复杂度有一个深入的理解,继续数值分析是有意义的。然而,在这种情况下,我们希望进行一个简单的检查,看看我们是否能够迅速获得胜利。

我们可以提出一个新的假设:“通过交换while语句中运算符的顺序,我们将实现可靠的加速。”我们可以使用kernprof来测试这个假设,但是用这种方式进行分析会增加太多的额外开销。相反,我们可以使用代码的早期版本,运行一个测试,比较while abs(z) < 2 and n < maxiter:while n < maxiter and abs(z) < 2:,我们在示例 2-8 中看到了这个例子。

line_profiler之外运行这两个变体意味着它们以类似的速度运行。line_profiler的开销也会扰乱结果,并且第 17 行的两个版本的结果相似。我们应该拒绝在 Python 3.7 中改变逻辑顺序会导致一致加速的假设——没有明确的证据支持这一点。Ian 指出,对于 Python 2.7,我们可以接受这个假设,但在 Python 3.7 中不再适用。

采用更合适的方法来解决这个问题(例如,切换到使用 Cython 或 PyPy,如第七章中描述的)将会带来更大的收益。

我们可以对结果感到自信,因为以下原因:

  • 我们提出了一个易于测试的假设。

  • 我们修改了代码,以便只测试假设(永远不要同时测试两个事物!)。

  • 我们收集了足够的证据来支持我们的结论。

为了全面,我们可以对包括我们的优化在内的两个主要函数运行最后的 kernprof,以确认我们对代码的整体复杂性有一个完整的理解。

示例 2-8. 交换复合 while 语句的顺序使函数稍微更快
$ kernprof -l -v julia1_lineprofiler3.py
...
Wrote profile results to julia1_lineprofiler3.py.lprof
Timer unit: 1e-06 s

Total time: 48.9154 s
File: julia1_lineprofiler3.py
Function: calculate_z_serial_purepython at line 9

Line #    Hits  Per Hit % Time  Line Contents
=======================================================
   9                            @profile
  10                            def calculate_z_serial_purepython(maxiter,
                                                                  zs, cs):
  11                             """Calculate output list using Julia update rule"""
  12         1   3312.0    0.0      output = [0] * len(zs)
  13   1000001      0.4   0.8      for i in range(len(zs)):
  14   1000000      0.4   0.7          n = 0
  15   1000000      0.4   0.8          z = zs[i]
  16   1000000      0.4   0.8          c = cs[i]
  17  34219980      0.5  38.2          while n < maxiter and abs(z) < 2:
  18  33219980      0.5  30.7              z = z * z + c
  19  33219980      0.4  27.1              n += 1
  20   1000000      0.4   0.8          output[i] = n
  21         1      1.0   0.0      return output

正如预期的那样,从 示例 2-9 的输出中,我们可以看到 calculate_z_serial_purepython 在其父函数中占用了大部分(97%)的时间。与此相比,列表创建步骤则较小。

示例 2-9. 测试设置程序的逐行成本
Total time: 88.334 s
File: julia1_lineprofiler3.py
Function: calc_pure_python at line 24

Line #      Hits  Per Hit   % Time  Line Contents
==============================================================
    24                              @profile
    25                              def calc_pure_python(draw_output,
                                                         desired_width,
                                                         max_iterations):
    26                               """Create a list of complex...
...
    44         1          1.0      0.0      zs = []
    45         1          0.0      0.0      cs = []
    46      1001          0.7      0.0      for ycoord in y:
    47   1001000          0.6      0.7          for xcoord in x:
    48   1000000          0.9      1.0            zs.append(complex(xcoord, ycoord))
    49   1000000          0.9      1.0            cs.append(complex(c_real, c_imag))
    50
    51         1         40.0      0.0      print("Length of x:", len(x))
    52         1          7.0      0.0      print("Total elements:", len(zs))
    53         1          4.0      0.0      start_time = time.time()
    54         1   85969310.0    97.3     output = calculate_z_serial_purepython \
                                           (max_iterations, zs, cs)
    55         1          4.0      0.0      end_time = time.time()
    56         1          1.0      0.0      secs = end_time - start_time
    57         1         36.0      0.0      print(calculate_z_serial...
    58
    59         1       6345.0      0.0      assert sum(output) == 33219980

line_profiler 为我们提供了有关循环内行和昂贵函数成本的深刻见解;尽管分析会增加速度惩罚,但它对科学开发人员是一个巨大的帮助。记得使用代表性数据来确保你专注于能为你带来最大收益的代码行。

使用 memory_profiler 诊断内存使用

就像 Robert Kern 的 line_profiler 包测量 CPU 使用情况一样,Fabian Pedregosa 和 Philippe Gervais 的 memory_profiler 模块按行测量内存使用情况。了解代码的内存使用特性使您可以问自己两个问题:

  • 通过重写这个函数以更高效地工作,我们能否使用更少的 RAM?

  • 通过缓存,我们能否使用更多的 RAM 并节省 CPU 周期?

memory_profiler 的操作方式与 line_profiler 非常相似,但运行速度慢得多。如果安装了 psutil 包(可选但建议安装),memory_profiler 将运行更快。内存分析可能会使您的代码运行速度慢 10 到 100 倍。在实际应用中,你可能偶尔会使用 memory_profiler,而更频繁地使用 line_profiler(用于 CPU 分析)。

使用命令 pip install memory_profiler 安装 memory_profiler(可选的还可以用 pip install psutil)。

如前所述,memory_profiler 的实现性能不如 line_profiler。因此,对于能在合理时间内完成的较小问题进行测试是有意义的。隔夜运行可能适合验证,但是你需要快速和合理的迭代来诊断问题和假设解决方案。示例 2-10 中的代码使用了完整的 1,000 × 1,000 网格,并且统计信息在 Ian 的笔记本电脑上收集大约花费了两个小时。

注意

修改源代码的要求是一个小的烦恼。与 line_profiler 类似,一个装饰器(@profile)用于标记选择的函数。这将破坏你的单元测试,除非你创建一个虚拟的装饰器—参见 “No-op @profile Decorator”。

在处理内存分配时,您必须意识到情况并不像处理 CPU 使用率那样明确。一般来说,在可以自由使用的进程中过度分配内存更为高效,因为内存分配操作相对昂贵。此外,垃圾回收并不是即时的,因此对象可能在垃圾回收池中不可用,但仍然存在一段时间。

结果是,很难真正理解 Python 程序内存使用和释放的情况,因为一行代码可能不会分配确定量的内存从外部过程中观察到的情况而言。通过观察一组行的总体趋势,可能会比仅观察一行行为获得更好的见解。

让我们来看看memory_profiler在示例 2-10 中的输出。在第 12 行的calculate_z_serial_purepython函数内部,我们可以看到分配了 100 万个项目导致该进程增加了约 7 MB 的 RAM。¹ 这并不意味着output列表确实是 7 MB 的大小,只是在列表的内部分配过程中,该进程大约增长了 7 MB。

在第 46 行的父函数中,我们看到zscs列表的分配将Mem usage列从 48 MB 变为 125 MB(增加了+77 MB)。同样值得注意的是,这并不一定是数组的真实大小,只是在这些列表创建后进程增长的大小。

在撰写本文时,memory_usage模块存在一个 bug——Increment列并不总是与Mem usage列的变化相匹配。在本书第一版中,这些列是正确跟踪的;您可能需要查看在GitHub上此 bug 的状态。我们建议您使用Mem usage列,因为这可以正确跟踪每行代码的进程大小变化。

示例 2-10。在我们的两个主要函数中,memory_profiler的结果显示在calculate_z_serial_purepython中存在意外的内存使用。
$ python -m memory_profiler julia1_memoryprofiler.py
...

Line #    Mem usage    Increment   Line Contents
================================================
     9  126.363 MiB  126.363 MiB   @profile
    10                             def calculate_z_serial_purepython(maxiter,
                                                                     zs, cs):
    11                                 """Calculate output list using...
    12  133.973 MiB    7.609 MiB       output = [0] * len(zs)
    13  136.988 MiB    0.000 MiB       for i in range(len(zs)):
    14  136.988 MiB    0.000 MiB           n = 0
    15  136.988 MiB    0.000 MiB           z = zs[i]
    16  136.988 MiB    0.000 MiB           c = cs[i]
    17  136.988 MiB    0.258 MiB           while n < maxiter and abs(z) < 2:
    18  136.988 MiB    0.000 MiB               z = z * z + c
    19  136.988 MiB    0.000 MiB               n += 1
    20  136.988 MiB    0.000 MiB           output[i] = n
    21  136.988 MiB    0.000 MiB       return output

...

Line #    Mem usage    Increment   Line Contents
================================================
    24   48.113 MiB   48.113 MiB   @profile
    25                             def calc_pure_python(draw_output,
                                                        desired_width,
                                                        max_iterations):
    26                                 """Create a list of complex...
    27   48.113 MiB    0.000 MiB       x_step = (x2 - x1) / desired_width
    28   48.113 MiB    0.000 MiB       y_step = (y1 - y2) / desired_width
    29   48.113 MiB    0.000 MiB       x = []
    30   48.113 MiB    0.000 MiB       y = []
    31   48.113 MiB    0.000 MiB       ycoord = y2
    32   48.113 MiB    0.000 MiB       while ycoord > y1:
    33   48.113 MiB    0.000 MiB           y.append(ycoord)
    34   48.113 MiB    0.000 MiB           ycoord += y_step
    35   48.113 MiB    0.000 MiB       xcoord = x1
    36   48.113 MiB    0.000 MiB       while xcoord < x2:
    37   48.113 MiB    0.000 MiB           x.append(xcoord)
    38   48.113 MiB    0.000 MiB           xcoord += x_step
    44   48.113 MiB    0.000 MiB       zs = []
    45   48.113 MiB    0.000 MiB       cs = []
    46  125.961 MiB    0.000 MiB       for ycoord in y:
    47  125.961 MiB    0.258 MiB           for xcoord in x:
    48  125.961 MiB    0.512 MiB               zs.append(complex(xcoord, ycoord))
    49  125.961 MiB    0.512 MiB               cs.append(complex(c_real, c_imag))
    50
    51  125.961 MiB    0.000 MiB       print("Length of x:", len(x))
    52  125.961 MiB    0.000 MiB       print("Total elements:", len(zs))
    53  125.961 MiB    0.000 MiB       start_time = time.time()
    54  136.609 MiB   10.648 MiB       output = calculate_z_serial...
    55  136.609 MiB    0.000 MiB       end_time = time.time()
    56  136.609 MiB    0.000 MiB       secs = end_time - start_time
    57  136.609 MiB    0.000 MiB       print(calculate_z_serial_purepython...
    58
    59  136.609 MiB    0.000 MiB       assert sum(output) == 33219980

另一种可视化内存使用变化的方法是随时间取样并绘制结果。memory_profiler有一个名为mprof的实用程序,用于首次取样内存使用情况,并第二次用于可视化这些样本。它通过时间取样,而不是通过行,因此几乎不会影响代码的运行时间。

图 2-6 是使用mprof run julia1_memoryprofiler.py创建的。这会生成一个统计文件,然后可以使用mprof plot来可视化。我们的两个函数被框定起来:这显示了它们在时间轴上的输入时刻,并且我们可以看到它们运行时 RAM 的增长。在calculate_z_serial_purepython内部,我们可以看到在函数执行期间 RAM 使用量的稳定增加;这是由创建的所有小对象(intfloat类型)引起的。

图 2-6. 使用mprofmemory_profiler报告

除了观察函数级行为外,我们还可以使用上下文管理器添加标签。在示例 2-11 中的片段用于生成图 2-7 中的图表。我们可以看到create_output_list标签:它在calculate_z_serial_purepython之后大约 1.5 秒的时候瞬间出现,并导致进程分配更多 RAM。然后我们暂停一秒钟;time.sleep(1)是为了使图表更易于理解而添加的人为延迟。

示例 2-11. 使用上下文管理器为mprof图表添加标签
@profile
def calculate_z_serial_purepython(maxiter, zs, cs):
    """Calculate output list using Julia update rule"""
    with profile.timestamp("create_output_list"):
        output = [0] * len(zs)
    time.sleep(1)
    with profile.timestamp("calculate_output"):
        for i in range(len(zs)):
            n = 0
            z = zs[i]
            c = cs[i]
            while n < maxiter and abs(z) < 2:
                z = z * z + c
                n += 1
            output[i] = n
    return output

在运行大部分图表的calculate_output块中,我们看到 RAM 使用量呈现非常缓慢的线性增长。这将来自内部循环中使用的所有临时数字。使用标签确实帮助我们在细粒度级别上理解内存的消耗情况。有趣的是,我们看到了“峰值 RAM 使用量”线——在程序终止之前的虚线垂直线标志出现在 10 秒标记之前。可能这是由于垃圾收集器在calculate_output期间恢复了一些用于临时对象的 RAM。

如果我们简化代码并删除对zscs列表的创建,会发生什么?然后我们必须在calculate_z_serial_purepython内计算这些坐标(因此执行相同的工作),但通过不存储它们在列表中可以节省内存。你可以在示例 2-12 中看到代码。

在图 2-8 中,我们看到行为有了重大变化——RAM 使用总体包络从 140 MB 降至 60 MB,将我们的 RAM 使用量减少了一半!

图 2-7. 使用带标签的mprofmemory_profiler报告

图 2-8. 移除两个大列表后的memory_profiler
示例 2-12. 动态创建复杂坐标以节省内存
@profile
def calculate_z_serial_purepython(maxiter, x, y):
    """Calculate output list using Julia update rule"""
    output = []
    for ycoord in y:
        for xcoord in x:
            z = complex(xcoord, ycoord)
            c = complex(c_real, c_imag)
            n = 0
            while n < maxiter and abs(z) < 2:
                z = z * z + c
                n += 1
            output.append(n)
    return output

如果我们想要测量多条语句使用的 RAM,可以使用 IPython 魔术命令%memit,它的用法与%timeit类似。在第 11 章中,我们将探讨使用%memit来测量列表的内存成本,并讨论更高效使用 RAM 的各种方法。

memory_profiler通过--pdb-mmem=*XXX*标志为调试大型进程提供了一个有趣的辅助。当进程超过*XXX* MB 时,pdb调试器将被激活。如果你在空间受限的环境中,这将直接将你放在代码中过多分配内存的地方。

使用 PySpy 检查现有进程

py-spy 是一个引人入胜的新型采样分析器——它不需要任何代码更改,而是 introspects 一个已经运行的 Python 进程,并在控制台中以类似 top 的显示形式报告。作为一个采样分析器,它对你的代码几乎没有运行时影响。它是用 Rust 编写的,需要提升权限才能 introspect 另一个进程。

这个工具在生产环境中有很大用处,特别是对于长期运行的进程或复杂的安装需求。它支持 Windows、Mac 和 Linux。使用 pip install py-spy 安装(注意名称中的短横线——还有一个与之无关的 pyspy 项目)。如果你的进程已经在运行,你需要使用 ps 获取其进程标识符(PID);然后可以将其传递给 py-spy,如 示例 2-13 所示。

示例 2-13. 在命令行运行 PySpy
$ ps -A -o pid,rss,cmd | ack python
...
15953 96156 python julia1_nopil.py
...
$ sudo env "PATH=$PATH" py-spy --pid 15953

在 图 2-9 中,你会看到控制台中类似 top 的显示的静态图片;每秒更新一次,显示哪些函数当前耗费的时间最多。

使用 PySpy 进行 Python 进程 introspection

图 2-9. 使用 PySpy 进行 Python 进程 introspection

PySpy 还可以导出火焰图。这里,我们将在运行 PySpy 时使用该选项,并要求 PySpy 直接运行我们的代码,而不需要 PID,使用 $ py-spy --flame profile.svg -- python julia1_nopil.py。你将在 图 2-10 中看到,显示的宽度代表整个程序的运行时间,每一层向下的图像代表从上方调用的函数。

PySpy 火焰图的一部分

图 2-10. PySpy 火焰图的一部分

字节码:引擎盖下

目前我们已经回顾了各种测量 Python 代码成本的方法(包括 CPU 和 RAM 的使用)。但我们还没有探讨虚拟机使用的底层字节码。了解“引擎盖下”的内容有助于建立对缓慢函数的心理模型,当你编译代码时也会有所帮助。所以让我们来介绍一些字节码。

使用 dis 模块检查 CPython 字节码

dis 模块让我们检查在基于栈的 CPython 虚拟机中运行的底层字节码。了解在运行你更高级别的 Python 代码的虚拟机中发生的事情将帮助你理解为什么某些编码风格比其他风格更快。当你使用像 Cython 这样的工具时,它会走出 Python,生成 C 代码,这也会有所帮助。

dis 模块是内置的。你可以传递代码或模块,它会打印出反汇编的代码。在 示例 2-14 中,我们反汇编了 CPU 密集型函数的外部循环。

小贴士

你应该尝试反汇编你自己的一个函数,并准确地跟踪反汇编的代码是如何与反汇编输出匹配的。你能将以下 dis 输出与原始函数匹配吗?

示例 2-14. 使用内置的dis来理解运行我们 Python 代码的基于栈的虚拟机
In [1]: import dis
In [2]: import julia1_nopil
In [3]: dis.dis(julia1_nopil.calculate_z_serial_purepython)
 11           0 LOAD_CONST               1 (0)
              2 BUILD_LIST               1
              4 LOAD_GLOBAL              0 (len)
              6 LOAD_FAST                1 (zs)
              8 CALL_FUNCTION            1
             10 BINARY_MULTIPLY
             12 STORE_FAST               3 (output)

 12          14 SETUP_LOOP              94 (to 110)
             16 LOAD_GLOBAL              1 (range)
             18 LOAD_GLOBAL              0 (len)
             20 LOAD_FAST                1 (zs)
             22 CALL_FUNCTION            1
             24 CALL_FUNCTION            1
             26 GET_ITER
        >>   28 FOR_ITER                78 (to 108)
             30 STORE_FAST               4 (i)

 13          32 LOAD_CONST               1 (0)
             34 STORE_FAST               5 (n)
...
 19     >>   98 LOAD_FAST                5 (n)
            100 LOAD_FAST                3 (output)
            102 LOAD_FAST                4 (i)
            104 STORE_SUBSCR
            106 JUMP_ABSOLUTE           28
        >>  108 POP_BLOCK

 20     >>  110 LOAD_FAST                3 (output)
            112 RETURN_VALUE

输出相当直接,虽然简短。第一列包含行号,与我们原始文件相关。第二列包含多个>>符号;这些是代码中其他跳转点的目标。第三列是操作地址;第四列是操作名称。第五列包含操作的参数。第六列包含注释,帮助将字节码与原始 Python 参数对齐。

参考示例 2-3 以将字节码与相应的 Python 代码匹配起来。字节码从 Python 第 11 行开始,将常量值 0 放入堆栈,然后构建一个单元素列表。接下来,它搜索命名空间以找到len函数,并将其放入堆栈,再次搜索命名空间以找到zs,然后将其放入堆栈。在 Python 第 12 行内,它调用堆栈中的len函数,消耗堆栈中的zs引用;然后将最后两个参数(zs的长度和单元素列表)应用二进制乘法,并将结果存储在output中。现在,我们处理了 Python 函数的第一行。跟随下一个字节码块以理解第二行 Python 代码的行为(外部for循环)。

提示

跳转点(>>)对应于指令如JUMP_ABSOLUTEPOP_JUMP_IF_FALSE。浏览您自己反汇编的函数,并将跳转点与跳转指令匹配起来。

引入了字节码之后,我们现在可以问:明确编写一个函数的字节码和时间成本与使用内置函数执行相同任务的成本如何?

不同的方法,不同的复杂性

应该有一种——最好只有一种——明显的方法来做到这一点。尽管这种方式一开始可能并不明显,除非你是荷兰人。²

Tim Peters,《Python 之禅》

有各种方式可以用 Python 表达您的想法。一般来说,最明智的选择应该是明显的,但如果您的经验主要是使用较旧版本的 Python 或其他编程语言,则可能会有其他方法。某些表达想法的方式可能比其他方式慢。

对于大多数代码而言,你可能更关心的是可读性而不是速度,因此你的团队可以在不被高效但晦涩的代码困扰的情况下进行编码。不过有时你可能需要性能(而不损失可读性)。某些情况下可能需要进行速度测试。

查看示例 2-15 中的两个代码片段。两者都完成同样的工作,但第一个生成了大量额外的 Python 字节码,这将导致更多的开销。

示例 2-15. 解决相同求和问题的一个天真和一个更有效的方法
def fn_expressive(upper=1_000_000):
    total = 0
    for n in range(upper):
        total += n
    return total

def fn_terse(upper=1_000_000):
    return sum(range(upper))

assert fn_expressive() == fn_terse(), "Expect identical results from both functions"

两个函数都计算一系列整数的和。一个简单的经验法则(但你必须通过性能分析来支持!)是,执行更多字节码行的代码比使用内置函数的等效行数执行得更慢。在示例 2-16 中,我们使用 IPython 的%timeit魔术函数来测量一组运行中的最佳执行时间。fn_terse的运行速度比fn_expressive快了两倍以上!

示例 2-16. 使用%timeit来测试我们的假设,即使用内置函数应该比编写我们自己的函数更快
In [2]: %timeit fn_expressive()
52.4 ms ± 86.4 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [3]: %timeit fn_terse()
18.1 ms ± 1.38 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)

如果我们使用dis模块来调查每个函数的代码,如示例 2-17 所示,我们可以看到虚拟机需要执行 17 行代码来执行更具表达力的函数,而只需要执行 6 行代码来执行非常易读但更简洁的第二个函数。

示例 2-17. 使用dis查看涉及到我们两个函数的字节码指令数量
In [4]: import dis
In [5]: dis.dis(fn_expressive)
  2           0 LOAD_CONST               1 (0)
              2 STORE_FAST               1 (total)

  3           4 SETUP_LOOP              24 (to 30)
              6 LOAD_GLOBAL              0 (range)
              8 LOAD_FAST                0 (upper)
             10 CALL_FUNCTION            1
             12 GET_ITER
        >>   14 FOR_ITER                12 (to 28)
             16 STORE_FAST               2 (n)

  4          18 LOAD_FAST                1 (total)
             20 LOAD_FAST                2 (n)
             22 INPLACE_ADD
             24 STORE_FAST               1 (total)
             26 JUMP_ABSOLUTE           14
        >>   28 POP_BLOCK

  5     >>   30 LOAD_FAST                1 (total)
             32 RETURN_VALUE

In [6]: dis.dis(fn_terse)
  8           0 LOAD_GLOBAL              0 (sum)
              2 LOAD_GLOBAL              1 (range)
              4 LOAD_FAST                0 (upper)
              6 CALL_FUNCTION            1
              8 CALL_FUNCTION            1
             10 RETURN_VALUE

这两个代码块之间的差异非常明显。在fn_expressive()内部,我们维护两个局部变量,并使用for语句迭代列表。for循环将检查在每次循环中是否引发了StopIteration异常。每次迭代都应用total.__add__函数,该函数将检查第二个变量n的类型。所有这些检查都会增加一些开销。

fn_terse()内部,我们调用了一个优化的 C 列表推导函数,它知道如何生成最终结果而不创建中间 Python 对象。这样做速度要快得多,尽管每次迭代仍然必须检查正在相加的对象的类型(在第四章中,我们讨论了修复类型以便在每次迭代中不需要检查的方法)。

正如之前所指出的,你必须对你的代码进行性能分析——如果你只依赖这种启发式方法,你最终肯定会写出速度较慢的代码。了解是否 Python 内置了解决你问题的更短且仍可读的方法绝对是值得的。如果有的话,它更有可能被其他程序员轻松阅读,并且可能运行更快。

优化过程中进行单元测试以保持正确性

如果你还没有对你的代码进行单元测试,你可能会损害你长期的生产力。伊恩(脸红)很尴尬地注意到,他曾经花了一天的时间优化他的代码,因为他觉得单元测试很不方便,所以禁用了它们,结果发现他显著提速的结果是由于破坏了他正在改进的算法的一部分。你不需要犯这种错误,甚至只有一次。

提示

为了更加理智地生活,给你的代码添加单元测试。这样一来,你会给你当前的自己和同事们带来信心,相信你的代码是可靠的;同时,你也会为将来需要维护这段代码的自己送上一份礼物。长远来看,通过给你的代码添加测试,你确实可以节省很多时间。

除了单元测试之外,你还应该考虑强烈使用 coverage.py。它检查哪些代码行被你的测试覆盖,并识别没有覆盖的部分。这可以快速帮助你确定是否正在测试你即将优化的代码,从而在优化过程中发现可能出现的任何错误。

无操作 @profile 装饰器

如果你的代码使用了line_profiler或者memory_profiler中的@profile装饰器,你的单元测试会因为NameError异常而失败。原因在于单元测试框架不会将@profile装饰器注入到本地命名空间中。下面展示的无操作装饰器解决了这个问题。将它添加到你正在测试的代码块中,在完成测试后再移除它。

使用无操作装饰器,你可以在不修改正在测试的代码的情况下运行你的测试。这意味着你可以在每次进行基于性能优化的测试后运行你的测试,以便你永远不会因糟糕的优化步骤而受损失。

假设我们有一个简单的 ex.py 模块,如示例 2-18 所示。它包含了一个用于pytest的测试和一个我们一直在使用line_profilermemory_profiler进行性能分析的函数。

示例 2-18. 简单的函数和测试用例,我们希望使用 @profile
import time

def test_some_fn():
    """Check basic behaviors for our function"""
    assert some_fn(2) == 4
    assert some_fn(1) == 1
    assert some_fn(-1) == 1

@profile
def some_fn(useful_input):
    """An expensive function that we wish to both test and profile"""
    # artificial "we're doing something clever and expensive" delay
    time.sleep(1)
    return useful_input ** 2

if __name__ == "__main__":
    print(f"Example call `some_fn(2)` == {some_fn(2)}")

如果我们在我们的代码上运行 pytest,我们将会得到一个 NameError,如示例 2-19 所示。

示例 2-19. 在测试中缺少装饰器会导致测试以一种无帮助的方式失败!
$ pytest utility.py
=============== test session starts ===============
platform linux -- Python 3.7.3, pytest-4.6.2, py-1.8.0, pluggy-0.12.0
rootdir: noop_profile_decorator
plugins: cov-2.7.1
collected 0 items / 1 errors

====================== ERRORS =====================
___________ ERROR collecting utility.py ___________
utility.py:20: in <module>
    @profile
E   NameError: name 'profile' is not defined

解决方案是在我们的模块开头添加一个无操作装饰器(在完成性能分析后可以删除它)。如果在命名空间中找不到 @profile 装饰器(因为未使用 line_profilermemory_profiler),我们编写的无操作版本将被添加进去。如果 line_profilermemory_profiler 已经将新函数注入到命名空间中,我们编写的无操作版本将被忽略。

对于 line_profilermemory_profiler,我们可以在示例 2-20 中添加代码。

示例 2-20. 在单元测试中向命名空间添加一个无操作 @profile 装饰器
# check for line_profiler or memory_profiler in the local scope, both
# are injected by their respective tools or they're absent
# if these tools aren't being used (in which case we need to substitute
# a dummy @profile decorator)
if 'line_profiler' not in dir() and 'profile' not in dir():
    def profile(func):
        return func

添加了无操作装饰器后,我们现在可以成功运行我们的pytest,如示例 2-21 所示,同时还可以使用我们的性能分析器,而不需要额外的代码更改。

示例 2-21. 使用无操作装饰器,我们的测试工作正常,而且我们的性能分析器也正常工作
$ pytest utility.py
=============== test session starts ===============
platform linux -- Python 3.7.3, pytest-4.6.2, py-1.8.0, pluggy-0.12.0
rootdir: /home/ian/workspace/personal_projects/high_performance_python_book_2e/
         high-performance-python-2e/examples_ian/ian/ch02/noop_profile_decorator
plugins: cov-2.7.1
collected 1 item

utility.py .

============= 1 passed in 3.04 seconds ============

$ kernprof -l -v utility.py
Example call `some_fn(2)` == 4
...
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    20                                           @profile
    21                                           def some_fn(useful_input):
    22                                               """An expensive function that...
    23                                               # artificial 'we're doing...
    24         1    1001345.0 1001345.0    100.0      time.sleep(1)
    25         1          7.0      7.0      0.0      return useful_input ** 2

$ python -m memory_profiler utility.py
Example call `some_fn(2)` == 4
Filename: utility.py

Line #    Mem usage    Increment   Line Contents
================================================
    20   48.277 MiB   48.277 MiB   @profile
    21                             def some_fn(useful_input):
    22                                 """An expensive function that we wish to...
    23                                 # artificial 'we're doing something clever...
    24   48.277 MiB    0.000 MiB       time.sleep(1)
    25   48.277 MiB    0.000 MiB       return useful_input ** 2

通过避免使用这些装饰器,你可以节省几分钟的时间,但是一旦因错误的优化而浪费数小时才会意识到这点,你就会希望将它们整合到你的工作流程中。

成功分析代码的策略

进行性能分析需要一些时间和注意力。如果你将要测试的部分与主体代码分离开来,你将更有可能理解你的代码。然后,你可以进行单元测试以保证正确性,并传入真实的伪造数据来执行你想要解决的低效率部分。

记得禁用任何基于 BIOS 的加速器,因为它们只会混淆你的结果。在 Ian 的笔记本电脑上,英特尔 Turbo Boost 功能可以在 CPU 温度足够低时临时加速 CPU 超过其正常最大速度。这意味着一个冷却的 CPU 可能比一个热的 CPU 更快地运行相同的代码块。你的操作系统也可能控制时钟速度——使用电池供电的笔记本电脑可能会比使用交流电的笔记本电脑更积极地控制 CPU 速度。为了创建更稳定的基准测试配置,我们执行以下操作:

  • 在 BIOS 中禁用 Turbo Boost。

  • 禁用操作系统覆盖 SpeedStep 的能力(如果允许你控制的话,你可以在 BIOS 中找到这个选项)。

  • 仅使用交流电(永不使用电池供电)。

  • 在运行实验时禁用像备份和 Dropbox 这样的后台工具。

  • 多次运行实验以获得稳定的测量结果。

  • 可能会降到运行级别 1(Unix),以确保没有其他任务在运行。

  • 重启并重新运行实验以双重确认结果。

尝试假设你的代码的预期行为,然后通过分析步骤的结果验证(或驳斥!)这些假设。你的选择不会改变(你应该仅仅通过使用分析结果来推动你的决策),但你对代码的直觉理解将会提高,这将在未来的项目中带来回报,因为你更有可能做出高性能的决策。当然,你将在进行中使用分析来验证这些高性能的决策。

不要在准备工作上吝啬。如果你尝试在较大项目中深入测试代码而没有将其与较大项目分离,很可能会遇到会使你的努力受挫的副作用。在进行精细的更改时,对于较大的项目进行单元测试可能会更加困难,这可能进一步阻碍你的努力。副作用可能包括其他线程和进程影响 CPU 和内存使用情况以及网络和磁盘活动,这些将使你的结果偏离。

当然,你已经在使用源代码控制(例如 Git 或 Mercurial),因此你可以在不丢失“运行良好版本”的情况下,在不同的分支上运行多个实验。如果你还没有使用源代码控制,请务必开始使用!

对于 Web 服务器,请调查dowserdozer;你可以使用它们实时可视化命名空间中对象的行为。如果可能的话,确实考虑将你想测试的代码从主 Web 应用程序中分离出来,这将显著简化分析过程。

确保你的单元测试覆盖了你分析的代码中的所有代码路径。你没有测试的任何东西,如果在你的基准测试中使用,可能会导致细微的错误,这些错误会减慢你的进度。使用 coverage.py 确认你的测试覆盖了所有代码路径。

对于生成大量数值输出的复杂代码部分进行单元测试可能会很困难。不要害怕生成一个结果文本文件来运行 diff 或者使用一个 pickled 对象。对于数值优化问题,Ian 喜欢创建长文本文件包含浮点数,并使用 diff——即使在输出中它们很少见,轻微的舍入误差会立即显现。

如果你的代码由于细微变化可能会受到数值舍入问题的影响,最好生成一个较大的输出,以便进行前后比较。一个造成舍入误差的原因是 CPU 寄存器和主存之间浮点精度的差异。通过不同的代码路径运行你的代码可能会导致细微的舍入误差,这可能会后来让你感到困惑——最好在它们出现时就意识到这一点。

显然,在进行性能分析和优化时使用源代码控制工具是有意义的。分支是廉价的,它将保持你的理智。

总结

经过性能分析技术的研究,你应该已经掌握了在你的代码中识别 CPU 和 RAM 使用瓶颈所需的所有工具。接下来,我们将看一下 Python 如何实现最常见的容器,这样你就可以对表示更大数据集做出明智的决策。

¹ memory_profiler 根据国际电工委员会的 MiB(2²⁰ 字节)来测量内存使用情况。这与更常见但也更模糊的 MB(兆字节有两种通常接受的定义!)稍有不同。1 MiB 等于 1.048576(或大约 1.05)MB。对于我们的讨论来说,除非我们在处理非常具体的数量,否则我们将认为这两者是等效的。

² 语言创造者 Guido van Rossum 是荷兰人,并不是每个人都同意他的“显而易见”的选择,但总体上我们喜欢 Guido 做出的选择!

第三章:列表和元组

在编写高效程序时,了解所使用数据结构的保证是非常重要的事情。实际上,编写高性能程序的一个重要部分是了解你要向你的数据提出什么问题,并选择一个能够快速回答这些问题的数据结构。在本章中,我们将讨论列表和元组能够快速回答的问题类型及其实现方式。

列表和元组属于一类称为数组的数据结构。数组是一种带有某种内在顺序的扁平数据列表。通常在这类数据结构中,元素的相对顺序与元素本身一样重要!此外,对于这种先验的顺序知识非常有价值:通过知道数组中数据的特定位置,我们可以在O(1)的时间复杂度内检索它!¹ 同时,有许多实现数组的方法,每种解决方案都有其自身的有用特性和保证。这就是为什么在 Python 中我们有两种类型的数组:列表和元组。列表是动态数组,允许我们修改和调整存储的数据,而元组是静态数组,其内容是固定且不可变的。

让我们详细解释一下前面的声明。计算机系统内存可以被视为一系列编号的桶,每个桶可以容纳一个数字。Python 通过引用的方式存储数据在这些桶中,这意味着数字本身只是指向或引用我们实际关心的数据。因此,这些桶可以存储我们想要的任何类型的数据(与numpy数组不同,后者具有静态类型,只能存储该类型的数据)²。

当我们想要创建一个数组(因此是一个列表或元组),我们首先必须分配一块系统内存(其中这块区域的每个部分将被用作一个整数大小的指向实际数据的指针)。这涉及到向系统内核请求使用N 连续 的桶。图 3-1 展示了大小为 6 的数组(在本例中是列表)的系统内存布局示例。

注意

在 Python 中,列表还存储其大小,因此在分配了六个块中,只有五个可用——第零个元素是长度。

数组分配

图 3-1. 大小为 6 的数组系统内存布局示例

为了查找列表中的任何特定元素,我们只需知道我们想要的元素及我们的数据从哪个桶开始。因为所有的数据将占用相同的空间(一个“桶”,或者更具体地说,一个整数大小的指向实际数据的指针),我们不需要知道正在存储的数据类型来进行这个计算。

提示

如果你知道你的列表有N个元素并且它们在内存中的起始位置,你将如何找到列表中的任意元素?

例如,如果我们需要检索数组中的第零个元素,我们只需转到我们序列中的第一个桶M,并读取其中的值。另一方面,如果我们需要数组中的第五个元素,我们将转到位置M + 5处的桶,并读取其内容。通常,如果我们要从数组中检索第i个元素,我们会去到桶M + i。因此,通过将数据存储在连续的桶中,并了解数据的排序方式,我们可以在一步(或O(1))内定位我们的数据,无论数组有多大(示例 3-1)。

示例 3-1. 不同大小列表中查找的时间
>>> %%timeit l = list(range(10))
 ...: l[5]
 ...:
30.1 ns ± 0.996 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

>>> %%timeit l = list(range(10_000_000))
 ...: l[100_000]
 ...:
28.9 ns ± 0.894 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

如果我们得到一个未知顺序的数组,并想检索特定的元素怎么办?如果已知排序,我们可以简单地查找该特定值。然而,在这种情况下,我们必须进行一个search操作。解决这个问题的最基本方法称为线性搜索,在这种方法中,我们遍历数组中的每个元素,并检查它是否是我们想要的值,就像在示例 3-2 中看到的那样。

示例 3-2. 对列表进行线性搜索
def linear_search(needle, array):
    for i, item in enumerate(array):
        if item == needle:
            return i
    return -1

此算法的最坏情况性能为O(n)。当我们搜索不在数组中的内容时,就会发生这种情况。为了知道我们正在搜索的元素不在数组中,我们必须先将其与每个其他元素进行比较。最终,我们将达到最后的return -1语句。事实上,这个算法正是list.index()使用的算法。

要提高速度的唯一方法是对内存中数据的布局或我们持有的数据桶的排列有一些其他理解。例如,哈希表(“字典和集合的工作原理?”),这是支持第四章的基本数据结构,通过增加额外的开销来解决这个问题,在插入/检索时强制执行严格而特殊的项目排序,从而以O(1)的时间复杂度解决了这个问题。或者,如果您的数据已排序,使得每个项目都比其左侧(或右侧)的邻居大(或小),则可以使用专门的搜索算法,将查找时间降至O(log n)。这看起来可能是从我们之前看到的常数时间查找中迈出的不可能步骤,但有时这是最佳选择(特别是因为搜索算法更灵活,允许您以创造性的方式定义搜索)。

更高效的搜索

正如前面提到的,如果我们首先对数据进行排序,使得所有位于特定项左侧的元素比该项小(或大),我们可以获得更好的搜索性能。比较是通过对象的__eq____lt__魔术函数进行的,如果使用自定义对象,则可以用户定义。

注意

如果一个自定义对象没有定义__eq____lt__方法,它将只能与相同类型的对象进行比较,并且比较将使用实例在内存中的位置来进行。如果定义了这两个魔法函数,你可以使用标准库中的functools.total_ordering装饰器自动定义所有其他排序函数,尽管会略微降低性能。

两个必需的要素是排序算法和搜索算法。Python 列表具有内置的排序算法,使用的是 Tim 排序。在最佳情况下,Tim 排序可以在O(n)时间内对列表进行排序(在最坏情况下是O(n log n))。它通过利用多种类型的排序算法和使用启发式算法来猜测在给定数据情况下哪种算法表现最佳(更具体地说,它混合了插入排序和归并排序算法),从而实现了这种性能。

一旦列表被排序,我们可以使用二分查找(示例 3-3)找到我们想要的元素,其平均情况下的复杂度为O(log n)。它通过首先查看列表的中间元素并将其值与所需值进行比较来实现。如果中间点的值小于我们想要的值,我们考虑列表的右半部分,并继续以这种方式对列表进行二分,直到找到值或者知道该值不会出现在排序的列表中。因此,我们不需要读取列表中的所有值,就像对于线性搜索是必要的那样;相反,我们只需要读取它们的一个小子集。

示例 3-3. 通过排序列表进行高效搜索——二分查找
def binary_search(needle, haystack):
    imin, imax = 0, len(haystack)
    while True:
        if imin > imax:
            return -1
        midpoint = (imin + imax) // 2
        if haystack[midpoint] > needle:
            imax = midpoint
        elif haystack[midpoint] < needle:
            imin = midpoint+1
        else:
            return midpoint

这种方法使我们能够在不使用字典这种可能笨重的解决方案的情况下,在列表中查找元素。当操作的数据列表本身就是有序的时候,这一点尤其显著。在列表上执行二分查找以查找对象比将数据转换为字典,然后在字典上进行查找更有效率。尽管字典查找只需O(1)时间,但将数据转换为字典需要O(n)时间(而且字典不能有重复的键可能是不可取的)。另一方面,二分查找将花费O(log n)时间。

另外,Python 标准库中的bisect模块通过提供简单的方法将元素添加到列表中并保持其排序,以及使用高度优化的二分查找来查找元素,大大简化了这个过程。它通过提供将元素添加到正确排序位置的替代函数来实现这一点。由于列表始终保持排序,我们可以轻松找到我们正在寻找的元素(可以在bisect 模块的文档中找到示例)。此外,我们还可以使用bisect快速找到与我们寻找的内容最接近的元素(见示例 3-4)。这对比较两个类似但不完全相同的数据集非常有用。

示例 3-4. 使用bisect模块在列表中查找接近的值
import bisect
import random

def find_closest(haystack, needle):
    # bisect.bisect_left will return the first value in the haystack
    # that is greater than the needle
    i = bisect.bisect_left(haystack, needle)
    if i == len(haystack):
        return i - 1
    elif haystack[i] == needle:
        return i
    elif i > 0:
        j = i - 1
        # since we know the value is larger than needle (and vice versa for the
        # value at j), we don't need to use absolute values here
        if haystack[i] - needle > needle - haystack[j]:
            return j
    return i

important_numbers = []
for i in range(10):
    new_number = random.randint(0, 1000)
    bisect.insort(important_numbers, new_number)

# important_numbers will already be in order because we inserted new elements
# with bisect.insort
print(important_numbers)
# > [14, 265, 496, 661, 683, 734, 881, 892, 973, 992]

closest_index = find_closest(important_numbers, -250)
print(f"Closest value to -250: {important_numbers[closest_index]}")
# > Closest value to -250: 14

closest_index = find_closest(important_numbers, 500)
print(f"Closest value to 500: {important_numbers[closest_index]}")
# > Closest value to 500: 496

closest_index = find_closest(important_numbers, 1100)
print(f"Closest value to 1100: {important_numbers[closest_index]}")
# > Closest value to 1100: 992

总的来说,这触及了编写高效代码的基本规则:选择正确的数据结构并坚持使用它!尽管对于特定操作可能存在更高效的数据结构,但转换到这些数据结构的成本可能会抵消任何效率提升。

列表与元组的比较

如果列表和元组都使用相同的底层数据结构,它们之间有什么区别?总结来说,主要区别如下:

  • 列表是动态数组;它们是可变的,并允许调整大小(更改保存的元素数量)。

  • 元组是静态数组;它们是不可变的,创建后其中的数据不能更改。

  • 元组由 Python 运行时缓存,这意味着每次使用时我们无需与内核通信来保留内存。

这些区别揭示了两者之间的哲学差异:元组用于描述一个不变物体的多个属性,而列表可用于存储关于完全不同对象的数据集合。例如,电话号码的各部分非常适合元组:它们不会改变,如果改变了,就代表一个新对象或不同的电话号码。同样地,多项式的系数适合元组,因为不同的系数代表不同的多项式。另一方面,正在阅读本书的人的姓名更适合列表:数据内容和大小都在不断变化,但始终代表相同的概念。

需要注意的是,列表和元组都可以接受混合类型的数据。正如你将看到的,这可能会引入相当大的开销,并减少一些潜在的优化。如果我们强制所有数据都是相同类型的,则可以消除这种开销。在第六章中,我们将讨论通过使用numpy来减少内存使用和计算开销。此外,像标准库模块array这样的工具可以减少其他非数值情况下的这些开销。这暗示了我们将在后续章节中接触的性能编程的一个重要点:通用代码比专门设计用来解决特定问题的代码要慢得多。

此外,与列表相比不可变的元组使其成为轻量级数据结构。这意味着在存储元组时,内存开销不大,并且对它们的操作非常简单。而对于列表,正如你将学到的那样,它们的可变性是以需要更多的内存来存储它们以及在使用它们时需要更多计算的代价换来的。

动态数组列表

一旦创建了列表,我们可以自由地根据需要更改其内容:

>>> numbers = [5, 8, 1, 3, 2, 6]
>>> numbers[2] = 2 * numbers[0]  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
>>> numbers
[5, 8, 10, 3, 2, 6]

1

如前所述,这个操作的时间复杂度为O(1),因为我们可以立即找到存储在零到第二个元素中的数据。

另外,我们可以向列表中追加新数据并增大其大小:

>>> len(numbers)
6
>>> numbers.append(42)
>>> numbers
[5, 8, 10, 3, 2, 6, 42]
>>> len(numbers)
7

这是可能的,因为动态数组支持resize操作来增加数组的容量。当首次向大小为N的列表追加时,Python 必须创建一个足够大以容纳原始N个项目及额外追加的一个项目的新列表。但是,实际上分配了M > N个项目,以提供未来追加的额外空间。然后将旧列表中的数据复制到新列表中,并销毁旧列表。

这种策略的哲学是一个追加操作可能是许多追加操作的开端,并且通过请求额外的空间,我们可以减少必须进行的分配次数,从而减少所需的总内存复制次数。这是非常重要的,因为内存复制可能非常昂贵,特别是当列表大小开始增长时。图 3-2 展示了 Python 3.7 中这种过度分配的情况。规定这种增长的公式见例 3-5。³

hpp2 0302

图 3-2. 显示了使用append创建包含 8000 个元素的列表时,Python 分配了大约 8600 个元素的空间,多分配了 600 个元素!
例 3-5. Python 3.7 中的列表分配方程
M = (N >> 3) + (3 if N < 9 else 6)
N 0 1-4 5-8 9-16 17-25 26-35 36-46 991-1120
M 0 4 8 16 25 35 46 1120

随着我们添加数据,我们利用额外的空间并增加列表的有效大小 N。因此,随着添加新数据,N 增长,直到 N == M。在这一点上,没有额外的空间来插入新数据,我们必须创建一个 列表,其中包含更多的额外空间。这个新列表具有由 示例 3-5 中的方程给出的额外的头部空间,并且我们将旧数据复制到新空间中。

这一系列事件在 图 3-3 中以可视化方式展示。该图跟随在 示例 3-6 中对列表 l 执行的各种操作。

hpp2 0303

图 3-3. 列表在多次追加时如何发生变异的示例
示例 3-6. 调整列表大小
l = [1, 2]
for i in range(3, 7):
    l.append(i)

这种额外的分配发生在第一次 append。当列表直接创建时,如前面的示例,只分配了所需数量的元素。

虽然分配的额外头部空间量通常相当小,但它可能会累积起来。在 示例 3-7 中,我们可以看到即使对于 100,000 个元素,通过使用追加构建列表与使用列表理解相比,我们的内存使用量也增加了 2.7 倍:

示例 3-7. 追加与列表理解的内存和时间后果
>>> %memit [i*i for i in range(100_000)]
peak memory: 70.50 MiB, increment: 3.02 MiB

>>> %%memit l = []
... for i in range(100_000):
...     l.append(i * 2)
...
peak memory: 67.47 MiB, increment: 8.17 MiB

>>> %timeit [i*i for i in range(100_000)]
7.99 ms ± 219 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

>>> %%timeit l = []
... for i in range(100_000):
...     l.append(i * 2)
...
12.2 ms ± 184 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

总体运行时间也较慢,因为必须运行额外的 Python 语句以及重新分配内存的成本。当您维护许多小列表或保留特别大的列表时,这种影响尤为明显。假设我们存储了 1,000,000 个包含 10 个元素的列表,我们会假定正在使用 10,000,000 个元素的内存。然而,实际上,如果使用 append 运算符来构建列表,最多可能会分配 16,000,000 个元素。同样,对于包含 100,000,000 个元素的大列表,我们实际上分配了 112,500,007 个元素!

元组作为静态数组

元组是固定的且不可变的。这意味着一旦创建了元组,与列表不同,它就不能被修改或调整大小:

>>> t = (1, 2, 3, 4)
>>> t[0] = 5
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

然而,尽管它们不支持调整大小,我们可以将两个元组连接在一起并形成一个新的元组。该操作类似于列表上的 resize 操作,但我们不会为结果元组分配任何额外的空间:

>>> t1 = (1, 2, 3, 4)
>>> t2 = (5, 6, 7, 8)
>>> t1 + t2
(1, 2, 3, 4, 5, 6, 7, 8)

如果我们将其视为对列表的 append 操作进行比较,我们会发现它的执行速度为 O(n),而不是列表的 O(1) 速度。这是因为每次添加元素时,我们都必须分配和复制元组,而不是只有当列表的额外头部空间用完时才复制。因此,没有类似于 append 的就地操作;添加两个元组始终返回一个新的元组,该元组位于内存中的新位置。

不为重新调整大小存储额外的空间有利于节省资源。用任何append操作创建的大小为 100,000,000 的列表实际上使用了 112,500,007 个元素的内存空间,而持有相同数据的元组只会精确地使用 100,000,000 个元素的内存空间。这使得元组轻量且在数据变为静态时更可取。

此外,即使我们创建一个没有append(因此没有由append操作引入的额外预留空间)的列表,它在内存中仍然比持有相同数据的元组大。这是因为列表必须跟踪关于它们当前状态的更多信息,以便高效地调整大小。虽然这些额外信息非常小(相当于一个额外的元素),但如果有数百万个列表在使用,这些信息会累积起来。

元组静态特性的另一个好处是 Python 在后台进行的资源缓存。Python 具有垃圾回收功能,这意味着当变量不再使用时,Python 会释放该变量使用的内存,并将其归还给操作系统供其他应用程序(或其他变量)使用。然而,对于大小为 1–20 的元组,当它们不再使用时,空间并不会立即归还给系统:每个大小的元组最多保存 20,000 个以供将来使用。这意味着当将来需要一个这种大小的新元组时,我们不需要与操作系统通信来找到一个内存区域来放置数据,因为我们已经有了一定的空闲内存储备。然而,这也意味着 Python 进程会有一些额外的内存开销。

虽然这可能看起来是一个小优点,但这是元组的奇妙之处之一:它们可以很容易快速地创建,因为它们可以避免与操作系统的通信,而这可能会让你的程序付出相当多的时间成本。示例 3-8 表明,实例化一个列表可能比实例化一个元组慢 5.1 倍——如果在快速循环中执行此操作,这些时间成本可能会迅速累积!

示例 3-8. 列表与元组的实例化时间对比
>>> %timeit l = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
95 ns ± 1.87 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

>>> %timeit t = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
12.5 ns ± 0.199 ns per loop (mean ± std. dev. of 7 runs, 100000000 loops each)

总结

列表和元组是在数据本身已经有内在顺序时使用的快速且低开销的对象。这种内在顺序使得你可以在这些结构中避开搜索问题:如果顺序事先已知,查找是O(1),避免了昂贵的O(n)线性搜索。虽然列表可以调整大小,但你必须小心理解过度分配量,以确保数据集仍然可以适应内存。另一方面,元组可以快速创建,而且没有列表的额外开销,代价是不可修改性。在“Python 列表足够好吗?”中,我们讨论了如何预先分配列表以减轻对 Python 列表频繁附加的一些负担,并查看了其他可以帮助解决这些问题的优化方法。

在下一章中,我们将讨论字典的计算性质,它解决了无序数据的搜索/查找问题,但代价是额外开销。

¹ O(1)使用大 O 符号来表示算法的效率。关于这个主题的良好介绍可以在Sarah Chima 的这篇dev.to文章或者Thomas H. Cormen等人的《算法导论》(MIT 出版社)的介绍章节中找到。

² 在 64 位计算机上,拥有 12 KB 的内存可以给你 725 个桶,而拥有 52 GB 的内存可以给你 3,250,000,000 个桶!

³ 负责这种过度分配的代码可以在 Python 源代码中的Objects/listobject.c:list_resize中看到。

第四章:字典和集合

当数据没有内在顺序(除了插入顺序)但具有可用于引用数据的唯一对象时(引用对象通常是字符串,但可以是任何可散列类型),字典和集合是理想的数据结构。这个引用对象称为,而数据是。字典和集合几乎是相同的,唯一的区别在于集合不包含实际的值:集合只是唯一键的集合。正如其名称所示,集合非常适合执行集合操作。

注意

可散列类型是指同时实现了__hash__魔术函数和__eq____cmp__的类型。Python 中所有原生类型已经实现了这些方法,任何用户类都有默认值。更多详情请参见“哈希函数和熵”。

在前一章中,我们看到在没有内在顺序的列表/元组(通过搜索操作)上,我们最多只能达到O(log n)的查找时间。然而,字典和集合基于任意索引可以实现O(1)的查找时间。此外,与列表/元组类似,字典和集合的插入时间也是O(1)。¹ 正如我们将在“字典和集合的工作原理”中看到的,这种速度是通过使用开放地址哈希表作为底层数据结构实现的。

然而,使用字典和集合是有代价的。首先,它们通常在内存中占用更大的空间。此外,虽然插入/查找的复杂度是O(1),但实际速度很大程度上取决于正在使用的哈希函数的评估速度。如果哈希函数评估速度慢,那么字典或集合上的任何操作都会变得很慢。

让我们来看一个例子。假设我们想为电话簿中的每个人存储联系信息。我们希望以一种简单的方式存储这些信息,以便将来可以简单地回答“John Doe 的电话号码是多少?”这样的问题。使用列表,我们将电话号码和姓名按顺序存储,并扫描整个列表以找到所需的电话号码,如示例 4-1 所示。

示例 4-1:使用列表进行电话簿查找
def find_phonenumber(phonebook, name):
    for n, p in phonebook:
        if n == name:
            return p
    return None

phonebook = [
    ("John Doe", "555-555-5555"),
    ("Albert Einstein", "212-555-5555"),
]
print(f"John Doe's phone number is {find_phonenumber(phonebook, 'John Doe')}")
注意

我们也可以通过对列表进行排序并使用bisect模块(来自示例 3-4)来实现O(log n)的性能。

然而,使用字典,我们可以简单地将“索引”作为姓名,将“值”作为电话号码,如示例 4-2 所示。这使得我们可以简单地查找我们需要的值,并直接引用它,而不必读取数据集中的每个值。

示例 4-2:使用字典进行电话簿查找
phonebook = {
    "John Doe": "555-555-5555",
    "Albert Einstein" : "212-555-5555",
}
print(f"John Doe's phone number is {phonebook['John Doe']}")

对于大型电话簿,字典的O(1)查找与线性搜索列表的O(n)(或者最好情况下,使用二分模块的O(log n)复杂度)之间的差异是非常显著的。

提示

创建一个脚本,用于比较列表-bisect方法和字典在电话簿中查找数字的性能。随着电话簿大小的增长,时间如何扩展?

另一方面,如果我们想回答“我的电话簿中有多少个独特的名字?”,我们可以利用集合的威力。回想一下,集合就是一组独特的键的集合——这正是我们希望在数据中实施的属性。这与基于列表的方法形成鲜明对比,后者需要通过将所有名字与其他所有名字进行比较来单独执行此属性。示例 4-3 进行了说明。

示例 4-3. 使用列表和集合查找唯一名字
def list_unique_names(phonebook):
    unique_names = []
    for name, phonenumber in phonebook: ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
        first_name, last_name = name.split(" ", 1)
        for unique in unique_names: ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
            if unique == first_name:
                break
        else:
            unique_names.append(first_name)
    return len(unique_names)

def set_unique_names(phonebook):
    unique_names = set()
    for name, phonenumber in phonebook: ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png)
        first_name, last_name = name.split(" ", 1)
        unique_names.add(first_name) ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/4.png)
    return len(unique_names)

phonebook = [
    ("John Doe", "555-555-5555"),
    ("Albert Einstein", "212-555-5555"),
    ("John Murphey", "202-555-5555"),
    ("Albert Rutherford", "647-555-5555"),
    ("Guido van Rossum", "301-555-5555"),
]

print("Number of unique names from set method:", set_unique_names(phonebook))
print("Number of unique names from list method:", list_unique_names(phonebook))

1, 3

我们必须检查电话簿中的所有条目,因此此循环的成本为O(n)

2

在这里,我们必须检查当前名字是否已经存在于我们已经看到的所有唯一名字中。如果是一个新的唯一名字,我们就将其添加到我们的唯一名字列表中。然后我们继续通过列表,对电话簿中的每个项目执行此步骤。

4

对于集合方法,我们不需要遍历已经见过的所有唯一名字,而是可以简单地将当前名字添加到我们的唯一名字集合中。因为集合保证它们包含的键的唯一性,如果您尝试添加已经在集合中的项,该项就不会被添加。此外,此操作的成本为O(1)

列表算法的内部循环遍历unique_names,其开始为空,并在最坏的情况下(所有名字都是唯一的情况下)增长到与phonebook大小相同。这可以被看作是在一个不断增长的列表上执行每个名字的线性搜索。因此,完整算法的表现为O(n²)

另一方面,集合算法没有内部循环;set.add操作是一个O(1)的过程,无论电话簿有多大,它都会在固定数量的操作内完成(这其中有一些小的注意事项,我们在讨论字典和集合的实现时会涉及到)。因此,这个算法的复杂性的唯一非常数贡献是对电话簿的循环遍历,使得这个算法的表现为O(n)

当使用具有 10,000 个条目和 7,412 个独特名字的phonebook来计时这两种算法时,我们可以看到O(n)O(n²)之间的巨大差异:

>>> %timeit list_unique_names(large_phonebook)
1.13 s ± 26.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

>>> %timeit set_unique_names(large_phonebook)
4.48 ms ± 177 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

换句话说,集合算法使我们获得了 252 倍的加速!此外,随着phonebook的大小增长,速度增益也会增加(当phonebook有 100,000 条条目和 15,574 个独特的名字时,我们获得了 557 倍的加速)。

字典和集合是如何工作的?

字典和集合使用哈希表实现其 O(1) 的查找和插入。这种效率是通过非常巧妙地使用哈希函数将任意键(例如字符串或对象)转换为列表的索引来实现的。哈希函数和列表稍后可以用于立即确定任何特定数据的位置,而无需搜索。通过将数据的键转换为可以像列表索引一样使用的东西,我们可以获得与列表相同的性能。此外,我们不必通过数字索引来引用数据,这本身就意味着数据的某种排序,而是可以通过这个任意键来引用它。

插入和检索

要从头开始创建哈希表,我们首先需要一些分配的内存,类似于我们为数组所做的。对于数组,如果我们要插入数据,我们只需找到最小的未使用存储桶并在那里插入我们的数据(如果需要,进行调整大小)。对于哈希表,我们必须首先确定在这个连续的内存块中数据的位置。

插入新数据的位置取决于我们要插入的数据的两个属性:键的哈希值以及值与其他对象的比较。这是因为在插入数据时,首先对键进行哈希处理并进行掩码操作,使其变成数组中的有效索引。² 掩码确保哈希值(可以是任何整数)适应于分配的存储桶数量。因此,如果我们分配了 8 个存储块而我们的哈希值是 28975,我们会考虑索引为 28975 & 0b111 = 7 的存储桶。但是,如果我们的字典已经增长到需要 512 个存储块,掩码则变为 0b111111111(在这种情况下,我们将考虑索引为 28975 & 0b11111111 的存储桶)。

现在我们必须检查这个存储桶是否已经被使用。如果为空,我们可以将键和值插入到这个内存块中。我们存储键以确保在查找时获取正确的值。如果已经被使用并且存储桶的值等于我们希望插入的值(使用 cmp 内置函数进行比较),则键/值对已经存在于哈希表中,我们可以直接返回。然而,如果值不匹配,我们必须找一个新的位置来存放数据。

作为额外的优化,Python 首先将键/值数据追加到标准数组中,然后仅在哈希表中存储这个数组的索引。这样可以减少内存使用量,达到 30%至 95%的减少。³ 此外,这使我们保留了一个关于新项添加到字典中顺序的记录(自 Python 3.7 起,所有字典都有此保证)。

要找到新的索引,我们使用一个简单的线性函数计算它,这称为探测方法。Python 的探测机制从原始哈希的高阶位添加贡献(回想一下,对于长度为 8 的表,我们仅考虑了初始索引的哈希的最后三位,通过使用掩码值 mask = 0b111 = bin(8 - 1))。使用这些高阶位为每个哈希提供了不同的下一个可能哈希序列,有助于避免未来的碰撞。

选择生成新索引的算法时有很大的自由度;但是,很重要的是方案必须访问表中的每个可能索引,以便均匀分布数据。数据在哈希表中的分布均匀程度称为负载因子,与哈希函数的熵有关。示例 4-4 中的伪代码展示了在 CPython 3.7 中使用的哈希索引计算。这也展示了有关哈希表的一个有趣事实:它们大部分的存储空间是空的!

示例 4-4. 字典查找序列
def index_sequence(key, mask=0b111, PERTURB_SHIFT=5):
    perturb = hash(key) ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
    i = perturb & mask
    yield i
    while True:
        perturb >>= PERTURB_SHIFT
        i = (i * 5 + perturb + 1) & mask
        yield i

1

hash 返回一个整数,而在实际的 CPython 代码中使用的是无符号整数。因此,这段伪代码并不能完全复制 CPython 中的行为;但是,它是一个很好的近似。

这种探测方法是线性探测的一种修改版。在线性探测中,我们简单地生成值 i = (i * 5 + perturb + 1) & mask,其中 i 被初始化为键的哈希值的。⁴ 需要注意的一点是,线性探测仅处理哈希的最后几位,并且忽略其余部分(即对于包含八个元素的字典,我们仅查看最后三位,此时掩码为 0x111)。这意味着,如果对两个项目进行哈希处理得到相同的最后三位二进制数,不仅会发生碰撞,而且探测索引的序列也将相同。Python 使用的扰动方案将开始考虑来自项目哈希更多位以解决此问题。

当我们在特定键上执行查找时,也会执行类似的过程:给定键被转换为一个索引,然后检查该索引。如果该索引中的键匹配(回想一下,在执行插入操作时,我们还存储了原始键),那么我们可以返回该值。如果不匹配,我们将使用相同的方案创建新的索引,直到我们找到数据或者遇到一个空桶。如果遇到空桶,我们可以得出结论,表中不存在该数据。

图 4-1 展示了向哈希表添加数据的过程。在这里,我们选择创建一个哈希函数,简单地使用输入的第一个字母。我们通过使用 Python 的ord函数获取输入的第一个字母的整数表示来实现这一点(请回想哈希函数必须返回整数)。正如我们将在“哈希函数和熵”中看到的,Python 为其大多数类型提供了哈希函数,因此通常您不必自己提供哈希函数,除非在极端情况下。

hpp2 0401

图 4-1. 插入带有冲突的哈希表的结果

插入关键字Barcelona引发了冲突,并使用示例 4-4 中的方案计算了一个新索引。此字典也可以使用示例 4-5 中的代码在 Python 中创建。

示例 4-5. 自定义哈希函数
class City(str):
    def __hash__(self):
        return ord(self[0])

# We create a dictionary where we assign arbitrary values to cities
data =  {
    City("Rome"): 'Italy',
    City("San Francisco"): 'USA',
    City("New York"): 'USA',
    City("Barcelona"): 'Spain',
}

在这种情况下,BarcelonaRome导致了哈希冲突(图 4-1 显示了这种插入的结果)。这是因为,对于一个具有四个元素的字典,我们的掩码值为0b111。因此,BarcelonaRome将尝试使用相同的索引:

hash("Barcelona") = ord("B") & 0b111
                  = 66 & 0b111
                  = 0b1000010 & 0b111
                  = 0b010 = 2

hash("Rome") = ord("R") & 0b111
             = 82 & 0b111
             = 0b1010010 & 0b111
             = 0b010 = 2

删除

当从哈希表中删除一个值时,我们不能简单地向内存的那个桶写入NULL。这是因为我们在探测哈希冲突时使用了NULL作为哨兵值。因此,我们必须写入一个特殊值,表示该桶为空,但在解决哈希冲突时仍可能存在后续值。因此,如果从字典中删除了“Rome”,那么对“Barcelona”的后续查找首先将看到曾经是“Rome”位置的哨兵值,而不是停止,并继续检查index_sequence给出的下一个索引。这些空槽可以在将来写入,并在调整哈希表大小时移除。

调整大小

当更多条目插入哈希表时,表本身必须调整大小以容纳它们。可以证明,只有不超过三分之二满的表在保持最佳空间节省的同时仍具有预期碰撞数的良好界限。因此,当表达到这一关键点时,它会被扩展。为此,分配一个更大的表(即,内存中保留更多桶),调整掩码以适应新表,并重新将旧表的所有元素重新插入新表中。这需要重新计算索引,因为更改的掩码会改变结果索引。因此,调整大小大的哈希表可能非常昂贵!但是,由于只有在表太小时才执行此调整操作,而不是在每次插入时执行,因此插入的摊销成本仍为O(1)。⁵

默认情况下,字典或集合的最小大小为 8(即使您只存储三个值,Python 也将分配八个元素),如果字典超过三分之二的容量,它将按 3 倍调整大小。因此,一旦第六个项目被插入到原始空字典中,它将调整大小以容纳 18 个元素。在这一点上,一旦第 13 个元素被插入对象中,它将调整大小为 39,然后 81,依此类推,始终以 3 倍增加大小(我们将解释如何计算字典大小在“哈希函数和熵”)。这给出了以下可能的大小:

8; 18; 39; 81; 165; 333; 669; 1,341; 2,685; 5,373; 10,749; 21,501; 43,005; ...

需要注意的是,调整大小可能使哈希表变大或者变小。也就是说,如果哈希表中删除了足够多的元素,表的大小可以缩小。但是,调整大小只会在插入时发生

哈希函数和熵

在 Python 中,对象通常是可哈希的,因为它们已经具有与它们相关联的内置__hash____cmp__函数。对于数值类型(intfloat),哈希基于它们表示的数字的位值。元组和字符串的哈希值基于它们的内容。另一方面,列表不支持哈希化,因为它们的值可以改变。由于列表的值可以改变,表示列表的哈希也可能改变,这将改变哈希表中该键的相对位置。⁶

用户定义的类也有默认的哈希和比较函数。默认的__hash__函数简单地返回对象由内置id函数给出的内存位置。类似地,__cmp__运算符比较对象的内存位置的数值。

一般来说,这是可以接受的,因为类的两个实例通常是不同的,不应在哈希表中发生碰撞。但是,在某些情况下,我们希望使用setdict对象来消除项目之间的歧义。考虑以下类定义:

class Point(object):
    def __init__(self, x, y):
        self.x, self.y = x, y

如果我们使用相同的xy值实例化多个Point对象,它们将在内存中都是独立的对象,因此具有不同的内存位置,这将使它们具有不同的哈希值。这意味着将它们全部放入一个set中会导致它们都有独立的条目:

>>> p1 = Point(1,1)
>>> p2 = Point(1,1)
>>> set([p1, p2])
set([<__main__.Point at 0x1099bfc90>, <__main__.Point at 0x1099bfbd0>])
>>> Point(1,1) in set([p1, p2])
False

我们可以通过形成一个基于对象实际内容而不是内存中对象位置的自定义哈希函数来解决这个问题。哈希函数可以是任何函数,只要对于同一对象始终给出相同的结果(还有关于哈希函数熵的考虑,我们稍后会讨论)。重新定义Point类如下将产生我们期望的结果:

class Point(object):
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __hash__(self):
        return hash((self.x, self.y))

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

这使我们能够创建一个由Point对象的属性而不是实例化对象的内存地址索引的集合或字典的条目:

>>> p1 = Point(1,1)
>>> p2 = Point(1,1)
>>> set([p1, p2])
set([<__main__.Point at 0x109b95910>])
>>> Point(1, 1) in set([p1, p2])
True

正如我们讨论哈希碰撞时所提到的,一个自定义选择的哈希函数应该小心地均匀分布哈希值,以避免碰撞。有许多碰撞会降低哈希表的性能:如果大多数键都发生碰撞,我们需要不断地“探测”其他值,实际上是遍历字典的一个可能很大的部分以找到所需的键。在最坏的情况下,当字典中的所有键都发生碰撞时,字典中查找的性能是O(n),因此与我们搜索列表时的性能相同。

如果我们知道我们要在字典中存储 5,000 个值,并且我们需要为我们希望用作键的对象创建一个哈希函数,我们必须意识到该字典将存储在大小为 16,384⁷ 的哈希表中,因此我们的哈希的最后 14 位被用来创建索引(对于这个大小的哈希表,掩码是bin(16_384 - 1) = 0b11111111111111)。

“我的哈希函数分布得有多好”这个想法被称为哈希函数的。熵的定义为

S = i p ( i ) · log p ( i )

这里p(i)是哈希函数给出哈希值i的概率。当每个哈希值被选择的概率相等时,它被最大化。最大化熵的哈希函数被称为理想哈希函数,因为它保证了最小数量的碰撞。

对于一个无限大的字典,用于整数的哈希函数是理想的。这是因为整数的哈希值就是整数本身!对于一个无限大的字典,掩码值是无限的,因此我们考虑哈希值中的所有位。因此,给定任意两个数字,我们可以保证它们的哈希值不会相同。

然而,如果我们将这个字典设为有限的,我们就不能再有这个保证了。例如,对于一个有四个元素的字典,我们使用的掩码是0b111。因此数字5的哈希值是5 & 0b111 = 5,而501的哈希值是501 & 0b111 = 5,它们的条目会发生碰撞。

注意

要找到一个具有任意元素数N的字典的掩码,我们首先找到该字典必须拥有的最小桶数,以使其仍然是三分之二满(N * (2 / 3 + 1))。然后我们找到能容纳这么多元素的最小字典大小(8;32;128;512;2,048;等等),并找到保存这个数量所需的位数。例如,如果N=1039,那么我们必须至少有 1,731 个桶,这意味着我们需要一个有 2,048 个桶的字典。因此掩码是bin(2048 - 1) = 0b11111111111

使用有限字典时,没有单一的最佳哈希函数可用。然而,提前知道将使用的值范围和字典的大小有助于做出良好的选择。例如,如果我们将所有两个小写字母的 676 种组合存储为字典中的键(aa, ab, ac等),那么一个良好的哈希函数将是示例 4-6 中显示的那种。

示例 4-6. 最佳的两字母哈希函数
def twoletter_hash(key):
    offset = ord('a')
    k1, k2 = key
    return (ord(k2) - offset) + 26 * (ord(k1) - offset)

这对任何两个小写字母组合都不会发生哈希碰撞,考虑到0b1111111111掩码(一个包含 676 个值的字典将存储在长度为 2048 的哈希表中,其掩码为bin(2048 - 1) = 0b11111111111)。

示例 4-7 非常明确地展示了为用户定义的类使用不良哈希函数的后果——在这里,使用不良哈希函数的代价(事实上,这是可能的最差的哈希函数!)是查找减慢了 41.8 倍。

示例 4-7. 良好和不良哈希函数之间的时间差异
import string
import timeit

class BadHash(str):
    def __hash__(self):
        return 42

class GoodHash(str):
    def __hash__(self):
        """
 This is a slightly optimized version of twoletter_hash
 """
        return ord(self[1]) + 26 * ord(self[0]) - 2619

baddict = set()
gooddict = set()
for i in string.ascii_lowercase:
    for j in string.ascii_lowercase:
        key = i + j
        baddict.add(BadHash(key))
        gooddict.add(GoodHash(key))

badtime = timeit.repeat(
    "key in baddict",
    setup = "from __main__ import baddict, BadHash; key = BadHash('zz')",
    repeat = 3,
    number = 1_000_000,
)
goodtime = timeit.repeat(
    "key in gooddict",
    setup = "from __main__ import gooddict, GoodHash; key = GoodHash('zz')",
    repeat = 3,
    number = 1_000_000,
)

print(f"Min lookup time for baddict: {min(badtime)}")
print(f"Min lookup time for gooddict: {min(goodtime)}")

# Results:
#   Min lookup time for baddict: 17.719061855008476
#   Min lookup time for gooddict: 0.42408075400453527

字典和命名空间

在字典上进行查找速度很快;然而,不必要地这样做会减慢您的代码,就像任何多余的行一样。一个显现这种情况的地方是 Python 的命名空间管理,在这里大量使用字典来进行查找。

每当在 Python 中调用变量、函数或模块时,都有一个层次结构确定它查找这些对象的位置。首先,Python 查找locals()数组,该数组包含所有局部变量的条目。Python 努力使局部变量查找快速化,这是链中唯一不需要字典查找的部分。如果在那里找不到,就会搜索globals()字典。最后,如果对象在那里找不到,就会搜索__builtin__对象。需要注意的是,虽然locals()globals()显式是字典,而__builtin__在技术上是模块对象,但当在__builtin__中搜索特定属性时,我们只是在它的locals()映射中进行字典查找(这对所有模块对象和类对象都适用!)。

为了使这更清晰,让我们看一个简单的例子,调用在不同范围内定义的函数(示例 4-8)。我们可以使用dis模块(示例 4-9)来解开函数,以更好地理解这些命名空间查找是如何进行的(参见“使用 dis 模块来检查 CPython 字节码”)。

示例 4-8. 命名空间查找
import math
from math import sin

def test1(x):
    """
 >>> %timeit test1(123_456)
 162 µs ± 3.82 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
 """
    res = 1
    for _ in range(1000):
        res += math.sin(x)
    return res

def test2(x):
    """
 >>> %timeit test2(123_456)
 124 µs ± 6.77 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
 """
    res = 1
    for _ in range(1000):
        res += sin(x)
    return res

def test3(x, sin=math.sin):
    """
 >>> %timeit test3(123_456)
 105 µs ± 3.35 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
 """
    res = 1
    for _ in range(1000):
        res += sin(x)
    return res
示例 4-9. 命名空间查找拆解
>>> dis.dis(test1)
    ...cut..
             20 LOAD_GLOBAL              1 (math)
             22 LOAD_METHOD              2 (sin)
             24 LOAD_FAST                0 (x)
             26 CALL_METHOD              1
    ...cut..

>>> dis.dis(test2)
    ...cut...
             20 LOAD_GLOBAL              1 (sin)
             22 LOAD_FAST                0 (x)
             24 CALL_FUNCTION            1
    ...cut...

>>> dis.dis(test3)
    ...cut...
             20 LOAD_FAST                1 (sin)
             22 LOAD_FAST                0 (x)
             24 CALL_FUNCTION            1
    ...cut...

第一个函数,test1,通过显式查看数学库进行 sin 的调用。这也可以从生成的字节码中看出:首先必须加载对 math 模块的引用,然后我们对此模块进行属性查找,直到最终获得对 sin 函数的引用。这通过两次字典查找完成:一次是找到 math 模块,另一次是在模块中找到 sin 函数。

另一方面,test2math 模块显式导入 sin 函数,然后函数直接在全局命名空间中可访问。这意味着我们可以避免对 math 模块和随后的属性查找。然而,我们仍然必须在全局命名空间中找到 sin 函数。这是另一个理由明确指定从模块导入哪些函数。这种做法不仅使代码更易读,因为读者知道确切需要从外部源中获取哪些功能,而且简化了特定函数实现的更改,并且通常加快了代码!

最后,test3sin 函数定义为关键字参数,其默认值是对 math 模块内 sin 函数的引用。虽然我们仍然需要在模块内查找对此函数的引用,但这只在首次定义 test3 函数时需要。之后,对 sin 函数的引用将作为默认关键字参数的形式存储在函数定义中的局部变量中。如前所述,局部变量无需进行字典查找即可找到;它们存储在一个非常紧凑的数组中,具有非常快的查找时间。因此,查找函数是非常快的!

虽然这些效果是 Python 命名空间管理方式的有趣结果,但test3显然不是“Pythonic”。幸运的是,只有在大量调用时(例如在非常快的循环的最内部块中,比如 Julia 集的示例中),这些额外的字典查找才会开始降低性能。考虑到这一点,一个更可读的解决方案是在循环开始前用全局引用设置一个局部变量。我们仍然需要在每次调用函数时做一次全局查找,但在循环中对该函数的所有调用将变得更快。这表明,即使是代码中微小的减速也可能在代码被执行数百万次时被放大。即使字典查找本身可能只需要几百纳秒,如果我们在这个查找上进行数百万次的循环,这些纳秒很快就会累加起来。

注意

有关微基准测试的消息:在示例 4-8 中,我们添加了额外的工作量,包括for循环和对res变量的修改,这可能看起来令人困惑。最初,这些函数中每个都只有相关的return sin(x)行,没有其他内容。因此,我们也得到了纳秒级运行时间和毫无意义的结果!

当我们在每个函数内部增加更大的工作负载时,就像通过循环和对res变量的修改完成的那样,我们开始看到了我们预期的结果。通过在函数内部增加更大的工作负载,我们可以更加确定我们不是在测量基准/计时过程中的开销。一般来说,当你运行基准测试并且在纳秒级别上有时间差异时,重要的是停下来思考一秒钟,思考你正在运行的实验是否有效,或者你是否在测量噪声或由仪器测量造成的无关时间。

总结

字典和集合提供了一种绝佳的方式来存储可以通过键索引的数据。通过哈希函数使用这个键的方式,可以极大地影响数据结构的性能。此外,了解字典的工作原理不仅可以帮助你更好地组织数据,还可以帮助你组织代码,因为字典是 Python 内部功能的一个固有部分。

在下一章中,我们将探讨生成器,这些生成器允许我们为代码提供数据,并且可以更好地控制顺序,而无需预先将完整数据集存储在内存中。这使我们能够避开在使用 Python 内置数据结构时可能遇到的许多障碍。

¹ 正如我们将在“哈希函数和熵”中讨论的那样,字典和集合非常依赖它们的哈希函数。如果特定数据类型的哈希函数不是O(1),那么包含该类型的任何字典或集合将不再具有O(1)的保证。

² 掩码是一种二进制数,用于截断数字的值。因此,0b1111101 & 0b111 = 0b101 = 5代表了0b111掩码操作0b1111101数的操作。这个操作也可以理解为取一个数字的最低有效位的某些位数。

³ 导致这一改进的讨论可以在https://oreil.ly/Pq7Lm找到。

⁴ 数值5来自于线性同余生成器(LCG)的特性,该生成器用于生成随机数。

⁵ 摊还分析关注的是算法的平均复杂度。这意味着一些插入操作可能会更昂贵,但平均而言,插入操作的复杂度是O(1)

⁶ 关于这个的更多信息可以在https://oreil.ly/g4I5-找到。

⁷ 5000 个数值需要一个至少有 8,333 个桶的字典。能容纳这么多元素的第一个可用尺寸是 16,384。

第五章:迭代器和生成器

当许多有其他语言经验的人开始学习 Python 时,他们对 for 循环符号的差异感到惊讶。也就是说,不是写成

*`#` `Other languages`*
for (i=0; i<N; i++) {
    do_work(i);
}

他们被介绍了一个名为 range 的新函数:

*`# Python`*
for i in range(N):
    do_work(i)

在 Python 代码示例中,似乎我们正在调用一个名为 range 的函数,它创建了 for 循环所需的所有数据。直观地说,这可能是一个耗时的过程——如果我们尝试循环遍历 1 到 100,000,000 的数字,那么我们需要花费大量时间创建该数组!然而,这就是 生成器 发挥作用的地方:它们基本上允许我们惰性评估这些函数,因此我们可以使用这些特殊用途函数而不会影响性能。

要理解这个概念,让我们实现一个函数,它通过填充列表和使用生成器来计算多个 Fibonacci 数字:

def fibonacci_list(num_items):
    numbers = []
    a, b = 0, 1
    while len(numbers) < num_items:
        numbers.append(a)
        a, b = b, a+b
    return numbers

def fibonacci_gen(num_items):
    a, b = 0, 1
    while num_items:
        yield a  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
        a, b = b, a+b
        num_items -= 1

1

此函数将会 yield 多个值而不是返回一个值。这将使得这个看起来很普通的函数成为一个可以重复轮询下一个可用值的生成器。

首先要注意的是 fibonacci_list 实现必须创建并存储所有相关的 Fibonacci 数字列表。因此,如果我们想要获取序列的 10,000 个数字,函数将执行 10,000 次向 numbers 列表追加(正如我们在 第三章 中讨论过的,这会带来额外的开销),然后返回它。

另一方面,生成器能够“返回”许多值。每次代码执行到 yield 时,函数都会发出其值,当请求另一个值时,函数恢复运行(保持其先前状态)并发出新值。当函数达到结尾时,会抛出 StopIteration 异常,表示给定的生成器没有更多的值。因此,尽管这两个函数最终必须执行相同数量的计算,但前面循环的 fibonacci_list 版本使用的内存是后者的 10,000 倍(或 num_items 倍)。

有了这段代码,我们可以分解使用我们的fibonacci_listfibonacci_gen实现的for循环。在 Python 中,for循环要求我们要循环遍历的对象支持迭代。这意味着我们必须能够从我们想要遍历的对象中创建一个迭代器。为了从几乎任何对象创建迭代器,我们可以使用 Python 内置的iter函数。对于列表、元组、字典和集合,这个函数返回对象中的项或键的迭代器。对于更复杂的对象,iter返回对象的__iter__属性的结果。由于fibonacci_gen已经返回一个迭代器,在其上调用iter是一个微不足道的操作,它返回原始对象(因此type(fibonacci_gen(10)) == type(iter(fibonacci_gen(10))))。然而,由于fibonacci_list返回一个列表,我们必须创建一个新对象,即列表迭代器,它将迭代列表中的所有值。一般来说,一旦创建了迭代器,我们就可以用它调用next()函数,获取新的值,直到抛出StopIteration异常。这为我们提供了for循环的一个良好分解视图,如示例 5-1 所示。

示例 5-1. Python for循环分解
# The Python loop
for i in object:
    do_work(i)

# Is equivalent to
object_iterator = iter(object)
while True:
    try:
        i = next(object_iterator)
    except StopIteration:
        break
    else:
        do_work(i)

for循环代码显示,在使用fibonacci_list时,我们调用iter会多做一些工作,而使用fibonacci_gen时,我们创建一个生成器,该生成器轻松转换为迭代器(因为它本身就是迭代器!);然而,对于fibonacci_list,我们需要分配一个新列表并预先计算其值,然后仍然必须创建一个迭代器。

更重要的是,预计算fibonacci_list列表需要分配足够的空间来存储完整的数据集,并将每个元素设置为正确的值,即使我们始终一次只需一个值。这也使得列表分配变得无用。事实上,这可能使循环无法运行,因为它可能尝试分配比可用内存更多的内存(fibonacci_list(100_000_000)将创建一个 3.1 GB 大的列表!)。通过时间结果的比较,我们可以非常明确地看到这一点:

def test_fibonacci_list():
    """
 >>> %timeit test_fibonacci_list()
 332 ms ± 13.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

 >>> %memit test_fibonacci_list()
 peak memory: 492.82 MiB, increment: 441.75 MiB
 """
    for i in fibonacci_list(100_000):
        pass

def test_fibonacci_gen():
    """
 >>> %timeit test_fibonacci_gen()
 126 ms ± 905 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

 >>> %memit test_fibonacci_gen()
 peak memory: 51.13 MiB, increment: 0.00 MiB
 """
    for i in fibonacci_gen(100_000):
        pass

正如我们所见,生成器版本的速度是列表版本的两倍多,并且不需要测量的内存,而fibonacci_list则需要 441 MB。此时可能会认为应该在任何地方使用生成器代替创建列表,但这会带来许多复杂性。

比如说,如果你需要多次引用斐波那契数列,fibonacci_list会提供一个预先计算好的数字列表,而fibonacci_gen则需要一遍又一遍地重新计算。一般来说,改用生成器而非预先计算的数组需要进行有时并不容易理解的算法改动。¹

注意

在设计代码架构时必须做出的一个重要选择是,您是否要优化 CPU 速度还是内存效率。在某些情况下,使用额外的内存来预先计算值并为将来的引用做好准备会节省整体速度。在其他情况下,内存可能受限,唯一的解决方案是重新计算值,而不是将它们保存在内存中。每个问题都有其对于 CPU/内存权衡的考虑因素。

这种的一个简单示例经常在源代码中看到,就是使用一个发生器来创建一个数字序列,然后使用列表推导来计算结果的长度:

divisible_by_three = len([n for n in fibonacci_gen(100_000) if n % 3 == 0])

尽管我们仍在使用fibonacci_gen生成斐波那契数列作为一个生成器,但我们随后将所有能被 3 整除的值保存到一个数组中,然后只取该数组的长度,然后丢弃数据。在这个过程中,我们无缘无故地消耗了 86 MB 的数据。[²] 事实上,如果我们对足够长的斐波那契数列执行此操作,由于内存问题,上述代码甚至无法运行,即使计算本身非常简单!

记住,我们可以使用形式为[<value> for <item> in <sequence> if <condition>]的语句创建一个列表推导。这将创建一个所有<value>项的列表。或者,我们可以使用类似的语法来创建一个生成器的<value>项,而不是一个带有(<value> for <item> in <sequence> if <condition>)的列表。

利用列表推导和生成器推导之间的这种细微差别,我们可以优化上述的divisible_by_three代码。然而,生成器没有length属性。因此,我们将不得不聪明地处理:

divisible_by_three = sum(1 for n in fibonacci_gen(100_000) if n % 3 == 0)

这里,我们有一个发生器,每当遇到能被 3 整除的数字时就发出1的值,否则不发出任何值。通过对这个发生器中的所有元素求和,我们本质上与列表推导版本做的事情相同,并且不消耗任何显著的内存。

注意

Python 中许多内置函数都是它们自己的生成器(尽管有时是一种特殊类型的生成器)。例如,range返回一个值的生成器,而不是指定范围内实际数字的列表。类似地,mapzipfilterreversedenumerate都根据需要执行计算,并不存储完整的结果。这意味着操作zip(range(100_000), range(100_000))将始终在内存中只有两个数字,以便返回其对应的值,而不是预先计算整个范围的结果。

这段代码的两个版本在较小的序列长度上的性能几乎相当,但是生成器版本的内存影响远远小于列表推导的影响。此外,我们将列表版本转换为生成器,因为列表中每个元素的重要性都在于其当前值——无论数字是否能被 3 整除,都没有关系;它的位置在数字列表中或前/后值是什么并不重要。更复杂的函数也可以转换为生成器,但是取决于它们对状态的依赖程度,这可能变得难以做到。

无限系列的迭代器

如果我们不计算已知数量的斐波那契数,而是尝试计算所有的呢?

def fibonacci():
    i, j = 0, 1
    while True:
        yield j
        i, j = j, i + j

在这段代码中,我们正在做一些以前的fibonacci_list代码无法做到的事情:我们正在将无限系列的数字封装到一个函数中。这允许我们从此流中取出尽可能多的值,并在我们的代码认为已经足够时终止。

生成器没有被充分利用的一个原因是其中很多逻辑可以封装在您的逻辑代码中。生成器实际上是一种组织代码并拥有更智能的循环的方式。例如,我们可以用多种方式回答问题“5000 以下有多少个斐波那契数是奇数?”:

def fibonacci_naive():
    i, j = 0, 1
    count = 0
    while j <= 5000:
        if j % 2:
            count += 1
        i, j = j, i + j
    return count

def fibonacci_transform():
    count = 0
    for f in fibonacci():
        if f > 5000:
            break
        if f % 2:
            count += 1
    return count

from itertools import takewhile
def fibonacci_succinct():
    first_5000 = takewhile(lambda x: x <= 5000,
                           fibonacci())
    return sum(1 for x in first_5000
               if x % 2)

所有这些方法在运行时特性上都相似(由内存占用和运行时性能测量),但是fibonacci_transform函数受益于几个方面。首先,它比fibonacci_succinct更冗长,这意味着另一个开发者可以更容易地进行调试和理解。后者主要是为了下一节,我们将在其中涵盖一些使用itertools的常见工作流程——虽然该模块可以大大简化许多迭代器的简单操作,但它也可能迅速使 Python 代码变得不太 Pythonic。相反,fibonacci_naive一次做多件事情,这隐藏了它实际正在执行的计算!虽然在生成器函数中很明显我们正在迭代斐波那契数,但我们并没有因实际计算而过于繁重。最后,fibonacci_transform更具一般性。该函数可以重命名为num_odd_under_5000,并通过参数接受生成器,因此可以处理任何系列。

fibonacci_transformfibonacci_succinct 函数的另一个好处是它们支持计算中有两个阶段的概念:生成数据和转换数据。这些函数明显地对数据执行转换,而 fibonacci 函数则生成数据。这种划分增加了额外的清晰度和功能:我们可以将一个转换函数移动到新的数据集上,或者在现有数据上执行多次转换。在创建复杂程序时,这种范式一直很重要;然而,生成器通过使生成器负责创建数据,普通函数负责对生成的数据进行操作,从而清晰地促进了这一点。

惰性生成器评估

正如前面提到的,我们通过生成器获得记忆优势的方式是仅处理当前感兴趣的值。在使用生成器进行计算时,我们始终只有当前值,并且不能引用序列中的任何其他项(按这种方式执行的算法通常称为单通道在线)。这有时会使得生成器更难使用,但许多模块和函数可以帮助。

主要感兴趣的库是标准库中的itertools。它提供许多其他有用的函数,包括这些:

islice

允许切片潜在无限生成器

chain

将多个生成器链接在一起

takewhile

添加一个将结束生成器的条件

cycle

通过不断重复使有限生成器变为无限

让我们举一个使用生成器分析大型数据集的例子。假设我们有一个分析例程,对过去 20 年的时间数据进行分析,每秒一条数据,总共有 631,152,000 个数据点!数据存储在文件中,每行一秒,我们无法将整个数据集加载到内存中。因此,如果我们想进行一些简单的异常检测,我们必须使用生成器来节省内存!

问题将是:给定一个形式为“时间戳,值”的数据文件,找出值与正态分布不同的日期。我们首先编写代码,逐行读取文件,并将每行的值输出为 Python 对象。我们还将创建一个read_fake_data生成器,生成我们可以用来测试算法的假数据。对于这个函数,我们仍然接受filename参数,以保持与read_data相同的函数签名;但是,我们将简单地忽略它。这两个函数,如示例 5-2 所示,确实是惰性评估的——我们只有在调用next()函数时才会读取文件中的下一行,或生成新的假数据。

示例 5-2. 惰性读取数据
from random import normalvariate, randint
from itertools import count
from datetime import datetime

def read_data(filename):
    with open(filename) as fd:
        for line in fd:
            data = line.strip().split(',')
            timestamp, value = map(int, data)
            yield datetime.fromtimestamp(timestamp), value

def read_fake_data(filename):
    for timestamp in count():
        #  We insert an anomalous data point approximately once a week
        if randint(0, 7 * 60 * 60 * 24 - 1) == 1:
            value = normalvariate(0, 1)
        else:
            value = 100
        yield datetime.fromtimestamp(timestamp), value

现在,我们想创建一个函数,输出出现在同一天的数据组。为此,我们可以使用itertools中的groupby函数(示例 5-3)。该函数通过接受一个项目序列和一个用于分组这些项目的键来工作。输出是一个生成器,产生元组,其项目是组的键和组中项目的生成器。作为我们的键函数,我们将输出数据记录的日历日期。这个“键”函数可以是任何东西——我们可以按小时、按年或按实际值中的某个属性对数据进行分组。唯一的限制是只有顺序数据才会形成组。因此,如果我们有输入A A A A B B A A,并且groupby按字母分组,我们将得到三组:(A, [A, A, A, A])(B, [B, B])(A, [A, A])

示例 5-3. 数据分组
from itertools import groupby

def groupby_day(iterable):
    key = lambda row: row[0].day
    for day, data_group in groupby(iterable, key):
        yield list(data_group)

现在进行实际的异常检测。我们在示例 5-4 中通过创建一个函数来完成这个任务,该函数在给定一个数据组时返回其是否符合正态分布(使用scipy.stats.normaltest)。我们可以使用itertools.filterfalse来仅过滤掉完整数据集中不通过测试的输入。这些输入被认为是异常的。

注意

在示例 5-3 中,我们将data_group转换为列表,即使它是以迭代器形式提供给我们的。这是因为normaltest函数需要一个类似数组的对象。然而,我们可以编写自己的“一次通过”normaltest函数,它可以在数据的单个视图上操作。通过使用Welford 的在线平均算法计算数字的偏度和峰度,我们可以轻松实现这一点。这将通过始终仅在内存中存储数据集的单个值而不是整天的方式来节省更多内存。然而,性能时间回归和开发时间应该考虑进去:将一天的数据存储在内存中是否足以解决这个问题,或者是否需要进一步优化?

示例 5-4. 基于生成器的异常检测
from scipy.stats import normaltest
from itertools import filterfalse

def is_normal(data, threshold=1e-3):
    _, values = zip(*data)
    k2, p_value = normaltest(values)
    if p_value < threshold:
        return False
    return True

def filter_anomalous_groups(data):
    yield from filterfalse(is_normal, data)

最后,我们可以将生成器链结合起来以获取具有异常数据的日期(示例 5-5)。

示例 5-5. 连接我们的生成器
from itertools import islice

def filter_anomalous_data(data):
    data_group = groupby_day(data)
    yield from filter_anomalous_groups(data_group)

data = read_data(filename)
anomaly_generator = filter_anomalous_data(data)
first_five_anomalies = islice(anomaly_generator, 5)

for data_anomaly in first_five_anomalies:
    start_date = data_anomaly[0][0]
    end_date = data_anomaly[-1][0]
    print(f"Anomaly from {start_date} - {end_date}")
# Output of above code using "read_fake_data"
Anomaly from 1970-01-10 00:00:00 - 1970-01-10 23:59:59
Anomaly from 1970-01-17 00:00:00 - 1970-01-17 23:59:59
Anomaly from 1970-01-18 00:00:00 - 1970-01-18 23:59:59
Anomaly from 1970-01-23 00:00:00 - 1970-01-23 23:59:59
Anomaly from 1970-01-29 00:00:00 - 1970-01-29 23:59:59

这种方法使我们能够获取异常日的列表,而无需加载整个数据集。只读取足够的数据以生成前五个异常。此外,anomaly_generator对象可以进一步阅读以继续检索异常数据。这被称为惰性评估——只有明确请求的计算才会执行,这可以显著减少总体运行时间,如果存在早期终止条件的话。

另一个有关以这种方式组织分析的好处是,它使我们能够更轻松地进行更加广泛的计算,而无需重做大部分代码。例如,如果我们想要一个移动窗口为一天而不是按天分块,我们可以在 Example 5-3 中用类似以下方式替换 groupby_day

from datetime import datetime

def groupby_window(data, window_size=3600):
    window = tuple(islice(data, window_size))
    for item in data:
        yield window
        window = window[1:] + (item,)

在此版本中,我们还可以非常明确地看到这种方法及其前一种方法的内存保证——它只会将窗口大小的数据作为状态存储(在两种情况下均为一天或 3,600 个数据点)。请注意,for 循环检索的第一项是第 window_size 个值。这是因为 data 是一个迭代器,在前一行我们消耗了前 window_size 个值。

最后说明:在 groupby_window 函数中,我们不断地创建新的元组,将它们填充数据,并将它们 yield 给调用者。我们可以通过使用 collections 模块中的 deque 对象来大大优化此过程。该对象提供了 O(1) 的向右(或末尾)附加和删除操作(而普通列表对于向末尾附加或删除操作是 O(1),对于向列表开头相同操作是 O(n))。使用 deque 对象,我们可以将新数据追加到列表的右侧(或末尾),并使用 deque.popleft() 从左侧(或开头)删除数据,而无需分配更多空间或执行长达 O(n) 的操作。然而,我们必须就地使用 deque 对象并销毁先前的滚动窗口视图(参见 “内存分配和就地操作” 了解有关就地操作的更多信息)。唯一的解决方法是在将数据复制到元组之前,将其销毁并返回给调用者,这将消除任何更改的好处!

总结

通过使用迭代器来制定我们的异常查找算法,我们可以处理比内存容量更大得多的数据。更重要的是,我们可以比使用列表时更快地执行操作,因为我们避免了所有昂贵的 append 操作。

由于迭代器在 Python 中是一种原始类型,这应该始终是尝试减少应用程序内存占用的方法。其好处在于结果是惰性评估的,因此您只处理所需的数据,并且节省内存,因为我们不存储以前的结果,除非显式需要。在 Chapter 11 中,我们将讨论其他可以用于更具体问题的方法,并介绍一些在 RAM 成为问题时看待问题的新方法。

使用迭代器解决问题的另一个好处是,它能够使你的代码准备好在多个 CPU 或多台计算机上使用,正如我们将在第九章和第十章中看到的那样。正如我们在“无限级数的迭代器”中讨论的那样,在使用迭代器时,你必须始终考虑算法运行所必需的各种状态。一旦你弄清楚了如何打包算法运行所需的状态,它在哪里运行就不重要了。我们可以在multiprocessingipython模块中看到这种范式,它们都使用类似于 map 的函数来启动并行任务。

¹ 一般来说,在线单遍 算法非常适合使用生成器。然而,在切换时,你必须确保你的算法仍然可以在没有能够多次引用数据的情况下运行。

² 通过 %memit len([n for n in fibonacci_gen(100_000) if n % 3 == 0]) 计算。

第六章:矩阵与向量计算

无论您在计算机上尝试解决什么问题,都会在某个时候遇到向量计算。向量计算是计算机如何工作以及如何在硅级别上加快程序运行时速度的核心内容——计算机唯一能做的就是对数字进行操作,而同时进行多个这样的计算会加快程序的运行。

在本章中,我们试图通过专注于一个相对简单的数学问题——解扩散方程来揭示这个问题的一些复杂性,并理解发生在 CPU 级别的情况。通过理解不同的 Python 代码如何影响 CPU 以及如何有效地探测这些内容,我们可以学会如何理解其他问题。

我们将首先介绍问题并使用纯 Python 提出一个快速解决方案。在识别出一些内存问题并尝试使用纯 Python 修复它们之后,我们将介绍numpy并识别它如何以及为什么加快我们的代码。然后,我们将开始进行一些算法变更,并专门优化我们的代码以解决手头的问题。通过去除我们正在使用的库的一些通用性,我们将再次能够获得更多的速度优势。接下来,我们引入一些额外的模块,将有助于在实地中促进这种过程,并探讨在优化之前进行性能分析的警示故事。

最后,我们将介绍 Pandas 库,该库基于numpy,通过将同类数据的列存储在异构类型的表中来构建。Pandas 已经超越了使用纯numpy类型,并且现在可以将其自己的缺失数据感知类型与numpy数据类型混合使用。虽然 Pandas 在科学开发人员和数据科学家中非常流行,但有关如何使其运行更快的误解很多;我们解决了其中一些问题,并为编写高性能和可支持的分析代码提供了建议。

问题介绍

为了探索本章中的矩阵和向量计算,我们将反复使用液体扩散的示例。扩散是将流体移动并尝试使其均匀混合的机制之一。

注意

本节旨在深入理解本章将要解决的方程式。您不一定需要严格理解本节即可继续学习本章的其余内容。如果您希望跳过本节,请至少查看示例 6-1 和 6-2 中的算法以了解我们将要优化的代码。

另一方面,如果您阅读了本节并希望获得更多解释,请阅读 William Press 等人编著的《Numerical Recipes》第 3 版的第十七章(剑桥大学出版社)。

在本节中,我们将探讨扩散方程背后的数学原理。这可能看起来很复杂,但不要担心!我们将快速简化它,以使其更易理解。此外,需要注意的是,虽然在阅读本章时对我们要解决的最终方程有基本的理解会有所帮助,但这并非完全必要;后续章节主要将重点放在代码的各种形式化上,而不是方程。然而,理解方程将有助于您对优化代码的方法产生直观的认识。这在一般情况下是正确的——理解代码背后的动机和算法的复杂性将为您提供有关优化方法的更深入的洞察。

扩散的一个简单例子是水中的染料:如果你把几滴染料放到室温水中,染料将慢慢扩散直到完全与水混合。由于我们没有搅拌水,也没有足够的温度来产生对流电流,扩散将是混合两种液体的主要过程。在数值上解这些方程时,我们选择初始条件的外观,并能够将初始条件向前演化到以后的时间以查看其外观(见图 6-2)。

尽管如此,对于我们的目的来说,关于扩散最重要的事情是它的公式化。作为一维(1D)偏微分方程陈述,扩散方程的形式如下:

t u ( x , t ) = D · 2 x 2 u ( x , t )

在这个公式化中,u是表示我们正在扩散的量的向量。例如,我们可以有一个向量,其中在只有水的地方值为0,只有染料的地方值为1(以及在混合处的值)。一般来说,这将是一个表示实际区域或流体体积的二维或三维矩阵。这样,我们可以将u作为一个表示玻璃杯中流体的三维矩阵,并且不仅仅沿着x方向进行二阶导数计算,而是在所有轴上进行。此外,D是表示我们正在模拟的流体的属性的物理值。较大的D值表示一种可以很容易扩散的流体。为简单起见,我们将在我们的代码中将D = 1,但仍然将其包含在计算中。

注意

扩散方程也称为热传导方程。在这种情况下,u代表了一个区域的温度,而D描述了材料导热的好坏程度。解方程告诉我们热量是如何传递的。因此,我们可能不是在解释几滴染料在水中扩散的过程,而是在解释 CPU 产生的热量如何传递到散热器中。

我们要做的是取扩散方程,该方程在空间和时间上是连续的,并使用离散的体积和离散的时间进行近似。我们将使用欧拉方法来实现这一点。欧拉方法简单地取导数,并将其写成一个差值,如下所示:

t u ( x , t ) u(x,t+dt)u(x,t) dt

其中dt现在是一个固定的数字。这个固定的数字代表我们希望解决这个方程的时间步长,或者说时间上的分辨率。可以将其视为我们试图制作的电影的帧率。随着帧率的提高(或dt的减小),我们能够得到更清晰的图片。事实上,随着dt接近零,欧拉逼近变得精确(然而,请注意,这种精确性只能在理论上实现,因为计算机上只有有限的精度,数值误差将很快主导任何结果)。因此,我们可以重新编写这个方程,以便找出u(x, t + dt)是多少,给定u(x,t)。这对我们意味着我们可以从某个初始状态开始(u(x,0),表示我们向其中加入一滴染料的水杯)并通过我们已经概述的机制来“演化”该初始状态,并查看在未来时刻(u(x,dt))它将会是什么样子。这种问题类型称为初值问题柯西问题。对x的导数使用有限差分逼近进行类似的技巧,我们得到最终方程:

u ( x , t + d t ) = u ( x , t ) + d t D u(x+dx,t)+u(xdx,t)2·u(x,t) dx 2

在这里,类似于dt表示帧率,dx表示图像的分辨率——dx越小,矩阵中每个单元格表示的区域就越小。为简单起见,我们将设置D = 1dx = 1。这两个值在进行正确的物理模拟时变得非常重要;然而,由于我们解决扩散方程是为了说明的目的,它们对我们来说并不重要。

使用这个方程,我们可以解决几乎任何扩散问题。然而,对于这个方程有一些考虑。首先,我们之前说过,在u中的空间索引(即x参数)将被表示为矩阵的索引。当我们尝试找到x - dx处的值时,当x位于矩阵的开头时会发生什么?这个问题称为边界条件。您可以有固定的边界条件,即“超出我的矩阵范围的任何值都将被设置为0”(或任何其他值)。或者,您可以有周期性的边界条件,即值会循环。也就是说,如果矩阵的一个维度长度为N,那么该维度上索引为-1的值与N - 1处的值相同,索引为N处的值与索引为0处的值相同。(换句话说,如果您试图访问索引为i处的值,您将得到索引为(i%N)处的值。)

另一个考虑因素是我们将如何存储u的多个时间分量。我们可以为每个计算时间点都有一个矩阵。至少,看起来我们需要两个矩阵:一个用于流体的当前状态,另一个用于流体的下一个状态。正如我们将看到的,对于这个特定问题,性能考虑非常重要。

那么在实践中解决这个问题是什么样子呢?示例 6-1 包含了一些伪代码,说明我们如何使用我们的方程来解决问题。

示例 6-1. 一维扩散的伪代码
# Create the initial conditions
u = vector of length N
for i in range(N):
    u = 0 if there is water, 1 if there is dye

# Evolve the initial conditions
D = 1
t = 0
dt = 0.0001
while True:
    print(f"Current time is: {t}")
    unew = vector of size N

    # Update step for every cell
    for i in range(N):
        unew[i] = u[i] + D * dt * (u[(i+1)%N] + u[(i-1)%N] - 2 * u[i])
    # Move the updated solution into u
    u = unew

    visualize(u)

此代码将以水中染料的初始条件作为输入,并告诉我们在未来每 0.0001 秒间隔下系统的样子。结果可以在图 6-1 中看到,我们将我们非常浓缩的染料滴(由顶帽函数表示)演化到未来。我们可以看到,远处的染料变得混合均匀,到达了染料的各处浓度相似的状态。

一维扩散示例

图 6-1. 一维扩散示例

对于本章的目的,我们将解决前述方程的二维版本。这意味着我们将不再在一个向量上操作(或者换句话说,一个带有一个索引的矩阵),而是在一个二维矩阵上操作。方程(以及随后的代码)的唯一变化是我们现在还必须在y方向上进行第二次导数。这简单地意味着我们之前处理的原始方程变成了以下形式:

t u ( x , y , t ) = D · 2 x 2 u ( x , y , t ) + 2 y 2 u ( x , y , t )

这个二维数值扩散方程可以通过示例 6-2 中的伪代码转化为实际操作,使用我们之前使用的相同方法。

示例 6-2. 计算二维扩散的算法
for i in range(N):
    for j in range(M):
        unew[i][j] = u[i][j] + dt * (
            (u[(i + 1) % N][j] + u[(i - 1) % N][j] - 2 * u[i][j]) + # d² u / dx²
            (u[i][(j + 1) % M] + u[i][(j - 1) % M] - 2 * u[i][j])   # d² u / dy²
        )

现在我们可以将所有这些内容结合起来,编写完整的 Python 二维扩散代码,这将作为本章其余部分基准的基础。尽管代码看起来更复杂,但结果与一维扩散的结果相似(如图 6-2 中所示)。

如果您想在本节中的相关主题上进行额外阅读,请查看扩散方程的维基百科页面和《复杂系统的数值方法》的第七章S. V. Gurevich

两组初始条件下的二维扩散示例

图 6-2. 两组初始条件下的二维扩散示例

Python 列表足够好吗?

让我们从示例 6-1 中拿出我们的伪代码,并将其形式化,以便更好地分析其运行时性能。第一步是编写接受矩阵并返回其演化状态的演化函数。这在示例 6-3 中展示出来。

示例 6-3. 纯 Python 2D 扩散
grid_shape = (640, 640)

def evolve(grid, dt, D=1.0):
    xmax, ymax = grid_shape
    new_grid = [[0.0] * ymax for x in range(xmax)]
    for i in range(xmax):
        for j in range(ymax):
            grid_xx = (
                grid[(i + 1) % xmax][j] + grid[(i - 1) % xmax][j] - 2.0 * grid[i][j]
            )
            grid_yy = (
                grid[i][(j + 1) % ymax] + grid[i][(j - 1) % ymax] - 2.0 * grid[i][j]
            )
            new_grid[i][j] = grid[i][j] + D * (grid_xx + grid_yy) * dt
    return new_grid
注意

与预先分配 new_grid 列表不同,我们可以通过在 for 循环中使用 append 逐步构建它。虽然这比我们编写的方法明显更快,但我们得出的结论仍然适用。我们选择这种方法是因为它更具说明性。

全局变量 grid_shape 表示我们将模拟的区域大小;正如 “问题介绍” 中所述,我们使用周期边界条件(这就是为什么在索引中使用模运算)。要使用此代码,我们必须初始化一个网格并对其调用 evolve。示例 6-4 中的代码是一个非常通用的初始化过程,在本章中将被多次重复使用(由于它只需运行一次,所以不会分析其性能特征,而与需要重复调用的 evolve 函数相对)。

示例 6-4. 纯 Python 2D 扩散初始化
def run_experiment(num_iterations):
    # Setting up initial conditions ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
    xmax, ymax = grid_shape
    grid = [[0.0] * ymax for x in range(xmax)]

    # These initial conditions are simulating a drop of dye in the middle of our
    # simulated region
    block_low = int(grid_shape[0] * 0.4)
    block_high = int(grid_shape[0] * 0.5)
    for i in range(block_low, block_high):
        for j in range(block_low, block_high):
            grid[i][j] = 0.005

    # Evolve the initial conditions
    start = time.time()
    for i in range(num_iterations):
        grid = evolve(grid, 0.1)
    return time.time() - start

1

此处使用的初始条件与 图 6-2 中的正方形示例相同。

dt 和网格元素的值选择足够小,使算法稳定。详见Numerical Recipes以深入了解算法的收敛特性。

分配过多的问题

通过在纯 Python 进化函数上使用 line_profiler,我们可以开始分析可能导致运行时间较慢的原因。查看示例 6-5 中的分析输出,我们发现函数中大部分时间用于导数计算和网格更新。¹ 这正是我们想要的,因为这是一个纯 CPU 限制的问题 —— 任何未用于解决此问题的时间都是显而易见的优化对象。

示例 6-5. 纯 Python 2D 扩散分析
$ kernprof -lv diffusion_python.py
Wrote profile results to diffusion_python.py.lprof
Timer unit: 1e-06 s

Total time: 787.161 s
File: diffusion_python.py
Function: evolve at line 12

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    12                                           @profile
    13                                           def evolve(grid, dt, D=1.0):
    14       500        843.0      1.7      0.0      xmax, ymax = grid_shape  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
    15       500   24764794.0  49529.6      3.1      new_grid = [0.0 for x in ...
    16    320500     208683.0      0.7      0.0      for i in range(xmax):  ![2
    17 205120000  128928913.0      0.6     16.4          for j in range(ymax):
    18 204800000  222422192.0      1.1     28.3              grid_xx = ...
    19 204800000  228660607.0      1.1     29.0              grid_yy = ...
    20 204800000  182174957.0      0.9     23.1              new_grid[i][j] = ...
    21       500        331.0      0.7      0.0      return new_grid  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png)

1

每次击中此语句所需的时间很长,因为必须从本地命名空间检索 grid_shape(有关更多信息,请参见 “字典和命名空间”)。

2

此行与 320,500 次击中相关联,因为我们操作的网格具有 xmax = 640,并且我们运行该函数 500 次。计算公式为 (640 + 1) * 500,其中额外的一次评估来自循环的终止。

3

此行与 500 次击中相关联,这告诉我们该函数已在 500 次运行中进行了分析。

看到第 15 行的 Per Hit% Time 字段的巨大差异真是有趣,这是我们为新网格分配内存的地方。这种差异的原因在于,虽然这行代码本身运行相当慢(Per Hit 字段显示每次运行需要 0.0495 秒,比循环内的所有其他行都慢),但它并不像其他循环内的行那样频繁调用。如果我们减少网格的大小并增加迭代次数(即减少循环的迭代次数,但增加调用函数的次数),我们会看到这行代码的 % Time 增加,并迅速主导运行时间。

这是一种浪费,因为 new_grid 的属性不会改变 —— 无论我们发送什么值到 evolvenew_grid 列表的形状和大小以及包含的值始终是相同的。一个简单的优化方法是只分配这个列表一次,然后简单地重用它。这样,我们只需要运行这段代码一次,不管网格的大小或迭代次数如何。这种优化类似于将重复的代码移出快速循环外:

from math import sin

def loop_slow(num_iterations):
    """
    >>> %timeit loop_slow(int(1e4))
    1.68 ms ± 61.3 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
    """
    result = 0
    for i in range(num_iterations):
        result += i * sin(num_iterations)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
    return result

def loop_fast(num_iterations):
    """
    >>> %timeit loop_fast(int(1e4))
    551 µs ± 23.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
    """
    result = 0
    factor = sin(num_iterations)
    for i in range(num_iterations):
        result += i
    return result * factor

1

sin(num_iterations) 的值在整个循环过程中都没有改变,因此每次重新计算它都没有意义。

我们可以对我们的扩散代码做类似的转换,如 示例 6-6 所示。在这种情况下,我们希望在 示例 6-4 中实例化 new_grid 并将其发送到我们的 evolve 函数中。该函数将像以前一样读取 grid 列表并写入 new_grid 列表。然后我们可以简单地交换 new_gridgrid,然后再继续。

示例 6-6. 减少内存分配后的纯 Python 2D 扩散
def evolve(grid, dt, out, D=1.0):
    xmax, ymax = grid_shape
    for i in range(xmax):
        for j in range(ymax):
            grid_xx = (
                grid[(i + 1) % xmax][j] + grid[(i - 1) % xmax][j] - 2.0 * grid[i][j]
            )
            grid_yy = (
                grid[i][(j + 1) % ymax] + grid[i][(j - 1) % ymax] - 2.0 * grid[i][j]
            )
            out[i][j] = grid[i][j] + D * (grid_xx + grid_yy) * dt

def run_experiment(num_iterations):
    # Setting up initial conditions
    xmax, ymax = grid_shape
    next_grid = [[0.0] * ymax for x in range(xmax)]
    grid = [[0.0] * ymax for x in range(xmax)]

    block_low = int(grid_shape[0] * 0.4)
    block_high = int(grid_shape[0] * 0.5)
    for i in range(block_low, block_high):
        for j in range(block_low, block_high):
            grid[i][j] = 0.005

    start = time.time()
    for i in range(num_iterations):
        # evolve modifies grid and next_grid in-place
        evolve(grid, 0.1, next_grid)
        grid, next_grid = next_grid, grid
    return time.time() - start

我们可以从修改后的代码行剖析中看到,在 示例 6-7 的版本中,这个小改动使我们的速度提高了 31.25%。这使我们得出一个类似于在我们讨论列表的 append 操作时得出的结论(参见 “列表作为动态数组”):内存分配并不便宜。每次我们请求内存来存储变量或列表时,Python 必须花费一些时间与操作系统通信,以便分配新的空间,然后我们必须迭代新分配的空间来初始化它的一些值。

在可能的情况下,重用已分配的空间将提高性能。但在实现这些更改时要小心。虽然速度提升可能很大,但您应该始终进行剖析,以确保实现了您想要的结果,并且没有简单地污染了您的代码库。

示例 6-7. 减少分配后 Python 扩散的行剖析
$ `kernprof` `-``lv` `diffusion_python_memory``.``py`
Wrote profile results to diffusion_python_memory.py.lprof
Timer unit: 1e-06 s

Total time: 541.138 s
File: diffusion_python_memory.py
Function: evolve at line 12

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    12                                           @profile
    13                                           def evolve(grid, dt, out, D=1.0):
    14       500        503.0      1.0      0.0      xmax, ymax = grid_shape
    15    320500     131498.0      0.4      0.0      for i in range(xmax):
    16 205120000   81105090.0      0.4     15.0          for j in range(ymax):
    17 204800000  166271837.0      0.8     30.7              grid_xx = ...
    18 204800000  169216352.0      0.8     31.3              grid_yy = ...
    19 204800000  124412452.0      0.6     23.0              out[i][j] = ...

内存碎片化

我们在示例 6-6 中编写的 Python 代码仍然存在一个问题,这是使用 Python 进行这些向量化操作的核心问题:Python 不原生支持向量化。这有两个原因:Python 列表存储指向实际数据的指针,并且 Python 字节码不针对向量化进行优化,因此for循环无法预测何时使用向量化会有益处。

Python 列表存储指针的事实意味着,与其实际持有我们关心的数据,列表存储了可以找到该数据的位置。对于大多数用途来说,这是好的,因为它允许我们在列表中存储任何类型的数据。然而,当涉及到向量和矩阵操作时,这是性能下降的一个源头。

这种性能下降是因为每次我们想要从grid矩阵中获取一个元素时,我们必须进行多次查找。例如,执行grid[5][2]需要我们首先在列表grid上进行索引5的查找。这将返回存储在该位置的数据的指针。然后我们需要在这个返回的对象上再次进行列表查找,获取索引2处的元素。一旦我们有了这个引用,我们就知道了存储实际数据的位置。

提示

与其创建一个列表的网格(grid[x][y]),你如何创建一个由元组索引的网格(grid[(x, y)])?这会如何影响代码的性能?

对于这种查找的开销并不大,大多数情况下可以忽略不计。然而,如果我们想要的数据位于内存中的一个连续块中,我们可以一次性移动所有数据,而不是每个元素需要两次操作。这是数据碎片化的一个主要问题之一:当数据碎片化时,你必须逐个移动每个片段,而不是移动整个块。这意味着你会引起更多的内存传输开销,并迫使 CPU 等待数据传输完成。我们将通过perf看到,这在查看cache-misses时是多么重要。

当 CPU 需要时将正确的数据传递给 CPU 这个问题与冯·诺伊曼瓶颈有关。这指的是现代计算机使用的分层存储器架构导致的内存和 CPU 之间存在的有限带宽。如果我们能够无限快地移动数据,我们就不需要任何缓存,因为 CPU 可以立即检索所需的任何数据。这将是一个不存在瓶颈的状态。

由于我们无法无限快地移动数据,我们必须预取来自 RAM 的数据,并将其存储在更小但更快的 CPU 缓存中,这样,希望当 CPU 需要某个数据时,它会位于可以快速读取的位置。虽然这是一种极为理想化的架构视角,我们仍然可以看到其中的一些问题 —— 我们如何知道未来会需要哪些数据?CPU 通过称为分支预测流水线的机制来有效地处理这些问题,这些机制尝试预测下一条指令,并在当前指令的同时将相关的内存部分加载到缓存中。然而,减少瓶颈影响的最佳方法是聪明地分配内存和计算数据。

探测内存移动到 CPU 的效果可能非常困难;然而,在 Linux 中,perf 工具可以用来深入了解 CPU 处理正在运行的程序的方式。³ 例如,我们可以在纯 Python 代码的例子 6-6 上运行 perf,看看 CPU 如何高效地运行我们的代码。

结果显示在例子 6-8 中。请注意,此示例和后续 perf 示例中的输出已被截断以适应页面的边界。移除的数据包括每个测量的差异,显示了值在多次基准测试中变化的程度。这对于查看测量值在程序的实际性能特性与其他系统属性(例如,使用系统资源的其他运行中程序)之间的依赖性有用。

例子 6-8. 性能计数器用于纯 Python 2D 扩散,减少内存分配(网格大小:640 × 640,500 次迭代)
$ perf stat -e cycles,instructions,\
    cache-references,cache-misses,branches,branch-misses,task-clock,faults,\
    minor-faults,cs,migrations python diffusion_python_memory.py

 Performance counter stats for 'python diffusion_python_memory.py':

   415,864,974,126      cycles                    #    2.889 GHz
 1,210,522,769,388      instructions              #    2.91  insn per cycle
       656,345,027      cache-references          #    4.560 M/sec
       349,562,390      cache-misses              #   53.259 % of all cache refs
   251,537,944,600      branches                  # 1747.583 M/sec
     1,970,031,461      branch-misses             #    0.78% of all branches
     143934.730837      task-clock (msec)         #    1.000 CPUs utilized
            12,791      faults                    #    0.089 K/sec
            12,791      minor-faults              #    0.089 K/sec
               117      cs                        #    0.001 K/sec
                 6      migrations                #    0.000 K/sec

     143.935522122 seconds time elapsed

理解性能

让我们花一点时间理解 perf 给我们的各种性能指标及其与我们的代码的关系。task-clock 指标告诉我们任务花费了多少时钟周期。这与总运行时间不同,因为如果我们的程序运行了一秒钟,但使用了两个 CPU,那么 task-clock 将是 2000task-clock 通常以毫秒为单位)。方便的是,perf 为我们进行了计算,并告诉我们在这个指标旁边,使用了多少个 CPU(它说“XXXX CPUs utilized”)。即使在使用两个 CPU 时,这个数字也不会完全是 2,因为进程有时依赖其他子系统来执行指令(例如,当分配内存时)。

另一方面,instructions 告诉我们代码发出了多少 CPU 指令,而 cycles 则告诉我们运行所有这些指令所需的 CPU 周期数。这两个数字的差异显示了我们的代码向量化和流水线化的效果如何。通过流水线化,CPU 能够在执行当前操作的同时获取和准备下一个操作。

cs(代表“上下文切换”)和 CPU-migrations 告诉我们程序如何在等待内核操作完成(例如 I/O)、让其他应用程序运行或将执行移动到另一个 CPU 核心时被暂停。当发生 context-switch 时,程序的执行被暂停,另一个程序被允许运行。这是一个非常耗时的任务,我们希望尽可能减少这种情况的发生,但是我们不能完全控制它何时发生。内核决定何时允许程序被切换出去;然而,我们可以做些事情来防止内核切换我们的程序。一般来说,内核在程序进行 I/O 操作(如从内存、磁盘或网络读取数据)时暂停程序。正如你将在后面的章节中看到的那样,我们可以使用异步程序来确保我们的程序即使在等待 I/O 时也能继续使用 CPU,这样我们可以在不被上下文切换的情况下继续运行。此外,我们可以设置程序的 nice 值,以提高程序的优先级并阻止内核对其进行上下文切换。⁴ 类似地,CPU-migrations 发生在程序被暂停并在不同的 CPU 上恢复执行时,目的是让所有 CPU 具有相同的利用率水平。这可以看作是一个特别糟糕的上下文切换,因为不仅我们的程序暂时停止了,而且我们还丢失了在 L1 缓存中的任何数据(回想每个 CPU 都有自己的 L1 缓存)。

page-fault(或简称fault)是现代 Unix 内存分配方案的一部分。当分配内存时,内核并不做太多工作,只是给程序一个内存的引用。然而,当内存首次被使用时,操作系统会抛出一个轻微的页面错误中断,暂停正在运行的程序,并适当地分配内存。这被称为惰性分配系统。虽然这种方法优于先前的内存分配系统,但是轻微的页面错误是非常昂贵的操作,因为大部分操作都是在你运行的程序的范围之外完成的。还有一个主要页面错误,当程序请求尚未读取的设备数据(磁盘、网络等)时发生。这些操作更加昂贵:它们不仅会中断您的程序,还会涉及从存放数据的任何设备中读取。这种页面错误通常不会影响 CPU 密集型工作;然而,它将成为任何进行磁盘或网络读/写的程序的痛点。⁵

一旦我们将数据加载到内存并引用它,数据将通过各个存储层(L1/L2/L3 存储器—参见“通信层”进行讨论)传输。每当我们引用已存在于缓存中的数据时,cache-references指标就会增加。如果我们在缓存中没有这些数据并且需要从 RAM 中获取,这被称为cache-miss。如果我们读取的是最近读取过的数据(该数据仍然存在于缓存中)或者是靠近最近读取过的数据的数据(数据会以块的形式从 RAM 中发送到缓存),我们就不会遇到缓存失效。缓存失效可能是 CPU 密集型工作中减慢速度的原因之一,因为我们需要等待从 RAM 获取数据,并且会中断执行流水线的流动(稍后详细讨论)。因此,顺序遍历数组会产生许多cache-references但不会有太多的cache-misses,因为如果我们读取第i个元素,第i + 1个元素已经在缓存中。然而,如果我们随机读取数组或者内存中的数据布局不佳,每次读取都需要访问不可能已经存在于缓存中的数据。本章后面将讨论如何通过优化内存中数据的布局来减少这种影响。

branch是代码中执行流程发生变化的时间。想象一个if...then语句-根据条件的结果,我们将执行代码的一个部分或另一个部分。这本质上是代码执行中的一个分支-程序中的下一条指令可能是两种情况之一。为了优化这一点,特别是关于流水线,CPU 尝试猜测分支将采取的方向并预加载相关指令。当这种预测不正确时,我们将得到一个branch-miss。分支未命中可能会非常令人困惑,并且可能会导致许多奇怪的效果(例如,一些循环在排序列表上运行的速度会比在未排序列表上快得多,仅仅是因为分支未命中较少)。

perf可以跟踪的指标还有很多,其中许多非常特定于你运行代码的 CPU。您可以运行perf list以获取系统当前支持的指标列表。例如,在本书的上一版中,我们在一台机器上运行,该机器还支持stalled-cycles-frontendstalled-cycles-backend,它们告诉我们我们的程序等待前端或后端管道填充的周期数。这可能是由于缓存未命中、错误的分支预测或资源冲突而发生的。管道的前端负责从内存中获取下一条指令并将其解码为有效操作,而后端负责实际运行操作。这些指标可以帮助调整代码的性能,以适应特定 CPU 的优化和架构选择;但是,除非您总是在相同的芯片组上运行,否则过度担心它们可能过多。

小贴士

如果您想要更深入地了解各种性能指标在 CPU 级别上的情况,请查看 Gurpur M. Prabhu 的出色的“计算机体系结构教程。” 它处理非常低级别的问题,这将让您对在运行代码时底层发生的情况有很好的理解。

使用 perf 的输出进行决策

考虑到所有这些因素,在示例 6-8 中的性能指标告诉我们,当运行我们的代码时,CPU 必须引用 L1/L2 缓存 656,345,027 次。在这些引用中,349,562,390(或 53.3%)是在那时不在内存中的数据的请求,必须检索。此外,我们还可以看到在每个 CPU 周期中,我们平均能执行 2.91 条指令,这告诉我们通过流水线化、乱序执行和超线程(或任何其他允许您在每个时钟周期运行多个指令的 CPU 功能)获得的总速度提升。

碎片化会增加到 CPU 的内存传输次数。另外,由于在请求计算时 CPU 缓存中没有准备好多个数据片段,因此无法对计算进行向量化。如“通信层”中所解释的,计算向量化(或让 CPU 同时进行多个计算)只能在 CPU 缓存中填充所有相关数据时才能发生。由于总线只能移动连续的内存块,这只有在网格数据在 RAM 中顺序存储时才可能。由于列表存储数据的指针而不是实际数据,网格中的实际值分散在内存中,无法一次性复制。

我们可以通过使用array模块而不是列表来缓解这个问题。这些对象在内存中顺序存储数据,因此array的切片实际上表示内存中的连续范围。然而,这并不能完全解决问题——现在我们的数据在内存中顺序存储了,但 Python 仍然不知道如何对我们的循环进行向量化。我们希望的是,任何对我们数组逐个元素进行算术运算的循环都能处理数据块,但正如前面提到的,Python 中没有这样的字节码优化(部分原因是语言极其动态的特性)。

注意

为什么我们希望存储在内存中的数据自动给我们向量化?如果我们看一下 CPU 正在运行的原始机器代码,向量化操作(如两个数组相乘)使用 CPU 的不同部分和不同指令。要使 Python 使用这些特殊指令,我们必须使用一个专门用于使用它们的模块。我们很快将看到numpy如何让我们访问这些专门的指令。

此外,由于实现细节的原因,在创建必须迭代的数据列表时,使用array类型实际上比简单创建list。这是因为array对象存储其所存储数字的非常低级别的表示形式,这在返回给用户之前必须转换为 Python 兼容版本。每次索引array类型都会产生额外的开销。这个实现决策使得array对象在数学上不太适用,而更适合在内存中更有效地存储固定类型数据。

进入 numpy

为了解决我们使用perf找到的碎片化问题,我们必须找到一个能够高效矢量化操作的软件包。幸运的是,numpy具有我们需要的所有功能——它将数据存储在内存的连续块中,并支持对其数据进行矢量化操作。因此,我们在numpy数组上进行的任何算术运算都是按块进行的,而无需我们显式地循环遍历每个元素。[⁷]

from array import array
import numpy

def norm_square_list(vector):
    """
    >>> vector = list(range(1_000_000))
    >>> %timeit norm_square_list(vector)
    85.5 ms ± 1.65 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
    """
    norm = 0
    for v in vector:
        norm += v * v
    return norm

def norm_square_list_comprehension(vector):
    """
    >>> vector = list(range(1_000_000))
    >>> %timeit norm_square_list_comprehension(vector)
    80.3 ms ± 1.37 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
    """
    return sum([v * v for v in vector])

def norm_square_array(vector):
    """
    >>> vector_array = array('l', range(1_000_000))
    >>> %timeit norm_square_array(vector_array)
    101 ms ± 4.69 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
    """
    norm = 0
    for v in vector:
        norm += v * v
    return norm

def norm_square_numpy(vector):
    """
    >>> vector_np = numpy.arange(1_000_000)
    >>> %timeit norm_square_numpy(vector_np)
    3.22 ms ± 136 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
    """
    return numpy.sum(vector * vector)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)

def norm_square_numpy_dot(vector):
    """
    >>> vector_np = numpy.arange(1_000_000)
    >>> %timeit norm_square_numpy_dot(vector_np)
    960 µs ± 41.1 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
    """
    return numpy.dot(vector, vector)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)

1

这创建了对vector的两个隐含循环,一个用于执行乘法,另一个用于执行求和。这些循环类似于norm_square_list_comprehension中的循环,但是它们使用了numpy优化的数值代码来执行。

2

这是使用矢量化的numpy.dot操作来执行向量范数的首选方法。提供了效率较低的norm_square_numpy代码供参考。

更简单的numpy代码比norm_square_list快 89 倍,比“优化”的 Python 列表推导式快 83.65 倍。纯 Python 循环方法和列表推导式方法之间的速度差异显示了在 Python 代码中隐含更多计算胜过显式执行计算的好处。通过使用 Python 已经构建好的机制进行计算,我们获得了 Python 基于的本机 C 代码的速度。这也部分解释了为什么我们在numpy代码中有如此 drasti 的速度提升:我们没有使用通用列表结构,而是使用了一个精心调整和专门构建的用于处理数字数组的对象。

除了更轻量级和专业化的机器之外,numpy对象还为我们提供了内存局部性和矢量化操作,在处理数值计算时非常重要。CPU 的速度非常快,而且通常只需更快地将数据提供给它即可快速优化代码。使用我们之前查看过的perf工具运行每个函数表明,array和纯 Python 函数需要约 10¹²条指令,而numpy版本需要约 10⁹条指令。此外,array和纯 Python 版本的缓存缺失率约为 53%,而numpy的缺失率约为 20%。

在我们的norm_square_numpy代码中,当执行vector * vector时,numpy会隐含地处理一个循环。这个隐含的循环与我们在其他示例中显式编写的循环相同:遍历vector中的所有项,将每个项乘以自身。但是,由于我们告诉numpy来做这件事,而不是在 Python 代码中显式写出来,numpy可以利用 CPU 启用的所有向量化优化。此外,numpy数组以低级数值类型在内存中顺序表示,这使它们具有与array模块中的array对象相同的空间要求。

作为额外的奖励,我们可以将问题重新表述为一个点积,numpy支持这种操作。这给了我们一个单一的操作来计算我们想要的值,而不是首先计算两个向量的乘积,然后对它们求和。正如您在图 6-3 中看到的那样,这个操作norm_numpy_dot在性能上远远优于其他所有操作——这要归功于函数的专门化,以及因为我们不需要像在norm_numpy中那样存储vector * vector的中间值。

不同长度向量的各种范数平方例程的运行时间

图 6-3. 不同长度向量的各种范数平方例程的运行时间

将 numpy 应用于扩散问题

根据我们对numpy的了解,我们可以轻松地使我们的纯 Python 代码向量化。我们必须引入的唯一新功能是numpyroll函数。这个函数做的事情与我们的模数索引技巧相同,但它是针对整个numpy数组的。从本质上讲,它向量化了这种重新索引:

>>> import numpy as np
>>> np.roll([1,2,3,4], 1)
array([4, 1, 2, 3])

>>> np.roll([[1,2,3],[4,5,6]], 1, axis=1)
array([[3, 1, 2],
 [6, 4, 5]])

roll函数创建一个新的numpy数组,这既有利也有弊。不利之处在于,我们需要花时间来分配新空间,然后填充适当的数据。另一方面,一旦我们创建了这个新的滚动数组,我们就能够快速对其进行向量化操作,而不会受到来自 CPU 缓存的缓存未命中的影响。这可以极大地影响我们必须在网格上执行的实际计算的速度。本章后面我们将对此进行改写,以便在不断分配更多内存的情况下获得相同的好处。

借助这个额外的函数,我们可以使用更简单、向量化的numpy数组重写示例 6-6 中的 Python 扩散代码。示例 6-9 展示了我们最初的numpy扩散代码。

示例 6-9. 初始的numpy扩散
from numpy import (zeros, roll)

grid_shape = (640, 640)

def laplacian(grid):
    return (
        roll(grid, +1, 0) +
        roll(grid, -1, 0) +
        roll(grid, +1, 1) +
        roll(grid, -1, 1) -
        4 * grid
    )

def evolve(grid, dt, D=1):
    return grid + dt * D * laplacian(grid)

def run_experiment(num_iterations):
    grid = zeros(grid_shape)

    block_low = int(grid_shape[0] * 0.4)
    block_high = int(grid_shape[0] * 0.5)
    grid[block_low:block_high, block_low:block_high] = 0.005

    start = time.time()
    for i in range(num_iterations):
        grid = evolve(grid, 0.1)
    return time.time() - start

立即可以看到这段代码要简短得多。这有时是性能良好的一个很好的指标:我们在 Python 解释器外部完成了大部分重活,希望在专门为性能和解决特定问题而构建的模块内完成(但是,这始终需要测试!)。这里的一个假设是 numpy 使用更好的内存管理来更快地为 CPU 提供所需的数据。然而,由于是否发生这种情况取决于 numpy 的实际实现,让我们分析我们的代码以查看我们的假设是否正确。示例 6-10 展示了结果。

示例 6-10. numpy 二维扩散的性能计数器(网格大小:640 × 640,500 次迭代)
$ perf stat -e cycles,instructions,\
    cache-references,cache-misses,branches,branch-misses,task-clock,faults,\
    minor-faults,cs,migrations python diffusion_numpy.py

 Performance counter stats for 'python diffusion_numpy.py':

     8,432,416,866      cycles                    #    2.886 GHz
     7,114,758,602      instructions              #    0.84  insn per cycle
     1,040,831,469      cache-references          #  356.176 M/sec
       216,490,683      cache-misses              #   20.800 % of all cache refs
     1,252,928,847      branches                  #  428.756 M/sec
         8,174,531      branch-misses             #    0.65% of all branches
       2922.239426      task-clock (msec)         #    1.285 CPUs utilized
           403,282      faults                    #    0.138 M/sec
           403,282      minor-faults              #    0.138 M/sec
                96      cs                        #    0.033 K/sec
                 5      migrations                #    0.002 K/sec

       2.274377105 seconds time elapsed

这表明对 numpy 的简单更改使我们的纯 Python 实现在减少内存分配的情况下加速了 63.3 倍(示例 6-8)。这是如何实现的呢?

首先,我们可以归功于 numpy 提供的矢量化功能。虽然 numpy 版本似乎每个周期运行的指令更少,但每个指令的工作量更大。换句话说,一个矢量化的指令可以将数组中的四个(或更多)数字相乘,而不是需要四个独立的乘法指令。总体而言,这导致解决同一问题所需的总指令数量更少。

几个其他因素导致 numpy 版本需要较少的绝对指令数来解决扩散问题。其中一个因素与在纯 Python 版本中运行时可用的完整 Python API 有关,但不一定适用于 numpy 版本,例如,纯 Python 网格可以在纯 Python 中追加,但不能在 numpy 中。即使我们没有显式使用这个(或其他)功能,提供可以使用的系统仍然存在开销。由于 numpy 可以假设存储的数据始终是数字,因此所有关于数组的操作都可以进行优化。在我们讨论 Cython(参见 “Cython”)时,我们将继续删除必要的功能以换取性能,甚至可以删除列表边界检查以加快列表查找速度。

通常,指令的数量并不一定与性能相关——指令较少的程序可能没有有效地发出它们,或者它们可能是缓慢的指令。然而,我们看到,除了减少指令的数量外,numpy版本还减少了一项重大的低效率:缓存未命中(20.8%的缓存未命中,而不是 53.3%)。如“内存碎片化”中所解释的那样,缓存未命中会减慢计算,因为 CPU 必须等待数据从较慢的内存中检索,而不是在其缓存中立即可用数据。事实上,内存碎片化在性能中占主导地位,以至于如果我们在numpy中禁用矢量化但保持其他一切不变,⁸与纯 Python 版本(例子 6-11)相比,我们仍然看到了相当大的速度提升。

例子 6-11. numpy 2D 扩散的性能计数器,没有矢量化(网格大小:640 × 640,500 次迭代)
$ perf stat -e cycles,instructions,\
    cache-references,cache-misses,branches,branch-misses,task-clock,faults,\
    minor-faults,cs,migrations python diffusion_numpy.py

 Performance counter stats for 'python diffusion_numpy.py':

    50,086,999,350      cycles                    #    2.888 GHz
    53,611,608,977      instructions              #    1.07  insn per cycle
     1,131,742,674      cache-references          #   65.266 M/sec
       322,483,897      cache-misses              #   28.494 % of all cache refs
     4,001,923,035      branches                  #  230.785 M/sec
         6,211,101      branch-misses             #    0.16% of all branches
      17340.464580      task-clock (msec)         #    1.000 CPUs utilized
           403,193      faults                    #    0.023 M/sec
           403,193      minor-faults              #    0.023 M/sec
                74      cs                        #    0.004 K/sec
                 6      migrations                #    0.000 K/sec

      17.339656586 seconds time elapsed

这表明,当引入numpy时我们速度提升了 63.3 倍的主要因素不是矢量化指令集,而是内存局部性和减少的内存碎片化。事实上,我们可以从前面的实验中看到,矢量化仅占 63.3 倍速度提升的约 13%。⁹

这一认识到内存问题是减慢我们代码速度的主要因素并不令人过于震惊。计算机非常精确地设计来执行我们请求的计算,如乘法和加法。瓶颈在于快速将这些数字传递给 CPU,以便它能够尽可能快地执行计算。

内存分配和原地操作

为了优化内存主导的影响,让我们尝试使用与我们在例子 6-6 中使用的相同方法来减少我们在numpy代码中的分配次数。分配远不及我们之前讨论的缓存未命中糟糕。不仅仅是在 RAM 中找到正确的数据而不是在缓存中找到时,分配还必须向操作系统请求一个可用的数据块然后进行保留。与简单地填充缓存不同,向操作系统发出请求会产生相当多的开销——尽管填充缓存未命中是在主板上优化的硬件例行程序,但分配内存需要与另一个进程——内核进行通信才能完成。

为了消除示例 6-9 中的分配,我们将在代码开头预分配一些临时空间,然后仅使用原位操作。原位操作(如+=*=等)重复使用其中一个输入作为输出。这意味着我们不需要分配空间来存储计算结果。

为了明确显示这一点,我们将看看当对其执行操作时numpy数组的id如何变化(示例 6-12)。由于id指示了引用的内存段,因此id是跟踪numpy数组的良好方法。如果两个numpy数组具有相同的id,则它们引用相同的内存段。¹⁰

示例 6-12. 原位操作减少内存分配
>>> import numpy as np
>>> array1 = np.random.random((10,10))
>>> array2 = np.random.random((10,10))
>>> id(array1)
140199765947424 ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
>>> array1 += array2
>>> id(array1)
140199765947424 ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
>>> array1 = array1 + array2
>>> id(array1)
140199765969792 ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png)

1, 2

这两个id是相同的,因为我们在执行原位操作。这意味着array1的内存地址并未改变;我们只是改变了其中包含的数据。

3

在这里,内存地址已经改变。执行array1 + array2时,会分配一个新的内存地址,并将计算结果填充进去。然而,这样做有其好处,即在需要保留原始数据时(即array3 = array1 + array2允许您继续使用array1array2),而原位操作会破坏部分原始数据。

此外,我们可以看到非原位操作的预期减速。在示例 6-13 中,我们看到对于 100 × 100 元素的数组,使用原位操作可获得 27%的加速。随着数组的增长,这个差距将会更大,因为内存分配变得更加紧张。然而,重要的是要注意,这种效果仅在数组大小大于 CPU 缓存时发生!当数组较小且两个输入和输出都可以适应缓存时,离开原位操作更快,因为它可以从矢量化中获益。

示例 6-13. 原位和离开原位操作的运行时差异
>>> import numpy as np

>>> %%timeit array1, array2 = np.random.random((2, 100, 100))  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png) ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png)
... array1 = array1 + array2
6.45 µs ± 53.3 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each) 
>>> %%timeit array1, array2 = np.random.random((2, 100, 100))  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
... array1 += array2
5.06 µs ± 78.7 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each) 
>>> %%timeit array1, array2 = np.random.random((2, 5, 5))  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
... array1 = array1 + array2
518 ns ± 4.88 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each) 
>>> %%timeit array1, array2 = np.random.random((2, 5, 5))  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
... array1 += array2
1.18 µs ± 6 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

1

由于这些数组过大无法适应 CPU 缓存,原位操作速度更快,因为分配较少且缓存未命中较少。

2

这些数组很容易适应缓存,我们看到离开原位操作更快。

3

请注意,我们使用%%timeit而不是%timeit,这使我们能够指定用于设置实验的代码,而不会计时。

缺点是,虽然将我们的代码从 示例 6-9 重写为使用原地操作并不是很复杂,但这确实使得结果代码有些难以阅读。在 示例 6-14 中,我们可以看到这种重构的结果。我们实例化了 gridnext_grid 向量,并且不断地将它们彼此交换。grid 是我们对系统当前信息的了解,而在运行 evolve 后,next_grid 包含了更新后的信息。

示例 6-14. 使大多数 numpy 操作原地执行
def laplacian(grid, out):
    np.copyto(out, grid)
    out *= -4
    out += np.roll(grid, +1, 0)
    out += np.roll(grid, -1, 0)
    out += np.roll(grid, +1, 1)
    out += np.roll(grid, -1, 1)

def evolve(grid, dt, out, D=1):
    laplacian(grid, out)
    out *= D * dt
    out += grid

def run_experiment(num_iterations):
    next_grid = np.zeros(grid_shape)
    grid = np.zeros(grid_shape)

    block_low = int(grid_shape[0] * 0.4)
    block_high = int(grid_shape[0] * 0.5)
    grid[block_low:block_high, block_low:block_high] = 0.005

    start = time.time()
    for i in range(num_iterations):
        evolve(grid, 0.1, next_grid)
        grid, next_grid = next_grid, grid  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
    return time.time() - start

1

由于 evolve 的输出存储在输出向量 next_grid 中,我们必须交换这两个变量,以便在循环的下一次迭代中,grid 具有最新的信息。这种交换操作非常便宜,因为只改变了对数据的引用,而不是数据本身。

警告

需要记住的是,由于我们希望每个操作都是原地执行,每当进行向量操作时,我们必须将其放在自己的一行上。这可能使得像 A = A * B + C 这样简单的操作变得非常复杂。由于 Python 强调可读性,我们应确保我们所做的更改能够提供足够的速度优势来证明其合理性。

从示例 6-15 和 6-10 中比较性能指标,我们看到消除了不必要的分配后,代码的运行速度提高了 30.9%。这种加速部分来自缓存未命中数量的减少,但主要来自于小故障的减少。这是通过减少代码中需要的内存分配次数来实现的,通过重复使用已分配的空间。

程序访问内存中新分配的空间时会导致小故障。由于内存地址由内核延迟分配,当你首次访问新分配的数据时,内核会暂停你的执行,确保所需空间存在并为程序创建引用。这额外的机制运行起来非常昂贵,并且可能大幅减慢程序运行速度。除了需要运行的额外操作外,我们还会失去缓存中的任何状态以及进行指令流水线处理的可能性。本质上,我们不得不放弃正在进行的所有事情,包括所有相关的优化,以便去分配一些内存。

示例 6-15. 使用原地内存操作的 numpy 性能指标(网格大小:640 × 640,500 次迭代)
$ perf stat -e cycles,instructions,\
    cache-references,cache-misses,branches,branch-misses,task-clock,faults,\
    minor-faults,cs,migrations python diffusion_numpy_memory.py

 Performance counter stats for 'python diffusion_numpy_memory.py':

     6,880,906,446      cycles                    #    2.886 GHz
     5,848,134,537      instructions              #    0.85  insn per cycle
     1,077,550,720      cache-references          #  452.000 M/sec
       217,974,413      cache-misses              #   20.229 % of all cache refs
     1,028,769,315      branches                  #  431.538 M/sec
         7,492,245      branch-misses             #    0.73% of all branches
       2383.962679      task-clock (msec)         #    1.373 CPUs utilized
            13,521      faults                    #    0.006 M/sec
            13,521      minor-faults              #    0.006 M/sec
               100      cs                        #    0.042 K/sec
                 8      migrations                #    0.003 K/sec

       1.736322099 seconds time elapsed

选择性优化:找出需要修复的问题

查看来自 示例 6-14 的代码,我们似乎已经解决了大部分问题:通过使用 numpy 减少了 CPU 负担,并减少了解决问题所需的分配次数。然而,还有更多的调查工作需要完成。如果我们对该代码进行行剖析(示例 6-16),我们会发现大部分工作都是在 laplacian 函数内完成的。事实上,evolve 运行所花费的时间中有 84% 是在 laplacian 函数内消耗的。

示例 6-16. 行剖析显示 laplacian 占用了太多时间。
Wrote profile results to diffusion_numpy_memory.py.lprof
Timer unit: 1e-06 s

Total time: 1.58502 s
File: diffusion_numpy_memory.py
Function: evolve at line 21

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    21                                           @profile
    22                                           def evolve(grid, dt, out, D=1):
    23       500    1327910.0   2655.8     83.8      laplacian(grid, out)
    24       500     100733.0    201.5      6.4      out *= D * dt
    25       500     156377.0    312.8      9.9      out += grid

laplacian 如此缓慢可能有许多原因。然而,有两个主要的高级问题需要考虑。首先,看起来对 np.roll 的调用正在分配新向量(我们可以通过查看函数的文档来验证这一点)。这意味着尽管我们在之前的重构中删除了七次内存分配,但仍然有四次未解决的分配问题。此外,np.roll 是一个非常通用的函数,其中有很多代码处理特殊情况。由于我们确切知道想要做什么(仅将数据的第一列移动到每个维度中的最后),我们可以重写此函数以消除大部分的多余代码。我们甚至可以将 np.roll 的逻辑与滚动数据时发生的添加操作合并,以创建一个非常专门的 roll_add 函数,以最少的分配次数和最少的额外逻辑来完成我们想要的操作。

示例 6-17 展示了此重构的样子。我们只需创建新的 roll_add 函数并让 laplacian 使用它即可。由于 numpy 支持花式索引,实现这样一个函数只是不搞乱索引的问题。然而,正如前面所述,虽然这段代码可能性能更好,但可读性大大降低。

警告

注意,为函数编写了信息丰富的文档字符串,并进行了完整的测试工作。当你走类似这样的路线时,保持代码的可读性是很重要的,这些步骤对确保你的代码始终按照预期工作,并让未来的程序员能够修改你的代码并了解事物的作用和不工作时都有很大帮助。

示例 6-17. 创建我们自己的 roll 函数
import numpy as np

def roll_add(rollee, shift, axis, out):
    """
 Given a matrix, a rollee, and an output matrix, out, this function will
 perform the calculation:

 >>> out += np.roll(rollee, shift, axis=axis)

 This is done with the following assumptions:
 * rollee is 2D
 * shift will only ever be +1 or -1
 * axis will only ever be 0 or 1 (also implied by the first assumption)

 Using these assumptions, we are able to speed up this function by avoiding
 extra machinery that numpy uses to generalize the roll function and also
 by making this operation intrinsically in-place.
 """
    if shift == 1 and axis == 0:
        out[1:, :] += rollee[:-1, :]
        out[0, :] += rollee[-1, :]
    elif shift == -1 and axis == 0:
        out[:-1, :] += rollee[1:, :]
        out[-1, :] += rollee[0, :]
    elif shift == 1 and axis == 1:
        out[:, 1:] += rollee[:, :-1]
        out[:, 0] += rollee[:, -1]
    elif shift == -1 and axis == 1:
        out[:, :-1] += rollee[:, 1:]
        out[:, -1] += rollee[:, 0]

def test_roll_add():
    rollee = np.asarray([[1, 2], [3, 4]])
    for shift in (-1, +1):
        for axis in (0, 1):
            out = np.asarray([[6, 3], [9, 2]])
            expected_result = np.roll(rollee, shift, axis=axis) + out
            roll_add(rollee, shift, axis, out)
            assert np.all(expected_result == out)

def laplacian(grid, out):
    np.copyto(out, grid)
    out *= -4
    roll_add(grid, +1, 0, out)
    roll_add(grid, -1, 0, out)
    roll_add(grid, +1, 1, out)
    roll_add(grid, -1, 1, out)

如果我们查看这个重写的性能计数器在 示例 6-18 中,我们可以看到,虽然比 示例 6-14 快 22%,但大多数计数器几乎都相同。主要的区别再次在于 cache-misses,下降了 7 倍。这一变化似乎还影响了将指令传输到 CPU 的吞吐量,将每周期的指令数从 0.85 增加到 0.99(增加了 14%)。同样,故障减少了 12.85%。这似乎是首先在原地进行滚动以及减少 numpy 机制所需的结果,以执行所有所需的计算。不再需要计算机在每次操作时重新填充缓存。这种消除 numpy 和一般 Python 中不必要机制的主题将继续在 “Cython” 中。

示例 6-18. numpy 使用原地内存操作和自定义 laplacian 函数的性能指标(网格大小:640 × 640,500 次迭代)
$ perf stat -e cycles,instructions,\
    cache-references,cache-misses,branches,branch-misses,task-clock,faults,\
    minor-faults,cs,migrations python diffusion_numpy_memory2.py

 Performance counter stats for 'python diffusion_numpy_memory2.py':

     5,971,464,515      cycles                    #    2.888 GHz
     5,893,131,049      instructions              #    0.99  insn per cycle
     1,001,582,133      cache-references          #  484.398 M/sec
        30,840,612      cache-misses              #    3.079 % of all cache refs
     1,038,649,694      branches                  #  502.325 M/sec
         7,562,009      branch-misses             #    0.73% of all branches
       2067.685884      task-clock (msec)         #    1.456 CPUs utilized
            11,981      faults                    #    0.006 M/sec
            11,981      minor-faults              #    0.006 M/sec
                95      cs                        #    0.046 K/sec
                 3      migrations                #    0.001 K/sec

       1.419869071 seconds time elapsed

numexpr:使原地操作更快速更便捷

numpy 对向量操作的优化的一个缺点是,它仅一次处理一个操作。也就是说,当我们使用 numpy 向量执行操作 A * B + C 时,首先完成整个 A * B 操作,然后将数据存储在临时向量中;然后将这个新向量与 C 相加。示例 6-14 中的扩散代码的原地版本明确展示了这一点。

然而,许多模块都可以帮助实现这一点。 numexpr 是一个可以将整个向量表达式编译成非常高效的代码的模块,优化以减少缓存未命中和临时空间的使用。此外,这些表达式可以利用多个 CPU 核心(详见第九章了解更多信息),并利用您的 CPU 可能支持的专用指令,以获得更大的加速效果。它甚至支持 OpenMP,可以在您的机器上并行执行操作。

改用 numexpr 很容易:所需的只是将表达式重写为带有本地变量引用的字符串。这些表达式在后台编译(并缓存,以便对相同表达式的调用不会再次付出编译成本),并使用优化代码运行。示例 6-19 展示了将 evolve 函数改用 numexpr 的简易程度。在这种情况下,我们选择使用 evaluate 函数的 out 参数,以便 numexpr 不会分配新的向量来存储计算结果。

示例 6-19. 使用numexpr进一步优化大矩阵操作
from numexpr import evaluate

def evolve(grid, dt, next_grid, D=1):
    laplacian(grid, next_grid)
    evaluate("next_grid * D * dt + grid", out=next_grid)

numexpr的一个重要特性是考虑 CPU 缓存。它特别移动数据,以便各种 CPU 缓存具有正确的数据,以最小化缓存未命中。当我们在更新后的代码上运行perf(示例 6-20)时,我们看到了加速。然而,如果我们看一下 256 × 256 的较小网格的性能,则会看到速度下降(参见表 6-2)。这是为什么呢?

示例 6-20. 用于numpy的性能指标与原地内存操作,自定义laplacian函数和numexpr(网格大小:640 × 640,500 次迭代)
$ perf stat -e cycles,instructions,\
    cache-references,cache-misses,branches,branch-misses,task-clock,faults,\
    minor-faults,cs,migrations python diffusion_numpy_memory2_numexpr.py

 Performance counter stats for 'python diffusion_numpy_memory2_numexpr.py':

     8,856,947,179      cycles                    #    2.872 GHz
     9,354,357,453      instructions              #    1.06  insn per cycle
     1,077,518,384      cache-references          #  349.423 M/sec
        59,407,830      cache-misses              #    5.513 % of all cache refs
     1,018,525,317      branches                  #  330.292 M/sec
        11,941,430      branch-misses             #    1.17% of all branches
       3083.709890      task-clock (msec)         #    1.991 CPUs utilized
            15,820      faults                    #    0.005 M/sec
            15,820      minor-faults              #    0.005 M/sec
             8,671      cs                        #    0.003 M/sec
             2,096      migrations                #    0.680 K/sec

       1.548924090 seconds time elapsed

我们在程序中引入numexpr的大部分额外机制涉及缓存考虑。当我们的网格大小较小且所有计算所需的数据都适合缓存时,这些额外机制只会增加更多不利于性能的指令。此外,编译我们以字符串编码的向量操作会增加很大的开销。当程序的总运行时间较短时,这种开销可能非常明显。然而,随着网格大小的增加,我们应该期望看到numexpr比原生numpy更好地利用我们的缓存。此外,numexpr利用多个核心进行计算,并尝试饱和每个核心的缓存。当网格大小较小时,管理多个核心的额外开销会超过任何可能的速度增加。

我们运行代码的特定计算机具有 8,192 KB 的缓存(Intel Core i7-7820HQ)。因为我们正在操作两个数组,一个用于输入,一个用于输出,所以我们可以轻松地计算填充我们缓存的网格大小。我们总共可以存储的网格元素数量为 8,192 KB / 64 位 = 1,024,000。由于我们有两个网格,所以这个数字分为两个对象(因此每个对象最多可以有 1,024,000 / 2 = 512,000 个元素)。最后,对这个数字取平方根会给出使用这么多网格元素的网格大小。总的来说,这意味着大约两个大小为 715 × 715 的二维数组会填满缓存( 8192 K B / 64 b i t / 2 = 715 . 5)。然而,在实际操作中,我们并不能自己填满缓存(其他程序将填满部分缓存),所以实际上我们可能可以容纳两个 640 × 640 的数组。查看表 6-1 和 6-2,我们可以看到,当网格大小从 512 × 512 跳至 1,024 × 1,024 时,numexpr代码开始优于纯numpy

一个警示故事:验证“优化”(scipy)

从本章中需要记住的重要一点是我们对每一个优化采取的方法:对代码进行分析以了解正在发生的情况,提出可能的解决方案来修复慢速部分,然后再次分析以确保修复确实有效。尽管这听起来很简单,但情况可能很快变得复杂,正如我们在考虑网格大小时看到的 numexpr 性能如何依赖于。

当然,我们提出的解决方案并不总是按预期工作。在编写本章代码时,我们发现 laplacian 函数是最慢的例程,并假设 scipy 的例程会快得多。这种想法来自于 Laplacian 在图像分析中是常见操作,可能有非常优化的库来加速调用。scipy 有一个图像子模块,所以我们一定会有所收获!

实现非常简单(示例 6-21),并且几乎不需要思考如何实现周期性边界条件(或者如 scipy 所称的“wrap”条件)。

示例 6-21. 使用 scipylaplace 过滤器
from scipy.ndimage.filters import laplace

def laplacian(grid, out):
    laplace(grid, out, mode="wrap")

实施的简易性非常重要,在我们考虑性能之前,这种方法确实赢得了一些好评。然而,一旦我们对 scipy 代码进行了基准测试(示例 6-22),我们有了一个领悟:与其基于的代码相比,这种方法并没有提供实质性的加速效果(参见示例 6-14)。事实上,随着网格大小的增加,这种方法的性能开始变差(请查看本章末尾的图 6-4)。

示例 6-22. 使用 scipylaplace 函数进行扩散性能指标测试(网格大小:640 × 640,500 次迭代)
$ perf stat -e cycles,instructions,\
    cache-references,cache-misses,branches,branch-misses,task-clock,faults,\
    minor-faults,cs,migrations python diffusion_scipy.py

 Performance counter stats for 'python diffusion_scipy.py':

    10,051,801,725      cycles                    #    2.886 GHz
    16,536,981,020      instructions              #    1.65  insn per cycle
     1,554,557,564      cache-references          #  446.405 M/sec
       126,627,735      cache-misses              #    8.146 % of all cache refs
     2,673,416,633      branches                  #  767.696 M/sec
         9,626,762      branch-misses             #    0.36% of all branches
       3482.391211      task-clock (msec)         #    1.228 CPUs utilized
            14,013      faults                    #    0.004 M/sec
            14,013      minor-faults              #    0.004 M/sec
                95      cs                        #    0.027 K/sec
                 5      migrations                #    0.001 K/sec

       2.835263796 seconds time elapsed

比较 scipy 版本代码与我们自定义的 laplacian 函数的性能指标(示例 6-18),我们可以开始得出一些线索,了解为什么从这次重写中没有得到预期的加速效果。

最引人注目的指标是 instructions。这表明 scipy 代码要求 CPU 执行的工作量是我们自定义 laplacian 代码的两倍以上。尽管这些指令在数值上更优化(正如我们从更高的 insn per cycle 计数中可以看出来,这个数值表示 CPU 在一个时钟周期内可以执行多少指令),额外的优化并没有因为增加的指令数量而胜出。

这在一定程度上可能是因为 scipy 代码编写得非常普遍,因此它可以处理各种带有不同边界条件的输入(这需要额外的代码和更多指令)。实际上,我们可以通过 scipy 代码所需的高分支数看到这一点。当代码有许多分支时,意味着我们基于条件运行命令(例如在 if 语句中有代码)。问题在于,在检查条件之前,我们不知道是否可以评估表达式,因此无法进行向量化或流水线处理。分支预测机制可以帮助解决这个问题,但并不完美。这更多地说明了专门代码速度的重要性:如果你不需要不断检查需要执行的操作,而是知道手头的具体问题,那么你可以更有效地解决它。

矩阵优化的教训

回顾我们的优化过程,我们似乎走了两条主要路线:减少将数据传输到 CPU 的时间和减少 CPU 需要执行的工作量。表格 6-1 和 6-2 比较了我们在各种数据集大小下,相对于原始纯 Python 实现,通过各种优化努力达到的结果。

图 6-4 显示了所有这些方法相互比较的图形化展示。我们可以看到三个性能带,分别对应这两种方法:底部的带显示了通过我们的第一个内存分配减少尝试对我们的纯 Python 实现所做的小改进;中间带显示了当我们使用 numpy 进一步减少分配时发生的情况;而上带则说明了通过减少我们的处理过程所做工作来实现的结果。

表格 6-1. 各种网格大小和 evolve 函数的 500 次迭代的所有方案的总运行时间

方法 256 x 256 512 x 512 1024 x 1024 2048 x 2048 4096 x 4096
Python 2.40s 10.43s 41.75s 168.82s 679.16s
Python + 内存 2.26s 9.76s 38.85s 157.25s 632.76s
numpy 0.01s 0.09s 0.69s 3.77s 14.83s
numpy + 内存 0.01s 0.07s 0.60s 3.80s 14.97s
numpy + 内存 + 拉普拉斯 0.01s 0.05s 0.48s 1.86s 7.50s
numpy + 内存 + 拉普拉斯 + numexpr 0.02s 0.06s 0.41s 1.60s 6.45s
numpy + 内存 + scipy 0.05s 0.25s 1.15s 6.83s 91.43s

表 6-2. 对纯 Python(示例 6-3)的加速比,所有方案和各种网格大小在 evolve 函数的 500 次迭代中进行比较

方法 256 x 256 512 x 512 1024 x 1024 2048 x 2048 4096 x 4096
Python 1.00x 1.00x 1.00x 1.00x 1.00x
Python + memory 1.06x 1.07x 1.07x 1.07x 1.07x
numpy 170.59x 116.16x 60.49x 44.80x 45.80x
numpy + memory 185.97x 140.10x 69.67x 44.43x 45.36x
numpy + memory + laplacian 203.66x 208.15x 86.41x 90.91x 90.53x
numpy + memory + laplacian + numexpr 97.41x 167.49x 102.38x 105.69x 105.25x
numpy + memory + scipy 52.27x 42.00x 36.44x 24.70x 7.43x

从中可以得到一个重要教训,那就是你应该始终处理代码在初始化期间必须完成的任何管理事务。这可能包括分配内存,或从文件中读取配置,甚至预先计算将在程序生命周期中需要的值。这有两个重要原因。首先,通过一次性地执行这些任务,你减少了这些任务必须完成的总次数,并且你知道未来将能够在不太多的惩罚的情况下使用这些资源。其次,你不会打断程序的流程;这使其能够更有效地进行流水线处理,并保持缓存中充满更相关的数据。

你还学到了更多关于数据局部性的重要性,以及将数据简单传输到 CPU 的重要性。CPU 缓存可能非常复杂,通常最好让设计用于优化它们的各种机制来处理这个问题。然而,理解正在发生的事情,并尽可能优化内存处理方式,可以产生重大影响。例如,通过理解缓存的工作原理,我们能够理解,无论网格大小如何,在 图 6-4 中导致性能下降的饱和加速度可能是由于我们的网格填满了 L3 缓存。当这种情况发生时,我们停止从层次化内存方法中受益,以解决冯·诺依曼瓶颈。

本章尝试方法的速度提升总结

图 6-4. 本章尝试方法的速度提升总结

另一个重要的教训涉及使用外部库。Python 以其易用性和可读性而闻名,使您能够快速编写和调试代码。然而,调整性能以适应外部库是至关重要的。这些外部库可以非常快,因为它们可以用较低级别语言编写,但由于它们与 Python 接口,您仍然可以快速编写使用它们的代码。

最后,我们学到了在运行实验之前对所有内容进行基准测试并形成性能假设的重要性。通过在运行基准测试之前形成假设,我们能够制定条件来告诉我们优化是否真正起作用。这个改变能够加快运行时间吗?它减少了分配的数量吗?缓存未命中的数量是否更少?优化有时候可以成为一种艺术,因为计算系统的复杂性非常庞大,通过定量探索实际发生的事情可以极大地帮助。

最后一点关于优化的内容是必须非常小心确保您所做的优化可以推广到不同的计算机(您所做的假设和基准测试的结果可能取决于您正在运行的计算机的体系结构,以及您使用的模块是如何编译的等等)。此外,在进行这些优化时,考虑其他开发人员以及更改将如何影响代码的可读性非常重要。例如,我们意识到我们在 示例 6-17 中实施的解决方案可能存在模糊性,因此需要确保代码被充分记录和测试,以帮助我们团队内部以及其他人。

然而,有时候,您的数值算法也需要相当多的数据整理和操作,而不仅仅是明确的数学操作。在这些情况下,Pandas 是一个非常流行的解决方案,它有其自身的性能特征。我们现在将深入研究 Pandas 并了解如何更好地使用它来编写高性能的数值代码。

Pandas

Pandas 是科学 Python 生态系统中处理表格数据的事实标准工具。它能轻松处理类似 Excel 的异构数据类型表格,称为 DataFrames,并且对时间序列操作有强大支持。自 2008 年以来,公共接口和内部机制都有了很大的发展,公共论坛上关于“快速解决问题的方法”存在很多争议信息。在本节中,我们将纠正关于 Pandas 常见用例的一些误解。

我们将审查 Pandas 的内部模型,找出如何在 DataFrame 上高效应用函数,看看为什么重复连接到 DataFrame 是构建结果的一个不良方式,并探讨处理字符串的更快方法。

Pandas 内部模型

Pandas 使用内存中的二维表格数据结构 —— 如果你想象一个 Excel 表格,那么你已经有了一个很好的初始心理模型。最初,Pandas 主要专注于 NumPy 的 dtype 对象,如每列的有符号和无符号数字。随着库的发展,它扩展到超出 NumPy 类型,现在可以处理 Python 字符串和扩展类型(包括可空的 Int64 对象 —— 注意大写的 “I” —— 和 IP 地址)。

DataFrame 上的操作适用于列中的所有单元格(或者如果使用 axis=1 参数,则适用于行中的所有单元格),所有操作都是急切执行的,并且不支持查询计划。对列的操作通常会生成临时中间数组,这些数组会消耗 RAM。一般建议是,当您操作您的 DataFrame 时,预期的临时内存使用量应为当前使用量的三到五倍。通常情况下,假设您有足够的 RAM 用于临时结果,Pandas 对小于 10 GB 大小的数据集效果很好。

操作可以是单线程的,可能受制于 Python 的全局解释器锁(GIL)。随着内部实现的改进,越来越多的情况下,GIL 可以自动禁用,从而实现并行操作。我们将探讨使用 Dask 进行并行化的方法,在《使用 Dask 进行并行 Pandas 操作》中。

幕后,相同 dtype 的列被 BlockManager 分组在一起。这个隐藏的机制部件旨在加快对相同数据类型列的行操作。这是使 Pandas 代码库复杂但使高级用户界面操作更快的许多隐藏技术细节之一。¹¹

在单个公共块的数据子集上执行操作通常会生成一个视图,而在跨不同 dtype 块的行切片可能会导致复制,这可能会较慢。一个后果是,虽然数字列直接引用它们的 NumPy 数据,但字符串列引用一个 Python 字符串列表,并且这些单独的字符串在内存中分散 —— 这可能会导致数字和字符串操作的速度差异出乎意料。

幕后,Pandas 使用了 NumPy 数据类型和其自身的扩展数据类型的混合。来自 NumPy 的示例包括 int8(1 字节)、int64(8 字节 — 注意小写的 “i”)、float16(2 字节)、float64(8 字节)和 bool(1 字节)。Pandas 提供的附加类型包括 categoricaldatetimetz。外观上,它们看起来工作方式类似,但在 Pandas 代码库的幕后,它们会引起大量特定于类型的 Pandas 代码和重复。

注意

虽然 Pandas 最初只使用numpy数据类型,但它已经发展出了自己的一套额外的 Pandas 数据类型,可以理解缺失数据(NaN)的行为,具有三值逻辑。你必须区分numpyint64(不支持 NaN)和 Pandas 的Int64,后者在幕后使用两列数据来处理整数和 NaN 的掩码位。需要注意的是,numpyfloat64天生支持 NaN。

使用 NumPy 数据类型的一个副作用是,虽然float具有 NaN(缺失值)状态,但对于intbool对象并非如此。如果在 Pandas 的intbool系列中引入 NaN 值,该系列将被提升为float。将int类型提升为float可能会降低可以在相同位数表示的数值精度,最小的floatfloat16,它的字节数是bool的两倍。

可空的Int64(注意大写的“I”)是 Pandas 0.24 版本中作为扩展类型引入的。在内部,它使用 NumPy 的int64和第二个布尔数组作为 NaN 掩码。对于Int32Int8也有等价的类型。截至 Pandas 1.0 版本,还引入了等效的可空布尔值(使用dtypeboolean,而不是numpybool,它不支持 NaN)。引入了StringDType,可能在未来提供比标准 Python str更高的性能和更少的内存使用,后者存储在object dtype列中。

对许多数据行应用函数

在 Pandas 中,对数据行应用函数是非常常见的。有多种方法可供选择,使用循环的 Python 惯用方法通常是最慢的。我们将通过一个基于真实挑战的示例来展示解决此问题的不同方法,并最终思考速度与可维护性之间的权衡。

普通最小二乘法(OLS)是数据科学中拟合数据线性的基本方法。它解决了m * x + c方程中的斜率和截距,根据给定的数据。当试图理解数据趋势时,这非常有用:它通常是增加还是减少?

我们工作中使用的一个示例是为一家电信公司的研究项目,我们希望分析一组潜在用户行为信号(例如市场营销活动、人口统计学和地理行为)。公司记录了每个人每天在手机上花费的小时数,并且问题是:这个人的使用是增加还是减少,以及这种变化随时间的推移如何?

要解决这个问题的一种方法是将公司多年来数百万用户的大数据集分成较小的数据窗口(例如,每个窗口代表多年数据中的 14 天)。对于每个窗口,我们通过 OLS 建模用户的使用情况,并记录他们的使用是否增加或减少。

最后,我们为每个用户列出了一个序列,显示出在给定的 14 天期间内,他们的使用情况通常是增加还是减少。然而,要达到这一点,我们必须大量运行 OLS!

对于一百万用户和两年的数据,我们可能有 730 个窗口,¹² 因此总共有 730,000,000 次 OLS 调用!为了实际解决这个问题,我们的 OLS 实现应该是相当精调的。

为了了解各种 OLS 实现的性能,我们将生成一些较小但代表性的合成数据,以便给我们提供对更大数据集预期的良好指示。我们将为 100,000 行生成数据,每行代表一个合成用户,每行包含 14 列,“每天使用小时数”,作为连续变量。

我们将从泊松分布(lambda==60,单位为分钟)中抽取,并除以 60 得到模拟的使用小时数作为连续值。对于这个实验来说,随机数据的真实性并不重要;使用具有最小值为 0 的分布是方便的,因为这代表了真实世界的最小值。您可以在示例 6-23 中看到一个样本。

示例 6-23。我们数据的片段
         0         1         2  ...       12        13
0  1.016667  0.883333  1.033333 ...  1.016667  0.833333
1  1.033333  1.016667  0.833333 ...  1.133333  0.883333
2  0.966667  1.083333  1.183333 ...  1.000000  0.950000

在图 6-5 中,我们看到了三行 14 天的合成数据。

Pandas 内存分组为特定数据类型的块

图 6-5。前三个模拟用户的合成数据,显示了 14 天的手机使用情况

生成 100,000 行数据的奖励是,仅仅因为随机变化,某些行将表现出“计数增加”,而某些行将表现出“计数减少”。请注意,我们的合成数据中没有这背后的信号,因为这些点是独立绘制的;仅仅因为我们生成了许多数据行,我们将看到我们计算的线的最终斜率存在差异。

这很方便,因为我们可以识别出“增长最多”和“下降最多”的线,并将它们绘制出来,以验证我们是否正在识别出希望在真实世界问题中导出的信号。图 6-6 展示了我们两条随机轨迹的最大和最小斜率(m)。

Pandas 内存分组为特定数据类型的块

图 6-6。我们随机生成的数据集中“增长最多”和“下降最多”的使用情况

我们将从 scikit-learn 的LinearRegression估算器开始计算每个m。虽然这种方法是正确的,但我们将在接下来的部分看到,与另一种方法相比,它会产生意外的开销。

我们应该使用哪种 OLS 实现?

示例 6-24 展示了我们想尝试的三种实现。我们将评估 scikit-learn 实现与直接使用 NumPy 的线性代数实现。这两种方法最终执行相同的工作,并计算每个 Pandas 行的目标数据的斜率(m)和截距(c),给定一个增加的x范围(值为[0, 1, ..., 13])。

对于许多机器学习从业者来说,scikit-learn 将是默认选择,而线性代数解决方案可能会受到来自其他学科背景人员的青睐。

示例 6-24. 使用 NumPy 和 scikit-learn 解决普通最小二乘法
def ols_sklearn(row):
    """Solve OLS using scikit-learn's LinearRegression"""
    est = LinearRegression()
    X = np.arange(row.shape[0]).reshape(-1, 1) # shape (14, 1)
    # note that the intercept is built inside LinearRegression
    est.fit(X, row.values)
    m = est.coef_[0] # note c is in est.intercept_
    return m

def ols_lstsq(row):
    """Solve OLS using numpy.linalg.lstsq"""
    # build X values for [0, 13]
    X = np.arange(row.shape[0]) # shape (14,)
    ones = np.ones(row.shape[0]) # constant used to build intercept
    A = np.vstack((X, ones)).T # shape(14, 2)
    # lstsq returns the coefficient and intercept as the first result
    # followed by the residuals and other items
    m, c = np.linalg.lstsq(A, row.values, rcond=-1)[0]
    return m

def ols_lstsq_raw(row):
    """Variant of `ols_lstsq` where row is a numpy array (not a Series)"""
    X = np.arange(row.shape[0])
    ones = np.ones(row.shape[0])
    A = np.vstack((X, ones)).T
    m, c = np.linalg.lstsq(A, row, rcond=-1)[0]
    return m

令人惊讶的是,如果我们使用timeit模块对ols_sklearn进行 10000 次调用,在相同的数据上,执行时间至少为 0.483 微秒,而在相同数据上,ols_lstsq只需 0.182 微秒。流行的 scikit-learn 解决方案花费的时间超过了简洁的 NumPy 变体的两倍!

基于来自“使用line_profiler进行逐行测量”的分析,我们可以使用对象接口(而不是命令行或 Jupyter 魔术界面)来了解 scikit-learn 实现较慢的原因。在示例 6-25 中,我们告诉LineProfiler分析est.fit(这是我们的LinearRegression估计器上的 scikit-learn fit方法),然后根据之前使用的 DataFrame 调用run方法。

我们看到了一些意外情况。fit的最后一行调用了与我们在ols_lstsq中调用的相同的linalg.lstsq,那么是什么导致我们的速度变慢?LineProfiler显示 scikit-learn 在调用另外两个昂贵的方法,即check_X_y_preprocess_data

这两种方法都旨在帮助我们避免犯错——确实,您的作者 Ian 已多次因将不合适的数据(如形状错误的数组或包含 NaN 的数组)传递给 scikit-learn 估计器而受到拯救。这种检查的结果是需要更多时间——更多的安全性导致运行速度变慢!我们在开发者时间(和理智)与执行时间之间进行权衡。

示例 6-25. 探索 scikit-learn 的LinearRegression.fit调用
...
lp = LineProfiler(est.fit)
print("Run on a single row")
lp.run("est.fit(X, row.values)")
lp.print_stats()

Line #   % Time  Line Contents
==============================
   438               def fit(self, X, y, sample_weight=None):
...
   462      0.3          X, y = check_X_y(X, y,
                                          accept_sparse=['csr', 'csc', 'coo'],
   463     35.0                           y_numeric=True, multi_output=True)
...
   468      0.3          X, y, X_offset, y_offset, X_scale = \
                                 self._preprocess_data(
   469      0.3                       X, y,
                                      fit_intercept=self.fit_intercept,
                                      normalize=self.normalize,
   470      0.2                       copy=self.copy_X,
                                      sample_weight=sample_weight,
   471     29.3                       return_mean=True)
...
   502                       self.coef_, self._residues,
                                      self.rank_, self.singular_ = \
   503     29.2                  linalg.lstsq(X, y)

在幕后,这两种方法都在执行各种检查,包括以下内容:

  • 检查适当的稀疏 NumPy 数组(尽管在此示例中我们使用的是密集数组)

  • 将输入数组的偏移量设置为 0 的平均值,以改善比我们使用的更宽的数据范围的数值稳定性

  • 检查我们提供了一个 2D 的 X 数组

  • 检查我们不提供 NaN 或 Inf 值

  • 检查我们提供的数据数组不为空

通常,我们更倾向于启用所有这些检查——它们帮助我们避免痛苦的调试过程,这会降低开发者的生产力。如果我们知道我们的数据对于所选算法是正确的形式,这些检查将会增加负担。你需要决定,当这些方法的安全性影响整体生产力时。

作为一般规则——在这种情况下,留在更安全的实现方式(例如 scikit-learn),除非你确信你的数据是正确的形式,并且你正在优化性能。我们追求更高的性能,因此我们将继续使用 ols_lstsq 方法。

将 lstsq 应用到我们的数据行

我们将从许多来自其他编程语言的 Python 开发人员常用的方法开始。这 不是 Python 的习惯用法,也不是 Pandas 中常见或高效的方法。它的优点是非常易于理解。在 示例 6-26 中,我们将迭代 DataFrame 的索引从行 0 到行 99,999;在每次迭代中,我们将使用 iloc 检索一行,然后计算该行的 OLS。

计算对于以下每种方法都是常见的——不同的是我们如何遍历行。这种方法需要 18.6 秒;它是我们评估选项中最慢的方法(慢了 3 倍)。

在幕后,每次解引用都是昂贵的——iloc 通过使用新的 row_idx 很多工作来获取行,然后将其转换为新的 Series 对象,返回并赋值给 row

示例 6-26. 我们最糟糕的实现方式——逐行计数和抓取,使用 iloc
ms = []
for row_idx in range(df.shape[0]):
    row = df.iloc[row_idx]
    m = ols_lstsq(row)
    ms.append(m)
results = pd.Series(ms)

接下来,我们将采用更符合 Python 习惯的方法:在 示例 6-27 中,我们使用 iterrows 迭代行,这看起来类似于我们如何使用 for 循环迭代 Python 可迭代对象(例如 listset)。这种方法看起来合理,而且稍快——花费 12.4 秒。

这更高效,因为我们不必进行如此多的查找——iterrows 可以沿着行行走,而不进行大量的顺序查找。row 仍然在每次循环迭代中作为新的 Series 创建。

示例 6-27. iterrows 用于更高效和“Python 式”的行操作
ms = []
for row_idx, row in df.iterrows():
    m = ols_lstsq(row)
    ms.append(m)
results = pd.Series(ms)

示例 6-28 跳过了大量的 Pandas 机制,因此避免了大量的开销。apply 直接将函数 ols_lstsq 传递给新的数据行(再次,在幕后为每行构造了新的 Series),而不创建 Python 中间引用。这需要 6.8 秒——这是一个显著的改进,并且代码更简洁易读!

示例 6-28. apply 用于习惯性的 Pandas 函数应用
ms = df.apply(ols_lstsq, axis=1)
results = pd.Series(ms)

我们在 示例 6-29 中的最终变体使用了相同的 apply 调用,加上额外的 raw=True 参数。使用 raw=True 可以阻止中间 Series 对象的创建。由于我们没有 Series 对象,我们必须使用我们的第三个 OLS 函数 ols_lstsq_raw;这个变体直接访问底层的 NumPy 数组。

避免创建和解除引用中间的 Series 对象,我们将执行时间再次减少,降至 5.3 秒。

示例 6-29. 使用 raw=True 避免中间 Series 对象的创建
ms = df.apply(ols_lstsq_raw, axis=1, raw=True)
results = pd.Series(ms)

使用 raw=True 选项使我们有选择地使用 Numba(“Numba 为 Pandas 编译 NumPy”)或 Cython 进行编译,因为它消除了编译 Pandas 层的复杂性,这些层目前不受支持。

我们将在 表 6-3 中总结对于单个窗口的 14 列模拟数据的 100,000 行数据的执行时间。新的 Pandas 用户经常在更喜欢使用 apply 时使用 ilociterrows(或类似的 itertuples)。

通过进行分析并考虑我们可能需要对 1,000,000 行数据进行 OLS 分析,最多达到 730 个数据窗口,我们可以看到,第一次朴素的方法结合 ilocols_sklearn 可能会花费 10(我们更大的数据集系数)* 730 * 18 秒 * 2(我们相对于 ols_lstsq 的减速系数)== 73 小时。

如果我们使用 ols_lstsq_raw 和我们最快的方法,相同的计算可能需要 10 * 730 * 5.3 秒 == 10 小时。对于一个可能代表一套类似操作的任务来说,这是一个显著的节省。如果我们编译并在多个核心上运行,我们将看到更快的解决方案。

表 6-3. 使用不同的 Pandas 逐行方法与 lstsq 的成本

方法 时间(秒)
iloc 18.6
iterrows 12.4
应用 6.8
应用 raw=True 5.3

早些时候,我们发现 scikit-learn 方法会在执行时间上增加显着的开销,因为它通过一系列检查来覆盖我们的数据。我们可以去掉这个安全网,但这可能会增加开发人员调试时间的成本。你的作者强烈建议你考虑向你的代码添加单元测试,以验证是否使用了一个众所周知且调试良好的方法来测试你选择的任何优化方法。如果你添加了一个单元测试来比较 scikit-learn 的 LinearRegression 方法和 ols_lstsq,你将为自己和其他同事提供一个关于为什么对一个看似标准的问题开发一个不太明显的解决方案的未来提示。

在进行了实验后,你可能还会得出一个结论,即经过充分测试的 scikit-learn 方法对于你的应用来说已经足够快了,而且你更乐意使用其他开发人员熟悉的库。这可能是一个非常明智的结论。

在“使用 Dask 进行并行 Pandas”的稍后部分,我们将通过使用 Dask 和 Swifter 将 Pandas 操作跨多个核心进行分组的数据。在“用于 Pandas 的 Numba 编译”中,我们研究了编译applyraw=True变体,以实现数量级的加速。编译和并行化可以结合起来,实现显著的最终加速,将我们预期的运行时间从大约 10 小时降低到只有 30 分钟。

从部分结果构建数据框架和系列,而不是简单地连接它们。

在示例 6-26 中,您可能会想知道为什么我们要建立一个部分结果列表,然后将其转换为系列,而不是逐步建立系列。我们先前的方法需要建立一个列表(具有内存开销),然后为系列建立第二结构,使我们在内存中有两个对象。这将我们带到使用 Pandas 和 NumPy 时的另一个常见错误。

通常情况下,您应该避免在 Pandas 中重复调用concat(以及在 NumPy 中的等效concatenate)。在示例 6-30 中,我们看到与前述解决方案类似的解决方案,但没有中间的ms列表。此解决方案花费了 56 秒,而使用列表的解决方案则为 18.6 秒!

示例 6-30. 每次连接都会带来显著的开销——要避免这种情况!
results = None
for row_idx in range(df.shape[0]):
    row = df.iloc[row_idx]
    m = ols_lstsq(row)
    if results is None:
        results = pd.Series([m])
    else:
        results = pd.concat((results, pd.Series([m])))

每次连接都会创建一个全新的系列对象,存储在内存的新部分,比前一个项目多一行。此外,我们必须为每个迭代的新m创建一个临时系列对象。我们强烈建议先建立中间结果列表,然后从此列表构建系列或数据框架,而不是连接到现有对象。

有多种(可能还有更快的)完成工作的方法。

由于 Pandas 的演变,通常有几种解决同一任务的方法,其中一些方法比其他方法更耗费资源。让我们拿 OLS 数据框架并将一个列转换为字符串;然后我们将计时一些字符串操作。对于包含名称、产品标识符或代码的基于字符串的列,通常需要预处理数据,将其转换为可以分析的内容。

假设我们需要找到某列中数字 9 的位置(如果存在)。尽管此操作没有真正的目的,但它与检查标识符序列中是否存在带代码的符号或检查名称中的尊称非常相似。通常情况下,对于这些操作,我们将使用strip去除多余的空格,lowerreplace来规范化字符串,并使用find来定位感兴趣的内容。

在 示例 6-31 中,我们首先构建了一个名为 0_as_str 的新 Series,它是将第零个随机数序列转换为可打印的字符串形式。然后我们将运行两种字符串操作的变体——两者都将移除开头的数字和小数点,然后使用 Python 的 find 方法来查找第一个 9,如果不存在则返回 -1。

示例 6-31. str Series 操作与字符串处理中的 apply 方法的比较
In [10]: df['0_as_str'] = df[0].apply(lambda v: str(v))
Out[10]:
              0            0_as_str
0      1.016667  1.0166666666666666
1      1.033333  1.0333333333333334
2      0.966667  0.9666666666666667
...

def find_9(s):
    """Return -1 if '9' not found else its location at position >= 0"""
    return s.split('.')[1].find('9')

In [11]: df['0_as_str'].str.split('.', expand=True)[1].str.find('9')
Out[11]:
0       -1
1       -1
2        0

In [12]: %timeit df['0_as_str'].str.split('.', expand=True)[1].str.find('9')
Out[12]: 183 ms ± 4.62 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [13]: %timeit df['0_as_str'].apply(find_9)
Out[13]: 51 ms ± 987 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

单行方法使用 Pandas 的 str 操作来访问 Python 的字符串方法,应用于 Series。对于 split 操作,我们将返回的结果扩展为两列(第一列包含前导数字,第二列包含小数点后的所有内容),然后选择第二列。然后我们应用 find 来定位数字 9。第二种方法使用 apply 和函数 find_9,其读起来像是常规的 Python 字符串处理函数。

我们可以使用 %timeit 来检查运行时间——这显示出两种方法之间存在 3.5 倍的速度差异,尽管它们都产生相同的结果!在前一种单行情况下,Pandas 必须创建几个新的中间 Series 对象,这增加了开销;在 find_9 情况下,所有字符串处理工作都是逐行进行,而不创建新的中间 Pandas 对象。

apply 方法的进一步好处是我们可以并行化此操作(请参阅 “使用 Dask 和 Swifter 进行并行 Pandas” 作为示例),并且我们可以编写一个单元测试来简洁地确认 find_9 执行的操作,这将有助于可读性和维护性。

有效使用 Pandas 的建议

安装可选的依赖项 numexprbottleneck 可以提高性能。这些不会默认安装,并且如果缺少它们,你将得不到提示。bottleneck 在代码库中很少使用;然而,numexpr 在使用 exec 时,在某些情况下会显著加快速度。你可以通过在环境中导入 import bottleneckimport numexpr 来测试它们的存在。

不要将代码写得太简洁;记住要使代码易于阅读和调试,以帮助你的未来自己。虽然“方法链”风格得到支持,但我们建议不要在序列中链接太多行的 Pandas 操作。通常在调试时很难找出哪一行有问题,然后你不得不拆分这些行——最好是最多只链接几个操作来简化维护。

避免做比必要更多的工作:最好是在计算剩余行之前过滤您的数据,而不是在计算后过滤。一般来说,为了高性能,我们希望机器尽可能少地进行计算;如果您能够过滤或遮蔽掉数据的部分,那么您可能会胜出。如果您从 SQL 源获取数据,然后在 Pandas 中进行连接或过滤,您可能希望首先在 SQL 层面进行过滤,以避免将不必要的数据拉入 Pandas。如果您正在调查数据质量,则可能不希望在最初就这样做,因为对您拥有的各种数据类型进行简化视图可能更有益。

随着 DataFrames 的演变,请检查它们的模式;使用像bulwark这样的工具,可以在运行时保证模式的符合,并在检查代码时视觉确认您的预期得到满足。在生成新结果时继续重命名您的列,以便您的 DataFrame 内容对您有意义;有时groupby和其他操作会给出愚蠢的默认名称,这可能会在以后造成困惑。使用.drop()删除不再需要的列以减少臃肿和内存使用。

对于包含低基数字符串的大 Series(例如“yes”和“no”,或“type_a”、“type_b”和“type_c”),尝试将 Series 转换为分类dtype,使用df['series_of_strings'].astype('category');您可能会发现像value_countsgroupby这样的操作运行得更快,而且 Series 可能会消耗更少的 RAM。

同样地,你可能想要将 8 字节的float64int64列转换为更小的数据类型——也许是 2 字节的float16或者 1 字节的int8,如果你需要更小的范围以进一步节省 RAM。

在演变 DataFrames 并生成新副本时,请记住可以使用del关键字删除早期的引用并从内存中清除,如果它们很大且浪费空间。您也可以使用 Pandas 的drop方法删除未使用的列。

如果您在准备数据进行处理时操作大型 DataFrames,可能最好是在函数或单独的脚本中执行这些操作一次,然后使用to_pickle将准备好的版本持久化到磁盘上。之后您可以在准备好的 DataFrame 上进行后续工作,而无需每次都处理它。

避免使用inplace=True操作符——逐步删除库中的原位操作。

最后,请始终为任何处理代码添加单元测试,因为代码很快就会变得更复杂且更难调试。在开始开发测试时就可以保证您的代码符合预期,并帮助您避免稍后出现的愚蠢错误,这些错误会消耗开发人员的调试时间。

加速 Pandas 的现有工具包括Modin和专注于 GPU 的cuDF。Modin 和 cuDF 采用不同的方法来并行化在类似于 Pandas DataFrame 的对象上的常见数据操作。

我们也想对新的Vaex 库表示敬意。Vaex 专为处理超出 RAM 范围的大型数据集而设计,通过惰性评估保留了与 Pandas 类似的接口。此外,Vaex 提供了大量内置的可视化函数。一个设计目标是尽可能多地利用 CPU,尽可能提供并行性。

Vaex 专注于处理更大的数据集和字符串密集型操作;作者已经重写了许多字符串操作,避免使用标准的 Python 函数,而是使用更快的 Vaex 的C++实现。请注意,Vaex 不能保证与 Pandas 完全相同的工作方式,因此可能会在不同行为的边缘情况下找到问题——无论如何,如果您尝试使用 Pandas 和 Vaex 处理相同的数据,请通过单元测试来确保代码的可靠性。

总结

在下一章中,我们将讨论如何创建自己的外部模块,以便更好地解决特定问题并提高效率。这使我们能够遵循快速原型制作程序的方法——首先用慢速代码解决问题,然后识别慢的元素,最后找到使这些元素更快的方法。通过频繁进行性能分析并仅优化已知慢的代码部分,我们可以节省时间,同时使程序尽可能快地运行。

¹ 这是来自示例 6-3 的代码,经过截断以适应页面边距。请记住,kernprof要求函数在进行性能分析时必须使用@profile进行装饰(参见“使用 line_profiler 进行逐行测量”)。

² 在示例 6-7 中分析的代码是来自示例 6-6 的代码,经过截断以适应页面边距。

³ 在 macOS 上,您可以使用 Google 的gperftools和提供的Instruments应用程序获得类似的度量。对于 Windows,我们听说 Visual Studio Profiler 工作效果很好;但是我们没有使用经验。

⁴ 可以通过使用nice实用程序来运行 Python 进程来实现(nice -n –20 python program.py)。将 nice 值设为-20 将确保尽可能少地执行代码。

⁵ 可以在https://oreil.ly/12Beq找到对各种故障的良好调查。

⁶ 这种效果在这个Stack Overflow 回答中有很好的解释。

⁷ 要深入了解numpy在各种问题上的应用,请查看 Nicolas P. Rougier 的From Python to Numpy

⁸ 我们通过在numpy中使用-fno-tree-vectorize标志来实现这一点。对于这个实验,我们使用以下命令构建了numpy 1.17.3:$ OPT='-fno-tree-vectorize' FOPT='-fno-tree-vectorize' BLAS=None LAPACK=None ATLAS=None python setup.py build

⁹ 这取决于使用的 CPU。

¹⁰ 这并不严格正确,因为两个numpy数组可以引用内存的同一部分,但使用不同的步幅信息以不同的方式表示相同的数据。这两个numpy数组将具有不同的idnumpy数组的id结构有许多微妙之处,超出了本讨论的范围。

¹¹ 查看 DataQuest 的博文“教程:在 Python 中使用 Pandas 处理大数据集”获取更多详细信息。

¹² 如果我们要使用滑动窗口,可能可以应用像statsmodels中的RollingOLS这样优化过的滚动窗口函数。

第七章:编译为 C

使代码运行更快的最简单方法是减少工作量。假设您已经选择了良好的算法并减少了处理的数据量,那么减少执行指令数的最简单方法就是将代码编译为机器码。

Python 为此提供了多种选项,包括像 Cython 这样的纯 C 编译方法;基于 LLVM 的编译通过 Numba;以及包含内置即时编译器(JIT)的替代虚拟机 PyPy。在决定采取哪种路径时,您需要平衡代码适应性和团队速度的需求。

每个工具都会向您的工具链添加新的依赖项,而 Cython 要求您使用一种新的语言类型(Python 和 C 的混合),这意味着您需要掌握一种新技能。Cython 的新语言可能会降低团队的速度,因为没有 C 知识的团队成员可能会在支持此代码时遇到困难;但实际上,这可能只是一个小问题,因为您只会在精心选择的代码小区域中使用 Cython。

值得注意的是,在您的代码上执行 CPU 和内存分析可能会让您开始考虑可以应用的更高级别算法优化。这些算法更改(例如避免计算的附加逻辑或缓存以避免重新计算)可以帮助您避免在代码中执行不必要的工作,而 Python 的表达能力有助于发现这些算法机会。Radim Řehůřek 在“使用 RadimRehurek.com(2014 年)使深度学习飞起来”的领域经验中讨论了 Python 实现如何击败纯 C 实现。

在本章中,我们将回顾以下内容:

  • Cython,最常用于编译为 C 的工具,涵盖了numpy和普通 Python 代码(需要一些 C 语言知识)

  • Numba,专门针对numpy代码的新编译器

  • PyPy,用于非numpy代码的稳定即时编译器,是普通 Python 可执行文件的替代品

本章稍后我们将探讨外部函数接口,允许将 C 代码编译为 Python 扩展模块。Python 的本地 API 与ctypes或者来自 PyPy 作者的cffi一起使用,以及 Fortran 到 Python 转换器f2py

可能实现什么样的速度提升?

如果您的问题可以采用编译方法,很可能会实现一个数量级或更大的性能提升。在这里,我们将探讨在单个核心上实现一到两个数量级加速以及通过 OpenMP 使用多核的各种方法。

经编译后,Python 代码通常运行更快的是数学代码,其中包含许多重复相同操作的循环。在这些循环内部,您可能会创建许多临时对象。

调用外部库的代码(例如正则表达式、字符串操作和调用数据库库)在编译后不太可能加速。受 I/O 限制的程序也不太可能显示显著的加速。

类似地,如果你的 Python 代码专注于调用向量化的numpy例程,那么在编译后可能不会运行得更快——只有在被编译的代码主要是 Python(也许主要是循环)时才会更快。我们在第六章中讨论了numpy操作;编译并不真正有帮助,因为中间对象并不多。

总体来说,编译后的代码很少会比手工编写的 C 例程运行得更快,但也不太可能运行得更慢。可能由你的 Python 生成的 C 代码会像手写的 C 例程一样快,除非 C 程序员特别了解如何调整 C 代码以适应目标机器的架构。

对于以数学为重点的代码,手写的 Fortran 例程可能会比等效的 C 例程更快,但这可能需要专家级别的知识。总体来说,编译后的结果(可能使用 Cython)将与大多数程序员所需的手写 C 代码结果非常接近。

在分析和优化算法时,请记住图 7-1 中的图表。通过分析你的代码,你可以更明智地选择算法级别的优化。之后,与编译器的一些集中工作应该可以带来额外的加速。可能会继续微调算法,但不要奇怪看到你的大量工作带来越来越小的改进。要知道额外的努力何时不再有用。

图 7-1. 一些分析和编译工作会带来很多收益,但持续的努力往往会带来越来越少的回报

如果你处理的是没有numpy的 Python 代码和“电池内置”库,那么 Cython 和 PyPy 是你的主要选择。如果你使用numpy,那么 Cython 和 Numba 是正确的选择。这些工具都支持 Python 3.6 及以上版本。

以下一些示例需要一些对 C 编译器和 C 代码的了解。如果你缺乏这方面的知识,你应该学习一些 C,并在深入研究之前编译一个可工作的 C 程序。

JIT 与 AOT 编译器

我们将要讨论的工具大致分为两类:提前编译的工具(AOT,例如 Cython)和即时编译的工具(JIT,例如 Numba、PyPy)。

通过 AOT 编译,你会创建一个针对你的机器专门优化的静态库。如果你下载numpyscipy或 scikit-learn,它将使用 Cython 在你的机器上编译库的部分(或者你将使用像 Continuum 的 Anaconda 这样的发行版预编译的库)。通过预先编译,你将拥有一个可立即用于解决问题的库。

通过 JIT 编译,你无需做太多(或者干脆不做)前期工作;你让编译器在使用时编译代码的恰当部分。这意味着你会遇到“冷启动”问题——如果你的程序的大部分内容可以编译而目前没有编译,那么当你开始运行代码时,它将运行非常缓慢,因为它在编译代码。如果每次运行脚本时都发生这种情况,而你运行脚本的次数很多,这个成本就会变得很大。PyPy 就遭受这个问题,所以你可能不想将其用于频繁运行但是运行时间较短的脚本。

目前的情况表明,预先编译可以为我们带来最佳的速度提升,但通常这需要较多的手动工作。即时编译提供了一些令人印象深刻的速度提升,而几乎不需要手动干预,但它也可能遇到刚刚描述的问题。在选择适合你问题的正确技术时,你必须考虑这些权衡。

为什么类型信息可以帮助代码运行更快?

Python 是动态类型的——一个变量可以引用任何类型的对象,并且任何代码行都可以改变所引用对象的类型。这使得虚拟机很难优化代码在机器码级别的执行方式,因为它不知道将来的操作将使用哪种基本数据类型。使代码保持通用性会使其运行速度变慢。

在下面的示例中,v要么是一个浮点数,要么是表示complex数的一对浮点数。这两种情况可能在同一循环的不同时间点发生,或者在相关的串行代码段中发生:

v = -1.0
print(type(v), abs(v))
<class 'float'> 1.0
v = 1-1j
print(type(v), abs(v))
<class 'complex'> 1.4142135623730951

abs函数的工作方式取决于底层数据类型。对整数或浮点数使用abs会将负值变为正值。对复数使用abs则涉及对平方和的平方根:

a b s ( c ) = c . r e a l 2 + c . i m a g 2

complex示例的机器码涉及更多的指令,运行时间更长。在对一个变量调用abs之前,Python 首先必须查找变量的类型,然后决定调用哪个版本的函数——当你进行大量重复调用时,这种开销会累积起来。

在 Python 内部,每个基本对象,比如整数,都会被包装在一个更高级的 Python 对象中(例如,整数的高级对象是int)。更高级的对象具有额外的函数,如用于存储的__hash__和用于打印的__str__

在一个受 CPU 限制的代码段中,变量的类型通常不会改变。这为我们提供了进行静态编译和更快代码执行的机会。

如果我们只想要大量的中间数学运算,我们不需要更高级的函数,也许我们也不需要引用计数的机制。我们可以降到机器代码级别,使用机器代码和字节快速计算,而不是操作更高级别的 Python 对象,这涉及更大的开销。为此,我们提前确定对象的类型,以便我们可以生成正确的 C 代码。

使用 C 编译器

在接下来的例子中,我们将使用来自 GNU C 编译器工具集的gccg++。如果您正确配置您的环境,您可以使用其他编译器(例如,Intel 的icc或 Microsoft 的cl)。Cython 使用gcc

gcc 对大多数平台来说都是一个非常好的选择;它得到了很好的支持并且相当先进。通过使用调优的编译器(例如,Intel 的icc在 Intel 设备上可能会比gcc产生更快的代码),通常可以挤出更多性能,但代价是您必须获得更多的领域知识,并学习如何调整替代编译器的标志。

C 和 C++ 经常用于静态编译,而不是像 Fortran 这样的其他语言,因为它们普及并且支持广泛的库。编译器和转换器(例如 Cython)可以研究注释的代码,以确定是否可以应用静态优化步骤(如内联函数和展开循环)。

对中间抽象语法树的积极分析(由 Numba 和 PyPy 执行)提供了结合 Python 表达方式的知识的机会,以通知底层编译器如何最好地利用已见到的模式。

重新审视朱利亚集合示例

回顾第二章中我们对朱利亚集合生成器进行了性能分析。该代码使用整数和复数生成输出图像。图像的计算受到 CPU 的限制。

代码中的主要成本是计算output列表的 CPU 限制的内部循环。该列表可以绘制为一个方形像素数组,其中每个值表示生成该像素的成本。

内部函数的代码在示例 7-1 中显示。

示例 7-1. 回顾朱利亚函数的 CPU 限制代码
def calculate_z_serial_purepython(maxiter, zs, cs):
    """Calculate output list using Julia update rule"""
    output = [0] * len(zs)
    for i in range(len(zs)):
        n = 0
        z = zs[i]
        c = cs[i]
        while n < maxiter and abs(z) < 2:
            z = z * z + c
            n += 1
        output[i] = n
    return output

在 Ian 的笔记本电脑上,使用纯 Python 实现在 CPython 3.7 上运行时,对 1,000 × 1,000 网格进行朱利亚集合计算,maxiter=300大约需要 8 秒。

Cython

Cython 是一个编译器,将带有类型注解的 Python 转换为编译的扩展模块。类型注解类似于 C 语言。可以使用import将此扩展作为常规 Python 模块导入。入门简单,但随着复杂性和优化水平的增加,需要逐步攀登学习曲线。对 Ian 而言,这是将计算密集型函数转换为更快代码的首选工具,因为它被广泛使用、成熟,并且支持 OpenMP。

借助 OpenMP 标准,可以将并行问题转换为在单台机器上多个 CPU 上运行的多进程感知模块。线程对你的 Python 代码是隐藏的;它们通过生成的 C 代码操作。

Cython(2007 年发布)是 Pyrex(2002 年发布)的一个分支,扩展了超出 Pyrex 初始目标的能力。使用 Cython 的库包括 SciPy、scikit-learn、lxml 和 ZeroMQ。

可以通过setup.py脚本使用 Cython 编译模块。也可以通过 IPython 中的“magic”命令交互使用。通常,开发人员会为类型进行注解,尽管某些自动注解也是可能的。

使用 Cython 编译纯 Python 版本

开始编写编译扩展模块的简便方法涉及三个文件。以我们的朱利亚集为例,它们如下所示:

  • 调用 Python 代码(我们之前的朱利亚代码的主体)

  • 要在新的.pyx文件中编译的函数

  • 包含调用 Cython 制作扩展模块的指令的setup.py

使用这种方法,调用setup.py脚本使用 Cython 将.pyx文件编译为编译模块。在类 Unix 系统上,编译后的模块可能是.so文件;在 Windows 上,应该是.pyd(类似 DLL 的 Python 库)。

对于朱利亚示例,我们将使用以下内容:

  • julia1.py用于构建输入列表和调用计算函数

  • cythonfn.pyx,其中包含我们可以注释的 CPU-bound 函数

  • setup.py,其中包含构建说明

运行setup.py的结果是一个可以导入的模块。在我们的julia1.py脚本中,在 Example 7-2 中,我们只需要对import新模块和调用我们的函数做一些微小的更改。

示例 7-2. 将新编译的模块导入我们的主要代码
...
import cythonfn  # as defined in setup.py
...
def calc_pure_python(desired_width, max_iterations):
    # ...
    start_time = time.time()
    output = cythonfn.calculate_z(max_iterations, zs, cs)
    end_time = time.time()
    secs = end_time - start_time
    print(f"Took {secs:0.2f} seconds")
...

在 Example 7-3 中,我们将从纯 Python 版本开始,没有类型注解。

示例 7-3. Cython 的 setup.py 中未修改的纯 Python 代码(从.py 重命名为 pyx)
# cythonfn.pyx
def calculate_z(maxiter, zs, cs):
    """Calculate output list using Julia update rule"""
    output = [0] * len(zs)
    for i in range(len(zs)):
        n = 0
        z = zs[i]
        c = cs[i]
        while n < maxiter and abs(z) < 2:
            z = z * z + c
            n += 1
        output[i] = n
    return output

在 Example 7-4 中显示的setup.py脚本很简短;它定义了如何将cythonfn.pyx转换为calculate.so

示例 7-4. setup.py,将 cythonfn.pyx 转换为 Cython 编译的 C 代码
from distutils.core import setup
from Cython.Build import cythonize

setup(ext_modules=cythonize("cythonfn.pyx",
                            compiler_directives={"language_level": "3"}))

当我们在示例 7-5 中使用build_ext参数运行setup.py脚本时,Cython 将查找cythonfn.pyx并构建cythonfn[…].so。这里language_level被硬编码为3以强制支持 Python 3.x

注意

请记住,这是一个手动步骤 —— 如果您更新了.pyxsetup.py并忘记重新运行构建命令,则不会有更新的.so模块可供导入。如果您不确定是否已编译代码,请检查.so文件的时间戳。如有疑问,请删除生成的 C 文件和.so文件,然后重新构建它们。

示例 7-5. 运行 setup.py 构建新的编译模块
$ python setup.py build_ext --inplace
Compiling cythonfn.pyx because it changed.
[1/1] Cythonizing cythonfn.pyx
running build_ext
building 'cythonfn' extension
gcc -pthread -B /home/ian/miniconda3/envs/high_performance_python_book_2e/...
gcc -pthread -shared -B /home/ian/miniconda3/envs/high_performance_python_...

--inplace参数告诉 Cython 将编译的模块构建到当前目录而不是单独的build目录中。构建完成后,我们将得到中间文件cythonfn.c,这相当难以阅读,以及cythonfn[…].so

现在运行julia1.py代码时,导入了编译的模块,并且在 Ian 的笔记本电脑上计算出朱利叶集耗时 4.7 秒,而不是通常的 8.3 秒。这是非常小的努力带来的有用改进。

pyximport

通过pyximport引入了一个简化的构建系统。如果您的代码具有简单的设置且不需要第三方模块,您可能完全可以不使用setup.py

通过在示例 7-6 中看到的方式导入pyximport并调用install,任何随后导入的.pyx文件都将自动编译。此.pyx文件可以包含注释,或者在本例中,它可以是未注释的代码。结果运行时间仍为 4.7 秒,唯一的区别是我们没有编写setup.py文件。

示例 7-6. 使用pyximport替换 setup.py
import pyximport
pyximport.install(language_level=3)
import cythonfn
# followed by the usual code

用于分析代码块的 Cython 注解

前面的示例显示,我们可以快速构建一个编译模块。对于紧密的循环和数学运算,这通常会导致速度提升。显然,我们不应该盲目优化 —— 我们需要知道哪些代码行花费了大量时间,以便决定在哪里集中精力。

Cython 具有一个注解选项,可以输出一个 HTML 文件,我们可以在浏览器中查看。我们使用命令cython -a cythonfn.pyx,并生成输出文件cythonfn.html。在浏览器中查看,它看起来像图 7-2。在Cython 文档中也提供了类似的图像。

图 7-2. 未注释函数的彩色 Cython 输出

每行都可以双击展开,显示生成的 C 代码。更多的黄色表示“更多的调用进入 Python 虚拟机”,而更多的白色表示“更多的非 Python C 代码”。目标是尽可能减少黄色行,并尽可能增加白色行。

尽管“更多黄线”意味着更多调用进入虚拟机,这并不一定会导致你的代码运行变慢。每次调用进入虚拟机都是有成本的,但这些调用的成本只有在发生在大循环内部时才会显著。在大循环之外的调用(例如,在函数开始时用于创建output的行)相对于内部计算循环的成本来说并不昂贵。不要浪费时间在不会导致减速的行上。

在我们的例子中,回调到 Python 虚拟机次数最多的行(“最黄色的”)是第 4 和第 8 行。根据我们以前的分析工作,我们知道第 8 行可能被调用超过 3000 万次,因此这是一个需要重点关注的绝佳候选项。

第 9、10 和 11 行几乎同样是黄色的,并且我们也知道它们位于紧密的内部循环中。总体而言,它们将负责这个函数的大部分执行时间,因此我们需要首先关注这些行。如果需要回顾一下在这一部分中花费了多少时间,请参阅“使用 line_profiler 进行逐行测量”。

第 6 和 7 行的黄色较少,因为它们只被调用了 100 万次,它们对最终速度的影响将会小得多,因此我们稍后可以专注于它们。事实上,由于它们是list对象,除了您将在“Cython 和 numpy”中看到的,用numpy数组替换list对象将带来小的速度优势外,我们实际上无法加速它们的访问。

为了更好地理解黄色区域,您可以展开每一行。在图 7-3 中,我们可以看到为了创建output列表,我们迭代了zs的长度,构建了新的 Python 对象,这些对象由 Python 虚拟机进行引用计数。尽管这些调用很昂贵,但它们实际上不会影响此函数的执行时间。

要改善函数的执行时间,我们需要开始声明涉及昂贵内部循环的对象类型。这些循环可以减少相对昂贵的回调到 Python 虚拟机,从而节省时间。

总的来说,可能消耗最多 CPU 时间的是这些行:

  • 在紧密的内部循环中

  • 解引用listarraynp.array的项目

  • 执行数学运算

图 7-3. Python 代码背后的 C 代码
提示

如果您不知道哪些行被执行频率最高,使用性能分析工具——line_profiler,详见“使用 line_profiler 进行逐行测量”,将是最合适的选择。您将了解哪些行被执行得最频繁,以及哪些行在 Python 虚拟机内部花费最多,因此您将清楚地知道需要专注于哪些行来获得最佳的速度增益。

添加一些类型注解

图 7-2 显示我们函数的几乎每一行都在回调到 Python 虚拟机。我们所有的数值工作也在回调到 Python,因为我们使用了更高级别的 Python 对象。我们需要将这些转换为本地 C 对象,然后,在进行数值编码后,需要将结果转换回 Python 对象。

在 示例 7-7 中,我们看到如何使用 cdef 语法添加一些原始类型。

注意

需要注意的是,这些类型只有 Cython 能理解,而不是 Python。Cython 使用这些类型将 Python 代码转换为 C 对象,这些对象不需要回调到 Python 栈;这意味着操作速度更快,但失去了灵活性和开发速度。

我们添加的类型如下:

  • int 表示带符号整数

  • unsigned int 表示只能为正数的整数

  • double complex 表示双精度复数

cdef 关键字允许我们在函数体内声明变量。这些必须在函数顶部声明,因为这是 C 语言规范的要求。

示例 7-7. 添加原始 C 类型,通过在 C 中执行更多工作而不是通过 Python 虚拟机运行,来使我们的编译函数开始运行更快
def calculate_z(int maxiter, zs, cs):
    """Calculate output list using Julia update rule"""
    cdef unsigned int i, n
    cdef double complex z, c
    output = [0] * len(zs)
    for i in range(len(zs)):
        n = 0
        z = zs[i]
        c = cs[i]
        while n < maxiter and abs(z) < 2:
            z = z * z + c
            n += 1
        output[i] = n
    return output
注意

在添加 Cython 注释时,你正在向 .pyx 文件添加非 Python 代码。这意味着你失去了在解释器中开发 Python 的交互性。对于那些熟悉在 C 中编码的人,我们回到了代码-编译-运行-调试的循环中。

你可能会想知道是否可以为我们传入的列表添加类型注释。我们可以使用 list 关键字,但对于这个示例没有实际效果。list 对象仍然需要在 Python 级别进行询问以获取其内容,这是非常慢的。

在给一些原始对象分配类型的行为反映在 图 7-4 的注释输出中。关键是,第 11 和 12 行——我们最频繁调用的两行——现在已经从黄色变成白色,表明它们不再回调到 Python 虚拟机。相比于之前的版本,我们可以预期有很大的加速。

图 7-4. 我们的第一个类型注释

编译后,这个版本完成所需的时间为 0.49 秒。仅对函数进行少量更改后,我们的运行速度比原始 Python 版本快了 15 倍。

需要注意的是,我们之所以获得速度提升的原因是因为更多频繁执行的操作被推送到了 C 级别——在这种情况下,对 zn 的更新。这意味着 C 编译器可以优化较低级别函数在表示这些变量的字节上操作的方式,而不调用相对较慢的 Python 虚拟机。

正如本章早前所述,复数的 abs 涉及计算实部和虚部平方和的平方根。在我们的测试中,我们希望查看结果的平方根是否小于 2。与其计算平方根,我们可以将比较式的另一边平方,将 < 2 转换为 < 4。这样避免了最后计算 abs 函数时需要计算平方根。

本质上,我们从以下内容开始

c . r e a l 2 + c . i m a g 2 < 4

我们已经简化了操作为

c . r e a l 2 + c . i m a g 2 < 4

如果我们在以下代码中保留了 sqrt 操作,仍然会看到执行速度的提升。优化代码的一个秘诀是尽量减少其工作量。通过考虑函数的最终目的而消除一个相对昂贵的操作,使得 C 编译器可以专注于其擅长的部分,而不是试图推测程序员的最终需求。

编写等效但更专业化的代码来解决相同的问题被称为强度降低。您牺牲了更差的灵活性(可能还有更差的可读性),换取更快的执行速度。

这种数学展开导致 示例 7-8,其中我们用一行简化的数学公式替换了相对昂贵的 abs 函数。

示例 7-8. 通过 Cython 扩展 abs 函数
def calculate_z(int maxiter, zs, cs):
    """Calculate output list using Julia update rule"""
    cdef unsigned int i, n
    cdef double complex z, c
    output = [0] * len(zs)
    for i in range(len(zs)):
        n = 0
        z = zs[i]
        c = cs[i]
        while n < maxiter and (z.real * z.real + z.imag * z.imag) < 4:
            z = z * z + c
            n += 1
        output[i] = n
    return output

通过对代码进行注释,我们看到第 10 行的 while(图 7-5)变得更加黄色——看起来它可能在做更多的工作而不是更少。目前不清楚我们将获得多少速度增益,但我们知道这一行被调用了超过 3000 万次,因此我们预计会有很大的改进。

这个改变产生了显著效果——通过减少内部循环中 Python 调用的数量,大大降低了函数的计算时间。这个新版本仅需 0.19 秒完成,比原始版本快了惊人的 40 倍。无论如何,看到什么就取经验,但测量来测试您所有的更改!

图 7-5. 扩展数学运算以获取最终优势
注意

Cython 支持多种编译到 C 的方法,其中一些比这里描述的全类型注释方法更简单。如果您希望更轻松地开始使用 Cython,应该先熟悉 纯 Python 模式,并查看 pyximport,以便为同事们引入 Cython 提供便利。

对于这段代码的最后可能改进,我们可以禁用列表中每个解引用的边界检查。边界检查的目标是确保程序不会访问超出分配数组的数据——在 C 语言中,不小心访问数组边界之外的内存会导致意外结果(很可能是段错误!)。

默认情况下,Cython 会保护开发人员免受意外超出列表限制的影响。这种保护会消耗一点 CPU 时间,但它发生在我们函数的外循环中,因此总体上不会占用太多时间。通常情况下,禁用边界检查是安全的,除非您正在执行自己的数组地址计算,否则您必须小心保持在列表的边界内。

Cython 有一组可以用各种方式表示的标志。最简单的方法是将它们作为单行注释添加到.pyx文件的开头。也可以使用装饰器或编译时标志来更改这些设置。要禁用边界检查,我们在.pyx文件的开头的注释中添加了一个 Cython 的指令:

#cython: boundscheck=False
def calculate_z(int maxiter, zs, cs):

如上所述,禁用边界检查只会节省一点时间,因为它发生在外循环中,而不是内循环中,后者更昂贵。对于这个例子,它不会再节省我们任何时间。

提示

如果您的 CPU 绑定代码处于频繁解引用项的循环中,请尝试禁用边界检查和包裹检查。

Cython 和 numpy

list对象(有关背景,请参阅第三章)对每个解引用都有开销,因为它们引用的对象可能出现在内存中的任何位置。相比之下,array对象在连续的 RAM 块中存储原始类型,这样可以更快地寻址。

Python 有array模块,为基本的原始类型(包括整数、浮点数、字符和 Unicode 字符串)提供了 1D 存储。NumPy 的numpy.array模块允许多维存储和更广泛的原始类型,包括复数。

当以可预测的方式遍历array对象时,可以指示编译器避免要求 Python 计算适当的地址,而是直接通过移动到其内存地址中的下一个原始项目来处理序列。由于数据是按照连续块布局的,因此通过使用偏移量来计算 C 中下一个项目的地址要比要求 CPython 计算相同结果要容易得多,后者需要缓慢调用回虚拟机。

请注意,如果您运行以下numpy版本没有任何 Cython 注释(也就是说,如果您只是将其作为普通的 Python 脚本运行),它将花费大约 21 秒的时间来运行——远远超过普通的 Pythonlist版本,后者大约需要 8 秒。这种减速是因为在numpy列表中解引用单个元素的开销——即使对于初学者来说,这可能感觉像处理操作的直观方式,但numpy从未设计用于这种方式。通过编译代码,我们可以消除这种开销。

Cython 有两种特殊的语法形式。 旧版本的 Cython 有一个用于numpy数组的特殊访问类型,但是最近引入了通过memoryview来实现通用缓冲区接口协议——这允许对实现了缓冲区接口的任何对象进行相同的低级访问,包括numpy数组和 Python 数组。

缓冲区接口的另一个优点是,内存块可以轻松地与其他 C 库共享,无需将它们从 Python 对象转换为另一种形式。

示例 7-9 中的代码块看起来有点像原始实现,只是我们添加了memoryview注释。 函数的第二个参数是double complex[:] zs,这意味着我们使用缓冲区协议指定了一个双精度complex对象,该协议使用[]指定,其中包含由单冒号:指定的一维数据块。

示例 7-9. Julia 计算函数的numpy版本添加了注释
# cythonfn.pyx
import numpy as np
cimport numpy as np

def calculate_z(int maxiter, double complex[:] zs, double complex[:] cs):
    """Calculate output list using Julia update rule"""
    cdef unsigned int i, n
    cdef double complex z, c
    cdef int[:] output = np.empty(len(zs), dtype=np.int32)
    for i in range(len(zs)):
        n = 0
        z = zs[i]
        c = cs[i]
        while n < maxiter and (z.real * z.real + z.imag * z.imag) < 4:
            z = z * z + c
            n += 1
        output[i] = n
    return output

除了使用缓冲区注释语法指定输入参数之外,我们还使用emptyoutput变量添加了注释,通过empty将其分配给 1D numpy数组。 调用empty将分配一块内存块,但不会使用合理的值初始化内存,因此它可能包含任何内容。 我们将在内部循环中重写此数组的内容,因此我们不需要使用默认值重新分配它。 这比使用默认值分配和设置数组内容略快。

我们还通过使用更快、更明确的数学版本扩展了对abs的调用。 这个版本运行时间为 0.18 秒——比示例 7-8 中纯 Python Julia 示例的原始 Cython 版本稍快一些。 纯 Python 版本在每次解引用 Python complex对象时都会有一些开销,但这些解引用发生在外部循环中,因此不会占用太多执行时间。 在外部循环之后,我们制作了这些变量的本机版本,它们以“C 速度”运行。 对于这个numpy示例和以前的纯 Python 示例的内部循环都在相同的数据上执行相同的工作,因此时间差异由外部循环解引用和创建output数组所解释。

供参考,如果我们使用前面的代码但不扩展abs数学,则 Cython 化结果需要 0.49 秒。 这个结果使它与早期等效的纯 Python 版本的运行时间相同。

在一台计算机上使用 OpenMP 并行化解决方案

作为这个版本代码演变的最后一步,让我们来看看使用 OpenMP C++扩展并行化我们的尴尬并行问题。 如果您的问题符合这种模式,您可以快速利用计算机中的多个核心。

开放多处理(OpenMP 是一个明确定义的跨平台 API,支持 C、C++ 和 Fortran 的并行执行和内存共享。它内置于大多数现代 C 编译器中,如果 C 代码编写得当,那么并行化将在编译器级别发生,因此对于开发者来说工作量相对较小,通过 Cython 实现。

使用 Cython,可以通过使用prange(并行范围)操作符,并将-fopenmp编译器指令添加到setup.py来添加 OpenMP。在prange循环中进行的工作可以并行执行,因为我们禁用了(GIL)。GIL 保护对 Python 对象的访问,防止多个线程或进程同时访问同一内存,这可能导致数据损坏。通过手动禁用 GIL,我们断言我们不会破坏自己的内存。在执行此操作时要小心,并尽可能保持代码简单,以避免细微的 bug。

支持prange的代码修改版本在示例 7-10 中展示。with nogil:指定禁用 GIL 的代码块;在此块内,我们使用prange来启用 OpenMP 并行for循环,独立计算每个i

警告

当禁用全局解释器锁(GIL)时,我们必须操作常规的 Python 对象(如列表);我们只能操作原始对象和支持memoryview接口的对象。如果我们并行操作普通的 Python 对象,那么我们将不得不解决 GIL 故意避免的相关内存管理问题。Cython 并不阻止我们操纵 Python 对象,但如果你这样做,只会带来痛苦和混乱!

示例 7-10. 添加prange以启用使用 OpenMP 进行并行化
# cythonfn.pyx
from cython.parallel import prange
import numpy as np
cimport numpy as np

def calculate_z(int maxiter, double complex[:] zs, double complex[:] cs):
    """Calculate output list using Julia update rule"""
    cdef unsigned int i, length
    cdef double complex z, c
    cdef int[:] output = np.empty(len(zs), dtype=np.int32)
    length = len(zs)
    with nogil:
        for i in prange(length, schedule="guided"):
            z = zs[i]
            c = cs[i]
            output[i] = 0
            while output[i] < maxiter and (z.real * z.real + z.imag * z.imag) < 4:
                z = z * z + c
                output[i] += 1
    return output

要编译cythonfn.pyx,我们必须修改setup.py脚本,如示例 7-11 所示。我们告诉它在编译期间使用-fopenmp作为参数通知 C 编译器启用 OpenMP,并链接 OpenMP 库。

示例 7-11. 为 Cython 的 setup.py 添加 OpenMP 编译器和链接器标志
#setup.py
from distutils.core import setup
from distutils.extension import Extension
import numpy as np

ext_modules = [Extension("cythonfn",
                         ["cythonfn.pyx"],
                         extra_compile_args=['-fopenmp'],
                         extra_link_args=['-fopenmp'])]

from Cython.Build import cythonize
setup(ext_modules=cythonize(ext_modules,
                            compiler_directives={"language_level": "3"},),
      include_dirs=[np.get_include()])

使用 Cython 的prange,我们可以选择不同的调度方法。使用static,工作负载均匀分布在可用的 CPU 上。我们的某些计算区域时间昂贵,而某些则便宜。如果我们请求 Cython 使用static在 CPU 上均匀调度工作块,则某些区域的结果将比其他区域更快完成,并且这些线程将会空闲。

dynamicguided调度选项都试图通过在运行时动态分配更小的工作块来缓解这个问题,以便在工作负载计算时间变化时更均匀地分配 CPU。正确的选择取决于工作负载的性质。

引入 OpenMP 并使用 schedule="guided",我们将执行时间降至约 0.05 秒——guided 调度将动态分配工作,因此更少的线程将等待新的工作。

对于这个示例,我们也可以通过使用 #cython: boundscheck=False 禁用边界检查,但这不会改善我们的运行时间。

Numba

Numba 是 Continuum Analytics 推出的一个即时编译器,专门用于处理 numpy 代码,它通过 LLVM 编译器在运行时编译代码(是像我们之前的示例中使用 g++gcc++ 那样预先编译)。它不需要预编译过程,因此当您针对新代码运行它时,它会为您的硬件编译每个带有注释的函数。美妙之处在于,您只需提供一个装饰器告诉它应该关注哪些函数,然后让 Numba 接管。它旨在运行所有标准的 numpy 代码。

自本书第一版以来,Numba 已经迅速发展。现在它非常稳定,因此如果你使用 numpy 数组并且有非向量化的代码,需要迭代多个项目,Numba 应该能为你带来快速而无痛的优势。Numba 不绑定外部的 C 库(Cython 可以做到),但它可以自动为 GPU 生成代码(Cython 不能做到)。

使用 Numba 的一个缺点是工具链——它使用 LLVM,而 LLVM 有许多依赖项。我们建议您使用 Continuum 的 Anaconda 发行版,因为它提供了所有内容;否则,在新环境中安装 Numba 可能会非常耗时。

示例 7-12 展示了将 @jit 装饰器添加到我们核心 Julia 函数的过程。这就是所需的全部操作;导入 numba 意味着 LLVM 机制在执行时会在后台编译这个函数。

示例 7-12. 将@jit装饰器应用于一个函数
from numba import jit
...
@jit()
def calculate_z_serial_purepython(maxiter, zs, cs, output):

如果去除 @jit 装饰器,这只是在 Python 3.7 中运行的 Julia 演示的 numpy 版本,需要 21 秒。添加 @jit 装饰器将执行时间降至 0.75 秒。这与我们使用 Cython 实现的结果非常接近,但不需要所有的注释工作。

如果我们在同一个 Python 会话中第二次运行相同的函数,它的运行速度会更快,仅为 0.47 秒——如果参数类型相同,则无需在第二次通过编译目标函数,因此整体执行速度更快。在第二次运行时,Numba 的结果与我们之前获得的使用 numpy 的 Cython 结果相当(所以它和 Cython 一样快,几乎没有额外工作!)。PyPy 有相同的预热要求。

如果您想了解 Numba 提供的另一个视角,请参阅 “Numba”,其中核心开发者 Valentin Haenel 谈到了 @jit 装饰器,查看原始 Python 源代码,并进一步探讨了并行选项以及纯 Python 编译互操作性的 typed Listtyped Dict

就像 Cython 一样,我们可以使用prange添加 OpenMP 并行化支持。示例 7-13 扩展了装饰器以要求nopythonparallelnopython说明如果 Numba 无法编译所有代码,将会失败。没有这个参数,Numba 可能会悄悄地回退到一个较慢的 Python 模式;你的代码会运行正确,但是不会看到任何加速效果。添加parallel可以启用对prange的支持。这个版本将一般的运行时间从 0.47 秒降低到 0.06 秒。目前 Numba 不支持 OpenMP 调度选项(而且使用 Cython,guided调度器在这个问题上运行稍快),但我们预计未来版本将会增加支持。

示例 7-13. 使用prange添加并行化
@jit(nopython=False, parallel=True)
def calculate_z(maxiter, zs, cs, output):
    """Calculate output list using Julia update rule"""
    for i in prange(len(zs)):
        n = 0
        z = zs[i]
        c = cs[i]
        while n < maxiter and (z.real*z.real + z.imag*z.imag) < 4:
            z = z * z + c
            n += 1
        output[i] = n

在使用 Numba 进行调试时,有用的是注意你可以要求 Numba 显示函数调用的中间表示和类型。在示例 7-14 中,我们可以看到calculate_z接受一个int64和三个array类型。

示例 7-14. 调试推断类型
print(calculate_z.inspect_types())
# calculate_z (int64, array(complex128, 1d, C),
               array(complex128, 1d, C), array(int32, 1d, C))

示例 7-15 展示了从调用inspect_types()得到的持续输出,其中每行编译代码都增加了类型信息。如果你发现无法使nopython=True工作,这个输出非常宝贵;在这里,你可以发现 Numba 无法识别你的代码的地方。

示例 7-15. 查看来自 Numba 的中间表示
...
def calculate_z(maxiter, zs, cs, output):

    # --- LINE 14 ---

    """Calculate output list using Julia update rule"""

    # --- LINE 15 ---
    #   maxiter = arg(0, name=maxiter)  :: int64
    #   zs = arg(1, name=zs)  :: array(complex128, 1d, C)
    #   cs = arg(2, name=cs)  :: array(complex128, 1d, C)
    #   output = arg(3, name=output)  :: array(int32, 1d, C)
    #   jump 2
    # label 2
    #   $2.1 = global(range: <class 'range'>)  :: Function(<class 'range'>)
...

Numba 是一个强大的即时编译器,现在正在成熟。不要期望一次成功的魔术——你可能需要审视生成的代码来弄清楚如何使你的代码在nopython模式下编译。一旦解决了这个问题,你可能会看到不错的收获。你最好的方法是将当前的代码分解成小的(<10 行)和离散的函数,并逐个解决它们。不要试图将一个大函数抛到 Numba 中;如果你只有小而离散的代码块需要逐个审查,你可以更快地调试这个过程。

使用 Numba 为 Pandas 编译 NumPy

在“Pandas”中,我们讨论了使用普通最小二乘法解决 Pandas DataFrame 中 10 万行数据的斜率计算任务。通过使用 Numba,我们可以将该方法的速度提高一个数量级。

我们可以取出之前使用的ols_lstsq_raw函数,并像示例 7-16 中所示装饰为numba.jit,生成一个编译版本。请注意nopython=True参数——这将强制 Numba 在传入不理解的数据类型时引发异常,否则它会悄悄地回退到纯 Python 模式。如果我们传入 Pandas Series,我们不希望它运行正确但速度慢;我们希望得到通知,表明我们传入了错误的数据。在这个版本中,Numba 只能编译 NumPy 数据类型,不能编译像 Series 这样的 Pandas 类型。

示例 7-16. 在 Pandas DataFrame 上使用numpy解决普通最小二乘法
def ols_lstsq_raw(row):
    """Variant of `ols_lstsq` where row is a numpy array (not a Series)"""
    X = np.arange(row.shape[0])
    ones = np.ones(row.shape[0])
    A = np.vstack((X, ones)).T
    m, c = np.linalg.lstsq(A, row, rcond=-1)[0]
    return m

# generate a Numba compiled variant
ols_lstsq_raw_values_numba = jit(ols_lstsq_raw, nopython=True)

results = df.apply(ols_lstsq_raw_values_numba, axis=1, raw=True)

第一次调用此函数时,我们会得到预期的短延迟,因为函数正在编译;处理 10 万行需要 2.3 秒,包括编译时间。后续调用处理 10 万行非常快——未编译的ols_lstsq_raw每 10 万行需要 5.3 秒,而使用 Numba 后只需要 0.58 秒。这几乎是十倍的加速!

PyPy

PyPy是 Python 语言的另一种实现,包含一个追踪即时编译器;它与 Python 3.5+兼容。通常,它落后于最新的 Python 版本;在撰写第二版时,Python 3.7 是标准版本,而 PyPy 支持最多到 Python 3.6。

PyPy 是 CPython 的即插即用替代品,并提供所有内置模块。该项目包括 RPython 翻译工具链,用于构建 PyPy(也可以用于构建其他解释器)。PyPy 中的 JIT 编译器非常有效,几乎不需要或不需要您付出任何努力就可以看到很好的速度提升。查看“PyPy 用于成功的 Web 和数据处理系统(2014 年)”了解一个大型 PyPy 部署成功故事。

PyPy 在不修改的情况下运行我们的纯 Python Julia 演示。使用 CPython 需要 8 秒,而使用 PyPy 只需要 0.9 秒。这意味着 PyPy 实现了与示例 7-8 中的 Cython 示例非常接近的结果,而毫无努力——这非常令人印象深刻!正如我们在 Numba 讨论中观察到的那样,如果计算在同一个会话中再次运行,则第二次及后续运行比第一次运行更快,因为它们已经编译过了。

通过扩展数学并去除对abs的调用,PyPy 运行时降至 0.2 秒。这相当于使用纯 Python 和numpy的 Cython 版本,而无需任何工作!请注意,这个结果只在没有使用numpy与 PyPy 时才成立。

PyPy 支持所有内置模块的事实很有趣——这意味着multiprocessing在 CPython 中的运行方式也可以在 PyPy 中使用。如果您遇到可以使用内置模块并且可以使用multiprocessing并行运行的问题,那么您可以期望所有可能希望获得的速度增益都将可用。

PyPy 的速度随时间而演变。speed.pypy.org上较旧的图表会让您了解 PyPy 的成熟度。这些速度测试反映了各种用例,而不仅仅是数学运算。显然,PyPy 提供比 CPython 更快的体验。

图 7-6. 每个新版本的 PyPy 都提供了速度改进。

垃圾回收差异

PyPy 使用不同类型的垃圾收集器比 CPython,这可能会导致代码的一些不明显的行为变化。而 CPython 使用引用计数,PyPy 使用了一种修改后的标记-清除方法,可能会在很长时间后清理未使用的对象。两者都是 Python 规范的正确实现;你只需要意识到在切换时可能需要进行代码修改。

在 CPython 中看到的一些编码方法依赖于引用计数器的行为,特别是在没有显式文件关闭的情况下打开并写入文件时的文件刷新。使用 PyPy 相同的代码将运行,但对文件的更新可能会在下次垃圾收集器运行时稍后刷新到磁盘上。在 PyPy 和 Python 中都有效的另一种形式是使用with来打开并自动关闭文件的上下文管理器。PyPy 网站上的Differences Between PyPy and CPython page列出了详细信息。

运行 PyPy 和安装模块

如果您从未运行过另一种 Python 解释器,则可以从一个简短的示例中受益。假设您已经下载并解压了 PyPy,现在将有一个包含bin目录的文件夹结构。按照 Example 7-17 中显示的方式运行它以启动 PyPy。

示例 7-17. 运行 PyPy 以查看它是否实现了 Python 3.6
...
$ pypy3
Python 3.6.1 (784b254d6699, Apr 14 2019, 10:22:42)
[PyPy 7.1.1-beta0 with GCC 6.2.0 20160901] on linux
Type "help", "copyright", "credits", or "license" for more information.
And now for something completely different
...

请注意,PyPy 7.1 作为 Python 3.6 运行。现在我们需要设置pip,并且我们想要安装 IPython。在 Example 7-18 中显示的步骤与您可能在没有现有分发或包管理器的帮助下安装pip时使用的 CPython 相同。请注意,当运行 IPython 时,我们得到与在前面示例中运行pypy3时看到的相同的构建号。

您可以看到 IPython 与 CPython 一样运行 PyPy,并使用%run语法在 IPython 中执行 Julia 脚本以获得 0.2 秒的运行时间。

示例 7-18. 为 PyPy 安装pip以安装第三方模块,如 IPython
...
$ pypy3 -m ensurepip
Collecting setuptools
Collecting pip
Installing collected packages: setuptools, pip
Successfully installed pip-9.0.1 setuptools-28.8.0

$ pip3 install ipython
Collecting ipython

$ ipython
Python 3.6.1 (784b254d6699, Apr 14 2019, 10:22:42)
Type 'copyright', 'credits', or 'license' for more information
IPython 7.8.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: %run julia1_nopil_expanded_math.py
Length of x: 1000
Total elements: 1000000
calculate_z_serial_purepython took 0.2143106460571289 seconds
Length of x: 1000
Total elements: 1000000
calculate_z_serial_purepython took 0.1965022087097168 seconds
...

请注意,PyPy 支持像numpy这样需要 C 绑定的项目,通过 CPython 扩展兼容层cpyext,但这会增加 4-6 倍的开销,通常使numpy变得太慢。如果你的代码大部分是纯 Python,并且只有少量调用numpy,你仍然可能会看到明显的整体收益。如果你的代码,像 Julia 示例一样,对numpy进行了多次调用,那么它将运行得明显慢。这里使用numpy数组的 Julia 基准运行速度比使用 CPython 运行时慢 6 倍。

如果您需要其他软件包,它们应该能够安装,多亏了 cpyext 兼容模块,这基本上是 PyPy 版本的 python.h。它处理 PyPy 和 CPython 不同的内存管理需求;然而,每个管理调用的成本为 4-6×,因此 numpy 的速度优势可能会被这些开销抵消。一个名为 HPy(以前称为 PyHandle)的新项目旨在通过提供更高级的对象句柄来消除这些开销,该句柄不与 CPython 的实现绑定,并且可以与 Cython 等其他项目共享。

如果您想了解 PyPy 的性能特征,请查看 vmprof 轻量级抽样分析器。它是线程安全的,并支持基于 Web 的用户界面。

PyPy 的另一个缺点是它可能使用大量内存。每个发布版在这方面都更好,但实际上可能比 CPython 使用更多内存。尽管如此,内存是相当便宜的,因此有意将其用于性能提升是有意义的。有些用户在使用 PyPy 时还报告了更低的内存使用情况。如果这对您很重要,请根据代表性数据进行实验。

速度改进摘要

总结之前的结果,在 Table 7-1 中我们看到,对于纯 Python 数学代码样本,PyPy 大约比不经过代码更改的 CPython 快 9 倍,如果简化 abs 行,它甚至更快。在这两种情况下,Cython 都比 PyPy 运行得更快,但需要有注释的代码,这会增加开发和支持的工作量。

Table 7-1. Julia(无 numpy)结果

速度
CPython 8.00 秒
Cython 0.49 秒
Cython 在扩展数学上 0.19 秒
PyPy 0.90 秒
PyPy 在扩展数学上 0.20 秒

使用 numpy 的 Julia 求解器可以探索 OpenMP。在 Table 7-2 中,我们可以看到,无论是 Cython 还是 Numba,在扩展数学运算中都比非 numpy 版本运行得更快。当我们加入 OpenMP 时,无论是 Cython 还是 Numba 都可以进一步提高速度,而额外编码的工作量非常少。

Table 7-2. Julia(使用 numpy 和扩展数学运算)结果

速度
CPython 21.00 秒
Cython 0.18 秒
Cython 和 OpenMP “guided” 0.05 秒
Numba(第二次及后续运行) 0.17 秒
Numba 和 OpenMP 0.06 秒

对于纯 Python 代码来说,PyPy 是显而易见的首选。对于 numpy 代码来说,Numba 是一个很好的首选。

何时使用每种技术

如果您正在处理数字项目,那么这些技术都可能对您有用。Table 7-3 总结了主要选项。

Table 7-3. 编译器选项

Cython Numba PyPy
成熟 Y Y Y
广泛使用 Y
numpy 支持 Y Y Y
不会破坏现有代码 Y Y
需要 C 知识 Y
支持 OpenMP Y Y

Numba 可能会为您带来快速的收获,付出的努力很少,但它也有一些限制,可能会导致它在您的代码上表现不佳。它也是一个相对年轻的项目。

Cython 可能为最广泛的问题集提供了最佳结果,但它确实需要更多的投入,并且由于将 Python 与 C 注释混合,存在额外的“支持税”。

如果您不使用numpy或其他难以移植的 C 扩展,PyPy 是一个强有力的选择。

如果您部署生产工具,您可能希望坚持使用已知的工具 —— Cython 应该是您的主要选择,并且您可能想查看 “Making Deep Learning Fly with RadimRehurek.com (2014)”。PyPy 在生产环境中也被使用(参见 “PyPy for Successful Web and Data Processing Systems (2014)”)。

如果您处理轻量级数值需求,请注意 Cython 的缓冲区接口接受array.array矩阵 —— 这是一种向 Cython 传递数据块进行快速数值处理的简便方法,而不必将numpy作为项目依赖添加进来。

总体而言,Numba 正在成熟,是一个有前途的项目,而 Cython 已经非常成熟。PyPy 现在被认为相当成熟,应该绝对被评估为长时间运行的进程。

在由 Ian 主持的班级中,一个能干的学生实现了 Julia 算法的 C 版本,并且失望地发现它比他的 Cython 版本执行得更慢。事实证明,他在 64 位机器上使用 32 位浮点数 —— 这比 64 位机器上的 64 位双精度浮点数运行更慢。尽管这位学生是一位优秀的 C 程序员,但他不知道这可能导致速度成本。他改变了他的代码,尽管这个手工编写的 C 版本比自动生成的 Cython 版本短得多,但速度大致相同。编写原始的 C 版本、比较其速度以及找出如何修复它的过程比一开始就使用 Cython 花费了更长的时间。

这只是一个轶事;我们并不认为 Cython 会生成最佳代码,而且有能力的 C 程序员可能会找出如何使他们的代码比 Cython 生成的版本运行更快的方法。值得注意的是,手写的 C 比转换的 Python 更快这一假设并不安全。您必须始终进行基准测试并根据证据做出决策。C 编译器在将代码转换为相当高效的机器代码方面非常出色,而 Python 在让您用易于理解的语言表达问题方面也非常出色 —— 合理地结合这两种力量。

其他即将推出的项目

PyData 编译器页面 列出了一组高性能和编译器工具。

Pythran是一个面向使用numpy的科学家的 AOT 编译器。通过少量的注解,它可以将 Python 数值代码编译成更快的二进制代码——它的加速效果与Cython非常相似,但工作量要少得多。除了其他功能外,它总是释放 GIL 并且可以使用 SIMD 指令和 OpenMP。像 Numba 一样,它不支持类。如果你在 Numpy 中有紧密的局部循环,Pythran 绝对值得评估。相关的 FluidPython 项目旨在使 Pythran 编写更加简单,并提供 JIT 能力。

Transonic试图通过一个接口将 Cython、Pythran、Numba 以及可能的其他编译器整合在一起,以便快速评估多个编译器,而无需重写代码。

ShedSkin是一个面向非科学纯 Python 代码的 AOT 编译器。它不支持numpy,但如果你的代码是纯 Python 的,ShedSkin 可以产生类似于 PyPy(不使用numpy)的加速效果。它支持 Python 2.7,并部分支持 Python 3.x

PyCUDAPyOpenCL为 Python 提供了 CUDA 和 OpenCL 的绑定,可以直接访问 GPU。这两个库已经非常成熟,并支持 Python 3.4+。

Nuitka是一个 Python 编译器,旨在成为传统 CPython 解释器的替代品,并支持创建编译后的可执行文件。它支持完整的 Python 3.7,尽管在我们的测试中,它对我们的纯 Python 数值测试并没有显著的速度提升。

我们的社区拥有多种编译选项,这是一种幸运。尽管它们都有各自的权衡,但它们也提供了强大的功能,使得复杂的项目能够充分利用 CPU 和多核架构的全部性能。

图形处理单元(GPU)

图形处理单元(GPU)因其加速算术密集型计算工作负载的能力而变得非常流行。最初设计用于处理 3D 图形的重型线性代数需求,GPU 特别适合解决易于并行化的问题。

有趣的是,如果仅看时钟速度,GPU 本身比大多数 CPU 要慢。这可能看起来有些反直觉,但正如我们在“计算单元”中讨论的那样,时钟速度只是硬件计算能力的一个衡量标准。GPU 在大规模并行任务方面表现出色,因为它们拥有惊人数量的计算核心。一般来说,CPU 通常有大约 12 个核心,而现代 GPU 有数千个核心。例如,在用于运行本节基准测试的机器上,AMD Ryzen 7 1700 CPU 每个核心运行速度为 3.2 GHz,而 NVIDIA RTX 2080 TI GPU 有 4,352 个核心,每个核心运行速度为 1.35 GHz。¹

这种令人难以置信的并行计算能力可以显著加快许多数值任务的速度。然而,在这些设备上编程可能非常困难。由于并行性的增加,需要考虑数据的局部性,并且这可能是速度提升的决定性因素。有许多工具可以在 Python 中编写本地 GPU 代码(也称为kernels),例如CuPy。然而,现代深度学习算法的需求已经推动了易于使用和直观的 GPU 新接口。

在易于使用的 GPU 数学库方面,TensorFlow 和 PyTorch 是两个领先者。我们将专注于 PyTorch,因为它易于使用且速度快。²

动态图:PyTorch

PyTorch是一个静态计算图张量库,特别适合用户,并且对于任何熟悉numpy的人来说,它具有非常直观的 API。此外,由于它是一个张量库,它具有与numpy相同的所有功能,还可以通过其静态计算图创建函数,并通过称为autograd的机制计算这些函数的导数。

注意

由于它与我们的讨论无关,PyTorch 的autograd功能被省略。然而,这个模块非常了不起,可以对由 PyTorch 操作组成的任意函数进行求导。它可以在任意值处实时进行,并且可以非常简单高效地完成。尽管这对你的工作可能也不相关,但我们建议学习autograd和自动微分,因为这确实是数值计算中的一个令人难以置信的进步。

通过静态计算图,我们指的是在 PyTorch 对象上执行操作时,会创建程序的动态定义,在执行时会在后台编译为 GPU 代码(就像从“JIT Versus AOT Compilers”中的 JIT 一样)。由于它是动态的,Python 代码的更改会自动反映在 GPU 代码的更改中,无需显式编译步骤。这极大地帮助了调试和交互性,与 TensorFlow 等静态图库相比。

在静态图中,如 TensorFlow,我们首先设置我们的计算,然后编译它。从那时起,我们的计算被固定在石头上,只能通过重新编译整个过程来进行更改。使用 PyTorch 的动态图,我们可以有条件地更改我们的计算图或者逐步构建它。这允许我们在代码中进行有条件的调试,或者在 IPython 的交互会话中与 GPU 进行互动。在处理复杂的基于 GPU 的工作负载时,灵活控制 GPU 的能力是一个完全改变游戏规则的因素。

作为库易用性和速度的示例,在示例 7-19 中,我们将 示例 6-9 的 numpy 代码改用 PyTorch 在 GPU 上运行。

示例 7-19. PyTorch 2D 扩散
import torch
from torch import (roll, zeros)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)

grid_shape = (640, 640)

def laplacian(grid):
    return (
        roll(grid, +1, 0)
        + roll(grid, -1, 0)
        + roll(grid, +1, 1)
        + roll(grid, -1, 1)
        - 4 * grid
    )

def evolve(grid, dt, D=1):
    return grid + dt * D * laplacian(grid)

def run_experiment(num_iterations):
    grid = zeros(grid_shape)

    block_low = int(grid_shape[0] * 0.4)
    block_high = int(grid_shape[0] * 0.5)
    grid[block_low:block_high, block_low:block_high] = 0.005

    grid = grid.cuda()  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
    for i in range(num_iterations):
        grid = evolve(grid, 0.1)
    return grid

1, 2

唯一需要的更改。

大部分工作都是在修改后的导入中完成的,我们将 numpy 更改为 torch。实际上,如果我们只想在 CPU 上运行优化后的代码,我们可以到此为止。³ 要使用 GPU,我们只需要将数据移动到 GPU 上,然后 torch 将自动将我们对该数据的所有计算编译成 GPU 代码。

如我们在图 7-7 中所见,这个小的代码改变给了我们惊人的加速。⁴ 对于一个 512 × 512 的网格,我们获得了 5.3× 的加速,对于一个 4,096 × 4,096 的网格,我们获得了 102× 的加速!有趣的是,GPU 代码似乎不像 numpy 代码那样受网格大小增加的影响。

比较 Numpy 和 PyTorch 在 CPU 和 GPU 上的性能(使用 NVIDIA RTX 2080TI)

图 7-7. PyTorch 对 numpy 性能的比较

这种加速是扩散问题可并行化程度的结果。正如我们之前所说,我们使用的 GPU 具有 4,362 个独立的计算核心。似乎一旦扩散问题被并行化,这些 GPU 核心中没有一个被充分利用。

警告

在测试 GPU 代码性能时,设置环境标志 CUDA_LAUNCH_BLOCKING=1 是很重要的。默认情况下,GPU 操作是异步运行的,允许更多操作被流水线化在一起,从而最大限度地减少 GPU 的总利用率并增加并行性。当启用异步行为时,我们可以保证只有在数据复制到另一个设备或发出 torch.cuda.synchronize() 命令时,计算才会完成。通过启用上述环境变量,我们可以确保计算在发出时完成,并且我们确实在测量计算时间。

基本 GPU 分析

验证我们使用 GPU 的具体利用率的一种方法是使用 nvidia-smi 命令来检查 GPU 的资源利用情况。我们最感兴趣的两个值是功率使用和 GPU 利用率:

$ nvidia-smi
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 440.44       Driver Version: 440.44       CUDA Version: 10.2     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  GeForce RTX 208...  Off  | 00000000:06:00.0 Off |                  N/A |
| 30%   58C    P2    96W / 260W |   1200MiB / 11018MiB |     95%      Default |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|    0     26329      C   .../.pyenv/versions/3.7.2/bin/python        1189MiB |
+-----------------------------------------------------------------------------+

GPU 利用率,在这里是 95%,是一个稍微误标的字段。它告诉我们在过去一秒钟内至少运行了一个核函数的百分比。因此,它并没有告诉我们我们使用了 GPU 的总计算能力的百分比,而是告诉我们有多少时间是空闲的。这是一个在调试内存传输问题和确保 CPU 提供足够工作给 GPU 时非常有用的测量。

另一方面,功耗是评估 GPU 计算能力使用情况的良好代理。作为经验法则,GPU 消耗的功率越大,当前执行的计算量就越大。如果 GPU 正在等待 CPU 的数据或仅使用了一半的可用核心,那么从最大值减少功耗。

另一个有用的工具是 gpustat。该项目通过比 nvidia-smi 更友好的界面提供了对 NVIDIA 许多统计数据的良好视图。

为了帮助理解您的 PyTorch 代码中具体导致减速的原因,该项目提供了一个特殊的性能分析工具。使用 python -m torch.utils.bottleneck 运行您的代码将显示 CPU 和 GPU 运行时统计信息,帮助您识别可能的优化部分。

GPU 的性能考虑

由于 GPU 是计算机上完全辅助的硬件部件,与 CPU 相比,其具有自己的体系结构,因此需要考虑许多特定于 GPU 的性能因素。

对于 GPU 来说,最大的速度考虑因素是从系统内存到 GPU 内存的数据传输时间。当我们使用 tensor.to(*DEVICE*) 时,我们触发了一个数据传输,可能需要一些时间,具体取决于 GPU 总线的速度和传输的数据量。

其他操作可能会触发传输。特别是,tensor.items()tensor.tolist() 在引入用于调试目的时经常引发问题。实际上,运行 tensor.numpy() 将 PyTorch 张量转换为 numpy 数组,这需要明确地从 GPU 复制数据,以确保您了解可能的惩罚。

例如,让我们在我们扩散代码的求解器循环中添加一个 grid.cpu() 调用:

    grid = grid.to(device)
    for i in range(num_iterations):
        grid = evolve(grid, 0.1)
        grid.cpu()

为了确保我们进行公平比较,我们还将在控制代码中添加 torch.cuda.synchronize(),以便我们仅仅测试从 CPU 复制数据的时间。除了通过触发从 GPU 到系统内存的数据传输来减慢您的代码之外,您的代码还会因为 GPU 将暂停本应在后台继续执行的代码而减慢。

这次对于 2,048 × 2,048 网格的代码修改使我们的代码减慢了 2.54×!尽管我们的 GPU 宣传的带宽为 616.0 GB/s,但这种额外开销会迅速累积起来。此外,还有其他与内存拷贝相关的开销。首先,我们正在为我们的代码执行的任何潜在流水线创建一个硬性停止。然后,因为我们不再进行流水线操作,我们在 GPU 上的数据必须全部同步出各个 CUDA 核的内存。最后,需要准备系统内存空间以接收来自 GPU 的新数据。

尽管这似乎是对我们的代码做了一个荒谬的补充,但这种情况经常发生。事实上,当涉及深度学习时,导致 PyTorch 代码运行变慢的最大因素之一是将训练数据从主机复制到 GPU 上。通常情况下,训练数据简直太大,无法完全放入 GPU 中,因此进行这些频繁的数据传输是不可避免的惩罚。

有方法可以减轻从 CPU 到 GPU 数据传输中的开销。首先,内存区域可以标记为pinned。这可以通过调用Tensor.pin_memory()方法来完成,该方法返回一个将 CPU 张量复制到内存的“页锁定”区域的副本。这个页锁定区域可以更快地复制到 GPU,并且可以异步复制,以避免干扰 GPU 正在进行的任何计算。在训练深度学习模型时,数据加载通常使用DataLoader类完成,该类方便地具有一个pin_memory参数,可以自动为所有训练数据执行此操作。⁵

使用“基本 GPU 性能分析”中提到的工具来对代码进行性能分析是最重要的一步。当您的代码大部分时间花在数据传输上时,您会看到低功耗、较小的 GPU 利用率(如nvidia-smi报告)、大部分时间花在to函数中(如bottleneck报告)。理想情况下,您将使用 GPU 可以支持的最大功率,并且利用率达到 100%。即使需要大量数据传输,比如训练大量图片的深度学习模型,这也是可能的!

注意

GPU 并不擅长同时运行多个任务。在启动需要大量使用 GPU 的任务时,请确保没有其他任务在使用 GPU,可以通过运行nvidia-smi来查看。然而,如果您正在运行图形环境,您可能别无选择,必须让您的桌面和 GPU 代码同时使用 GPU。

何时使用 GPU

我们已经看到 GPU 可以非常快速;然而,内存考虑因素可能对运行时产生严重影响。这似乎表明,如果您的任务主要需要线性代数和矩阵操作(如乘法、加法和傅里叶变换),那么 GPU 是一个非常好的工具。特别是如果计算可以在 GPU 上连续一段时间进行,然后再复制回系统内存。

作为需要大量分支的任务的示例,我们可以想象每一步计算都需要前一步结果的代码。如果我们比较使用 PyTorch 与使用 NumPy 运行 示例 7-20,我们会发现对于包含的示例,NumPy 一直更快(速度快 98%!)。这是合乎逻辑的,考虑到 GPU 的架构。虽然 GPU 可以同时运行更多任务,但每个任务在 GPU 上运行比在 CPU 上慢得多。这个示例任务一次只能运行一个计算,因此拥有多个计算核心并不会帮助;最好只有一个非常快速的核心。

示例 7-20. 高度分支任务
import torch

def task(A, target):
    """
 Given an int array of length N with values from (0, N] and a target value,
 iterates through the array, using the current value to find the next array
 item to look at, until we have seen a total value of at least `target`.
 Returns how many iterations until the value was reached.
 """
    result = 0
    i = 0
    N = 0
    while result < target:
        r = A[i]
        result += r
        i = A[i]
        N += 1
    return N

if __name__ == "__main__":
    N = 1000

    A_py = (torch.rand(N) * N).type(torch.int).to('cuda:0')
    A_np = A_py.cpu().numpy()

    task(A_py, 500)
    task(A_np, 500)

另外,由于 GPU 的有限内存,它不是需要处理极大量数据、对数据进行多次条件操作或更改数据的好工具。大多数用于计算任务的 GPU 具有约 12 GB 的内存,这对“大量数据”是一个显著限制。然而,随着技术的进步,GPU 内存的大小增加,因此希望这种限制在未来变得不那么严重。

评估是否使用 GPU 的一般步骤如下:

  1. 确保问题的内存使用适合于 GPU(在 “使用 memory_profiler 诊断内存使用” 中,我们探索了内存使用的分析)。

  2. 评估算法是否需要大量分支条件而不是矢量化操作。作为经验法则,numpy 函数通常非常适合矢量化,因此如果您的算法可以用 numpy 调用编写,您的代码可能会很好地矢量化!在运行 perf 时,您还可以检查 branches 的结果(如 “理解 perf” 中所述)。

  3. 评估需要在 GPU 和 CPU 之间移动多少数据。这里可以问一些问题:“在需要绘制/保存结果之前我可以做多少计算?”和“是否有时我的代码将不得不复制数据以运行一个我知道不兼容 GPU 的库?”

  4. 确保 PyTorch 支持您想要执行的操作!PyTorch 实现了大部分 numpy API,因此这不应该是问题。在大多数情况下,API 甚至是相同的,因此您根本不需要更改代码。但是,在某些情况下,PyTorch 可能不支持某些操作(例如处理复数)或 API 稍有不同(例如生成随机数)。

考虑这四点将有助于确保 GPU 方法是值得的。关于 GPU 何时比 CPU 更好没有硬性规则,但这些问题将帮助您获得一些直觉。然而,PyTorch 还使得将代码转换为使用 GPU 几乎无痛,因此即使只是评估 GPU 的性能,进入门槛也很低。

外部函数接口

有时自动解决方案并不尽如人意,您需要自己编写定制的 C 或 Fortran 代码。这可能是因为编译方法没有找到一些潜在的优化,或者因为您想利用 Python 中不可用的库或语言功能。在所有这些情况下,您都需要使用外部函数接口,这使您可以访问用另一种语言编写和编译的代码。

在本章的其余部分,我们将尝试使用外部库以与我们在 第六章 中所做的方式相同来解决二维扩散方程。[⁶](ch07.xhtml#idm46122415229448)此库的代码,如 示例 7-21 所示,可能代表您已安装的库或您编写的代码。我们将要研究的方法是将代码的小部分移到另一种语言中以进行非常有针对性的基于语言的优化的好方法。

示例 7-21. 解决二维扩散问题的样例 C 代码
void evolve(double in[][512], double out[][512], double D, double dt) {
    int i, j;
    double laplacian;
    for (i=1; i<511; i++) {
        for (j=1; j<511; j++) {
            laplacian = in[i+1][j] + in[i-1][j] + in[i][j+1] + in[i][j-1] \
                        - 4 * in[i][j];
            out[i][j] = in[i][j] + D * dt * laplacian;
        }
    }
}
注意

为了简化示例代码,我们将网格大小固定为 512 × 512。要接受任意大小的网格,您必须将 inout 参数作为双指针传入,并包含用于网格实际大小的函数参数。

要使用此代码,我们必须将其编译成创建 .so 文件的共享模块。我们可以使用 gcc(或任何其他 C 编译器)按照以下步骤执行此操作:

$ gcc -O3 -std=gnu11 -c diffusion.c
$ gcc -shared -o diffusion.so diffusion.o

我们可以将这个最终的共享库文件放在任何对我们的 Python 代码可访问的地方,但标准的 *nix 组织会将共享库存储在 /usr/lib/usr/local/lib 中。

ctypes

在 CPython 中最基本的外部函数接口是通过 ctypes 模块实现的。[⁷](ch07.xhtml#idm46122415064632)这个模块的基本特性有时会相当具有抑制性——您需要负责做所有事情,而且确保一切井井有条可能需要相当长的时间。我们的 ctypes 扩散代码中体现了这种额外的复杂性,如 示例 7-22 所示。

示例 7-22. ctypes 二维扩散代码
import ctypes

grid_shape = (512, 512)
_diffusion = ctypes.CDLL("diffusion.so")  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)

# Create references to the C types that we will need to simplify future code
TYPE_INT = ctypes.c_int
TYPE_DOUBLE = ctypes.c_double
TYPE_DOUBLE_SS = ctypes.POINTER(ctypes.POINTER(ctypes.c_double))

# Initialize the signature of the evolve function to:
# void evolve(int, int, double**, double**, double, double)
_diffusion.evolve.argtypes = [TYPE_DOUBLE_SS, TYPE_DOUBLE_SS, TYPE_DOUBLE,
                              TYPE_DOUBLE]
_diffusion.evolve.restype = None

def evolve(grid, out, dt, D=1.0):
    # First we convert the Python types into the relevant C types
    assert grid.shape == (512, 512)
    cdt = TYPE_DOUBLE(dt)
    cD = TYPE_DOUBLE(D)
    pointer_grid = grid.ctypes.data_as(TYPE_DOUBLE_SS)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
    pointer_out = out.ctypes.data_as(TYPE_DOUBLE_SS)

    # Now we can call the function
    _diffusion.evolve(pointer_grid, pointer_out, cD, cdt)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png)

1

这与导入 diffusion.so 库类似。要么这个文件位于标准系统路径中的一个,要么我们可以输入一个绝对路径。

2

gridout 都是 numpy 数组。

3

最终,我们已经准备好了所有必要的设置,并可以直接调用 C 函数。

我们首先要做的是“导入”我们的共享库。这通过 ctypes.CDLL 调用完成。在此行中,我们可以指定 Python 可访问的任何共享库(例如,ctypes-opencv 模块加载 libcv.so 库)。从中,我们得到一个 _diffusion 对象,其中包含共享库包含的所有成员。在这个示例中,diffusion.so 只包含一个名为 evolve 的函数,现在作为 _diffusion 对象的一个属性对我们可用。如果 diffusion.so 包含许多函数和属性,我们可以通过 _diffusion 对象访问它们所有。

然而,尽管 _diffusion 对象内部有 evolve 函数可用,Python 并不知道如何使用它。C 是静态类型的,函数具有非常具体的签名。要正确使用 evolve 函数,我们必须明确设置输入参数类型和返回类型。在与 Python 接口同时开发库或处理快速变化的库时,这可能变得非常繁琐。此外,由于 ctypes 无法检查您是否提供了正确的类型,如果出错,您的代码可能会悄无声息地失败或导致段错误!

此外,除了设置函数对象的参数和返回类型之外,我们还需要转换我们希望与之一起使用的任何数据(这称为类型转换)。我们发送给函数的每个参数都必须仔细转换为本地的 C 类型。有时这可能会变得非常棘手,因为 Python 对其变量类型非常宽松。例如,如果我们有 num1 = 1e5,我们必须知道这是一个 Python 的 float,因此我们应该使用 ctype.c_float。另一方面,对于 num2 = 1e300,我们必须使用 ctype.c_double,因为它会超出标准 C 的 float 范围。

话虽如此,numpy 提供了 .ctypes 属性,使其数组与 ctypes 易于兼容。如果 numpy 没有提供这种功能,我们将不得不初始化一个正确类型的 ctypes 数组,然后找到我们原始数据的位置,让新的 ctypes 对象指向它。

警告

除非您将要转换为 ctype 对象的对象实现了缓冲区(如 array 模块、numpy 数组、io.StringIO 等),否则您的数据将被复制到新对象中。在将 int 转换为 float 时,这对代码的性能影响不大。但是,如果您要转换一个非常长的 Python 列表,这可能会带来相当大的性能损失!在这些情况下,使用 array 模块或 numpy 数组,甚至使用 struct 模块构建自己的缓冲对象,将有所帮助。然而,这会影响代码的可读性,因为这些对象通常比其原生 Python 对应物不够灵活。

如果你需要向库发送一个复杂的数据结构,内存管理就会变得更加复杂。例如,如果你的库期望一个表示空间中点的 C struct,具有 xy 属性,你需要定义如下内容:

from ctypes import Structure

class cPoint(Structure):
    _fields_ = ("x", c_int), ("y", c_int)

在这一点上,你可以通过初始化一个 cPoint 对象(即 point = cPoint(10, 5))来开始创建 C 兼容的对象。这并不是一件很麻烦的工作,但可能会变得乏味,并导致一些脆弱的代码。如果发布了一个略微更改了结构的库的新版本会发生什么?这将使您的代码非常难以维护,并且通常会导致代码停滞,开发人员决定永远不升级正在使用的底层库。

因此,如果你已经对 C 语言有了很好的理解,并且希望能够调整接口的每个方面,使用 ctypes 模块是一个很好的选择。它具有很好的可移植性,因为它是标准库的一部分,如果你的任务很简单,它提供了简单的解决方案。只需小心,因为 ctypes 解决方案(以及类似的低级解决方案)的复杂性很快就会变得难以管理。

cffi

虽然意识到 ctypes 有时可能使用起来相当麻烦,cffi 尝试简化程序员使用的许多标准操作。它通过具有内部 C 解析器来理解函数和结构定义来实现这一点。

因此,我们可以简单地编写定义我们希望使用的库结构的 C 代码,然后 cffi 将为我们完成所有繁重的工作:它导入模块,并确保我们向结果函数指定了正确的类型。事实上,如果库的源代码是可用的,这项工作几乎可以是微不足道的,因为头文件(以 *.h 结尾的文件)将包含我们需要的所有相关定义。⁸ 示例 7-23 展示了二维扩散代码的 cffi 版本。

示例 7-23. cffi 二维扩散代码
from cffi import FFI, verifier

grid_shape = (512, 512)

ffi = FFI()
ffi.cdef(
    "void evolve(double **in, double **out, double D, double dt);"  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
)
lib = ffi.dlopen("../diffusion.so")

def evolve(grid, dt, out, D=1.0):
    pointer_grid = ffi.cast("double**", grid.ctypes.data)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
    pointer_out = ffi.cast("double**", out.ctypes.data)
    lib.evolve(pointer_grid, pointer_out, D, dt)

1

通常可以从你正在使用的库的手册或查看库的头文件获取这些定义的内容。

2

尽管我们仍然需要为了与我们的 C 模块一起使用而强制转换非本地的 Python 对象,但是对于有 C 经验的人来说,语法是非常熟悉的。

在前述代码中,我们可以将cffi的初始化视为两步操作。首先,我们创建一个FFI对象,并提供所有需要的全局 C 声明。这可能包括数据类型和函数签名。这些签名并不一定包含任何代码;它们只需要定义代码的外观。然后,我们可以使用dlopen导入包含函数实际实现的共享库。这意味着我们可以告诉FFI关于evolve函数的函数签名,然后加载两种不同的实现并将它们存储在不同的对象中(这对于调试和性能分析非常棒!)。

除了轻松导入共享的 C 库之外,cffi还允许您编写 C 代码,并使用verify函数动态编译它。这有许多即时的好处,例如,您可以轻松地将代码的小部分重写为 C 代码,而无需调用单独的 C 库大机制。或者,如果您希望使用某个库,但需要一些 C 语言粘合代码以使接口完美工作,您可以将其内联到您的cffi代码中,如示例 7-24 所示,使一切都集中在一个位置。此外,由于代码是动态编译的,您可以为每个需要编译的代码块指定编译指令。然而,请注意,每次运行verify函数以执行编译时都会有一次性的惩罚。

示例 7-24. cffi与内联 2D 扩散代码
ffi = FFI()
ffi.cdef(
    "void evolve(double **in, double **out, double D, double dt);"
)
lib = ffi.verify(
    r"""
void evolve(double in[][512], double out[][512], double D, double dt) {
    int i, j;
    double laplacian;
    for (i=1; i<511; i++) {
        for (j=1; j<511; j++) {
            laplacian = in[i+1][j] + in[i-1][j] + in[i][j+1] + in[i][j-1] \
                        - 4 * in[i][j];
            out[i][j] = in[i][j] + D * dt * laplacian;
        }
    }
}
""",
    extra_compile_args=["-O3"],  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
)

1

由于我们是即时编译此代码,我们还可以提供相关的编译标志。

verify功能的另一个好处是它与复杂的cdef语句很好地配合。例如,如果我们正在使用一个具有复杂结构的库,但只想使用其中的一部分,我们可以使用部分结构定义。为此,在ffi.cdef中的结构定义中添加...,并在稍后的verify中包含相关的头文件。

例如,假设我们正在使用一个包含结构如下的头文件complicated.h的库:

struct Point {
    double x;
    double y;
    bool isActive;
    char *id;
    int num_times_visited;
}

如果我们只关心xy属性,我们可以编写一些简单的cffi代码,只关注这些值:

from cffi import FFI

ffi = FFI()
ffi.cdef(r"""
 struct Point {
 double x;
 double y;
 ...;
 };
 struct Point do_calculation();
""")
lib = ffi.verify(r"""
 #include <complicated.h>
""")

然后,我们可以运行来自complicated.h库的do_calculation函数,并返回一个具有可访问的xy属性的Point对象。这对于可移植性来说是令人惊讶的,因为只要它们都具有xy属性,此代码将在具有不同Point实现或complicated.h新版本的系统上正常运行。

所有这些细节使得cffi在你使用 Python 处理 C 代码时成为一个了不起的工具。它比ctypes简单得多,同时在直接使用外部函数接口时提供了同样精细的控制能力。

f2py

对于许多科学应用程序,Fortran 仍然是金标准。虽然它作为通用语言的日子已经过去,但它仍然有许多细节使得向量操作易于编写且非常快速。此外,许多性能数学库都是用 Fortran 编写的(如LAPACKBLAS等),这些库在诸如 SciPy 等库中至关重要,能够在性能 Python 代码中使用它们可能非常关键。

对于这种情况,f2py 提供了一种非常简单的方法将 Fortran 代码导入 Python。由于 Fortran 中类型的显式性,这个模块可以如此简单。因为类型可以轻松解析和理解,f2py可以轻松地生成一个 CPython 模块,使用 C 中的原生外部函数支持来使用 Fortran 代码。这意味着当您使用f2py时,实际上是在自动生成一个懂得如何使用 Fortran 代码的 C 模块!因此,在使用f2py时,ctypescffi解决方案中存在的许多困惑根本不存在。

在示例 7-25 中,我们可以看到一些简单的f2py兼容代码,用于解决扩散方程问题。事实上,所有本地的 Fortran 代码都是f2py兼容的;然而,函数参数的注解(以!f2py开头的语句)简化了生成的 Python 模块,并提供了一个更易于使用的接口。这些注解隐式地告诉f2py我们是否打算将一个参数仅作为输出或输入,或者是我们希望在原地修改或完全隐藏的内容。隐藏类型特别适用于向量的大小:虽然 Fortran 可能需要显式指定这些数字,但我们的 Python 代码已经具备了这些信息。当我们将类型设置为“隐藏”时,f2py可以自动为我们填充这些值,在最终的 Python 接口中将它们隐藏起来。

示例 7-25. 使用f2py注解的 Fortran 2D 扩散代码
SUBROUTINE evolve(grid, next_grid, D, dt, N, M)
    !f2py threadsafe
    !f2py intent(in) grid
    !f2py intent(inplace) next_grid
    !f2py intent(in) D
    !f2py intent(in) dt
    !f2py intent(hide) N
    !f2py intent(hide) M
    INTEGER :: N, M
    DOUBLE PRECISION, DIMENSION(N,M) :: grid, next_grid
    DOUBLE PRECISION, DIMENSION(N-2, M-2) :: laplacian
    DOUBLE PRECISION :: D, dt

    laplacian = grid(3:N, 2:M-1) + grid(1:N-2, 2:M-1) + &
                grid(2:N-1, 3:M) + grid(2:N-1, 1:M-2) - 4 * grid(2:N-1, 2:M-1)
    next_grid(2:N-1, 2:M-1) = grid(2:N-1, 2:M-1) + D * dt * laplacian
END SUBROUTINE evolve

要将代码构建为 Python 模块,运行以下命令:

$ f2py -c -m diffusion --fcompiler=gfortran --opt='-O3' diffusion.f90
提示

我们在前面的f2py调用中特别使用了gfortran。确保它已安装在您的系统上,或者您更改对应的参数以使用您已安装的 Fortran 编译器。

这将创建一个库文件,固定到您的 Python 版本和操作系统上(在我们的案例中是diffusion.cpython-37m-x86_64-linux-gnu.so),可以直接导入到 Python 中使用。

如果我们在交互式环境中使用生成的模块进行测试,我们可以看到f2py给我们带来的便利,这要归功于我们的注解和它解析 Fortran 代码的能力。

>>> import diffusion

>>> diffusion?
Type:        module
String form: <module 'diffusion' from '[..]cpython-37m-x86_64-linux-gnu.so'>
File:        [..cut..]/diffusion.cpython-37m-x86_64-linux-gnu.so
Docstring:
This module 'diffusion' is auto-generated with f2py (version:2).
Functions:
  evolve(grid,scratch,d,dt)
.

>>> diffusion.evolve?
Call signature: diffusion.evolve(*args, **kwargs)
Type:           fortran
String form:    <fortran object>
Docstring:
evolve(grid,scratch,d,dt)

Wrapper for ``evolve``.

Parameters
grid : input rank-2 array('d') with bounds (n,m)
scratch :  rank-2 array('d') with bounds (n,m)
d : input float
dt : input float

该代码显示了f2py生成结果的自动文档化,并且接口非常简化。例如,我们不需要提取向量的大小,f2py已经找出如何自动找到这些信息,并且只是在生成的接口中隐藏了它。事实上,生成的evolve函数在其签名上看起来与我们在示例 6-14 中编写的纯 Python 版本完全相同。

我们唯一需要注意的是numpy数组在内存中的排序。由于大部分与numpy和 Python 相关的代码源自 C,我们始终使用 C 约定来排序内存中的数据(称为行主序排序)。Fortran 使用不同的约定(列主序排序),我们必须确保我们的向量遵循这种排序。这些排序简单地说明了对于 2D 数组,列或行在内存中是连续的。⁹ 幸运的是,这意味着我们在声明向量时可以简单地指定order='F'参数给numpy

注意

排序上的差异基本上改变了在多维数组上进行迭代时的外部循环。在 Python 和 C 中,如果您将一个数组定义为array[X][Y],您的外部循环将是在X上,内部循环将是在Y上。在 Fortran 中,您的外部循环将是在Y上,内部循环将是在X上。如果您使用错误的循环顺序,您最多会因为增加cache-misses(参见“内存碎片化”)而遭受严重的性能惩罚,最坏情况下可能访问错误的数据!

这导致了以下代码用于调用我们的 Fortran 子例程。这段代码看起来与我们在示例 6-14 中使用的完全相同,除了从f2py派生的库导入和我们数据的显式 Fortran 顺序之外:

from diffusion import evolve

def run_experiment(num_iterations):
    scratch = np.zeros(grid_shape, dtype=np.double, order="F")  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
    grid = np.zeros(grid_shape, dtype=np.double, order="F")

    initialize_grid(grid)

    for i in range(num_iterations):
        evolve(grid, scratch, 1.0, 0.1)
        grid, scratch = scratch, grid

1

Fortran 在内存中以不同的顺序排序数字,因此我们必须记住设置我们的numpy数组以使用该标准。

CPython 模块

最后,我们总是可以直接到达 CPython API 级别并编写一个 CPython 模块。这要求我们以与 CPython 开发的方式编写代码,并处理我们的代码与 CPython 实现之间的所有交互。

这种方法的优点在于它的移植性非常强,取决于 Python 版本。我们不需要任何外部模块或库,只需一个 C 编译器和 Python!然而,这并不一定能很好地适应 Python 的新版本。例如,为 Python 2.7 编写的 CPython 模块无法与 Python 3 一起使用,反之亦然。

注意

实际上,Python 3 的推出中很多的减速问题都源于进行这种更改的困难。创建 CPython 模块时,你与实际的 Python 实现紧密耦合,语言的大规模变更(比如从 2.7 到 3 的变更)需要对你的模块进行大规模修改。

这种便携性虽然带来了很大的成本,但你需要负责 Python 代码与模块之间的每一个接口细节。这会导致即使是最简单的任务也要编写数十行代码。例如,为了与 示例 7-21 中的扩散库进行接口交互,我们必须编写 28 行代码,仅仅是为了读取函数的参数并解析它们(示例 7-26)。当然,这确实意味着你能够非常精细地控制正在发生的事情。这甚至可以到达手动更改 Python 垃圾收集的引用计数的程度(这在创建处理本地 Python 类型的 CPython 模块时可能会带来很多痛苦)。因此,由于这个原因,最终生成的代码往往比其他接口方法稍微快一点。

警告

总而言之,这种方法应该被作为最后的选择。虽然编写 CPython 模块确实很有信息量,但生成的代码不如其他潜在方法那样可重用或可维护。在模块中进行微小的更改通常可能需要完全重新设计它。实际上,我们包括了模块代码和必需的 setup.py 来编译它(示例 7-27)作为一个警示故事。

示例 7-26. 用于 2D 扩散库接口的 CPython 模块
// python_interface.c
// - cpython module interface for diffusion.c

#define NPY_NO_DEPRECATED_API    NPY_1_7_API_VERSION

#include <Python.h>
#include <numpy/arrayobject.h>
#include "diffusion.h"

/* Docstrings */
static char module_docstring[] =
   "Provides optimized method to solve the diffusion equation";
static char cdiffusion_evolve_docstring[] =
   "Evolve a 2D grid using the diffusion equation";

PyArrayObject *py_evolve(PyObject *, PyObject *);

/* Module specification */
static PyMethodDef module_methods[] =
{
   /* { method name , C function              , argument types , docstring       } */
   { "evolve", (PyCFunction)py_evolve, METH_VARARGS, cdiffusion_evolve_docstring },
   { NULL,     NULL,                              0, NULL                        }
};

static struct PyModuleDef cdiffusionmodule =
{
   PyModuleDef_HEAD_INIT,
   "cdiffusion",      /* name of module */
   module_docstring,  /* module documentation, may be NULL */
   -1,                /* size of per-interpreter state of the module,
 * or -1 if the module keeps state in global variables. */
   module_methods
};

PyArrayObject *py_evolve(PyObject *self, PyObject *args)
{
   PyArrayObject *data;
   PyArrayObject *next_grid;
   double         dt, D = 1.0;

   /* The "evolve" function will have the signature:
 *     evolve(data, next_grid, dt, D=1)
 */
   if (!PyArg_ParseTuple(args, "OOd|d", &data, &next_grid, &dt, &D))
   {
      PyErr_SetString(PyExc_RuntimeError, "Invalid arguments");
      return(NULL);
   }

   /* Make sure that the numpy arrays are contiguous in memory */
   if (!PyArray_Check(data) || !PyArray_ISCONTIGUOUS(data))
   {
      PyErr_SetString(PyExc_RuntimeError, "data is not a contiguous array.");
      return(NULL);
   }
   if (!PyArray_Check(next_grid) || !PyArray_ISCONTIGUOUS(next_grid))
   {
      PyErr_SetString(PyExc_RuntimeError, "next_grid is not a contiguous array.");
      return(NULL);
   }

   /* Make sure that grid and next_grid are of the same type and have the same
 * dimensions
 */
   if (PyArray_TYPE(data) != PyArray_TYPE(next_grid))
   {
      PyErr_SetString(PyExc_RuntimeError,
                      "next_grid and data should have same type.");
      return(NULL);
   }
   if (PyArray_NDIM(data) != 2)
   {
      PyErr_SetString(PyExc_RuntimeError, "data should be two dimensional");
      return(NULL);
   }
   if (PyArray_NDIM(next_grid) != 2)
   {
      PyErr_SetString(PyExc_RuntimeError, "next_grid should be two dimensional");
      return(NULL);
   }
   if ((PyArray_DIM(data, 0) != PyArray_DIM(next_grid, 0)) ||
       (PyArray_DIM(data, 1) != PyArray_DIM(next_grid, 1)))
   {
      PyErr_SetString(PyExc_RuntimeError,
                      "data and next_grid must have the same dimensions");
      return(NULL);
   }

   evolve(
      PyArray_DATA(data),
      PyArray_DATA(next_grid),
      D,
      dt
      );

   Py_XINCREF(next_grid);
   return(next_grid);
}

/* Initialize the module */
PyMODINIT_FUNC
PyInit_cdiffusion(void)
{
   PyObject *m;

   m = PyModule_Create(&cdiffusionmodule);
   if (m == NULL)
   {
      return(NULL);
   }

   /* Load `numpy` functionality. */
   import_array();

   return(m);
}

要构建这段代码,我们需要创建一个 setup.py 脚本,使用 distutils 模块来确保构建的代码与 Python 兼容(示例 7-27)。除了标准的 distutils 模块外,numpy 还提供了自己的模块来帮助将 numpy 整合到你的 CPython 模块中。

示例 7-27. CPython 模块扩散接口的设置文件
"""
setup.py for cpython diffusion module.  The extension can be built by running

 $ python setup.py build_ext --inplace

which will create the __cdiffusion.so__ file, which can be directly imported into
Python.
"""

from distutils.core import setup, Extension
import numpy.distutils.misc_util

__version__ = "0.1"

cdiffusion = Extension(
    'cdiffusion',
    sources = ['cdiffusion/cdiffusion.c', 'cdiffusion/python_interface.c'],
    extra_compile_args = ["-O3", "-std=c11", "-Wall", "-p", "-pg", ],
    extra_link_args = ["-lc"],
)

setup (
    name = 'diffusion',
    version = __version__,
    ext_modules = [cdiffusion,],
    packages = ["diffusion", ],
    include_dirs = numpy.distutils.misc_util.get_numpy_include_dirs(),
)

由此产生的结果是一个 cdiffusion.so 文件,可以直接从 Python 中导入并且非常容易使用。由于我们完全控制了生成函数的签名以及我们的 C 代码如何与库交互,我们能够(通过一些艰苦的工作)创建一个易于使用的模块:

from cdiffusion import evolve

def run_experiment(num_iterations):
    next_grid = np.zeros(grid_shape, dtype=np.double)
    grid = np.zeros(grid_shape, dtype=np.double)

    # ... standard initialization ...

    for i in range(num_iterations):
        evolve(grid, next_grid, 1.0, 0.1)
        grid, next_grid = next_grid, grid

总结

本章介绍的各种策略允许您根据需要专门化您的代码,以减少 CPU 必须执行的指令数,并提高程序的效率。有时可以通过算法实现,尽管通常必须手动完成(参见“JIT Versus AOT Compilers”)。此外,有时候必须使用这些方法仅仅是为了使用已经用其他语言编写的库。无论出于何种动机,Python 都使我们能够在某些问题上享受其他语言可以提供的加速效果,同时在需要时保持冗长和灵活性。

我们还研究了如何利用 GPU 使用特定目的的硬件比单独使用 CPU 解决问题更快。这些设备非常专业化,其性能考虑与传统的高性能编程有很大不同。然而,我们已经看到像 PyTorch 这样的新库使评估 GPU 比以往任何时候都更简单。

但需要注意的是,这些优化仅用于优化计算指令的效率。如果您的 I/O 限制进程与计算限制问题耦合在一起,仅仅编译代码可能不会提供任何合理的加速效果。对于这些问题,我们必须重新思考我们的解决方案,并可能使用并行处理来同时运行不同的任务。

¹ RTX 2080 TI 还包括 544 个张量核心,专门用于帮助特别适用于深度学习的数学运算。

² 要比较 TensorFlow 和 PyTorch 的性能,请参见https://oreil.ly/8NOJWhttps://oreil.ly/4BKM5

³ PyTorch 在 CPU 上的性能并不是非常出色,除非你从源代码安装。从源代码安装时,会使用优化的线性代数库,以提供与 NumPy 相媲美的速度。

⁴ 与任何 JIT 一样,第一次调用函数时,将需要编译代码的开销。在示例 7-19 中,我们多次对函数进行剖析,并忽略第一次,以便仅测量运行时速度。

DataLoader对象还支持多个工作线程的运行。如果数据从磁盘加载,建议使用多个工作线程以最小化 I/O 时间。

⁶ 为简单起见,我们不会实现边界条件。

⁷ 这与 CPython 相关。其他版本的 Python 可能有它们自己的ctypes版本,其工作方式可能有所不同。

⁸ 在 Unix 系统中,系统库的头文件可以在/usr/include找到。

⁹ 更多信息,请参阅维基百科页面,了解行主序和列主序的排序。

第八章:异步 I/O

到目前为止,我们已经集中精力通过增加程序在给定时间内完成的计算周期数来加速代码。然而,在大数据时代,将相关数据传递给您的代码可能成为瓶颈,而不是代码本身。当这种情况发生时,你的程序被称为I/O 绑定;换句话说,速度受到输入/输出效率的限制。

I/O 可以对程序的流程造成相当大的负担。每当你的代码从文件中读取或向网络套接字写入时,它都必须暂停联系内核,请求实际执行读取操作,然后等待其完成。这是因为实际的读取操作不是由你的程序完成的,而是由内核完成的,因为内核负责管理与硬件的任何交互。这个额外的层次可能看起来并不像世界末日一样糟糕,尤其是当你意识到类似的操作每次分配内存时也会发生;然而,如果我们回顾一下图 1-3,我们会发现我们执行的大多数 I/O 操作都是在比 CPU 慢几个数量级的设备上进行的。因此,即使与内核的通信很快,我们也将花费相当长的时间等待内核从设备获取结果并将其返回给我们。

例如,在写入网络套接字所需的时间内,通常需要约 1 毫秒,我们可以在一台 2.4 GHz 计算机上完成 2,400,000 条指令。最糟糕的是,我们的程序在这 1 毫秒时间内大部分时间都被暂停了——我们的执行被暂停,然后我们等待一个信号表明写入操作已完成。这段时间在暂停状态中度过称为I/O 等待

异步 I/O 帮助我们利用这段浪费的时间,允许我们在 I/O 等待状态时执行其他操作。例如,在图 8-1 中,我们看到一个程序的示例,必须运行三个任务,其中所有任务都有 I/O 等待期。如果我们串行运行它们,我们将遭受三次 I/O 等待的惩罚。然而,如果我们并行运行这些任务,我们可以通过在此期间运行另一个任务来实际隐藏等待时间。重要的是要注意,所有这些仍然发生在单个线程上,并且仍然一次只使用一个 CPU!

这种方式的实现是因为当程序处于 I/O 等待时,内核只是等待我们请求的设备(硬盘、网络适配器、GPU 等)发出信号,表示请求的数据已经准备好。我们可以创建一个机制(即事件循环)来分派数据请求,继续执行计算操作,并在数据准备好读取时得到通知,而不是等待。这与多进程/多线程(第 9 章)范式形成了鲜明对比,后者启动新进程以进行 I/O 等待,但利用现代 CPU 的多任务性质允许主进程继续。然而,这两种机制通常同时使用,其中我们启动多个进程,每个进程在异步 I/O 方面都很有效,以充分利用计算机的资源。

注意

由于并发程序在单线程上运行,通常比标准多线程程序更容易编写和管理。所有并发函数共享同一个内存空间,因此在它们之间共享数据的方式与您预期的正常方式相同。然而,您仍然需要注意竞态条件,因为不能确定代码的哪些行在何时运行。

通过以事件驱动的方式对程序建模,我们能够利用 I/O 等待,在单线程上执行比以往更多的操作。

hpp2 0801

图 8-1. 串行和并发程序的比较

异步编程简介

通常,当程序进入 I/O 等待时,执行会暂停,以便内核执行与 I/O 请求相关的低级操作(这称为上下文切换),直到 I/O 操作完成。上下文切换是一种相当重的操作。它要求我们保存程序的状态(丢失 CPU 级别的任何缓存),并放弃使用 CPU。稍后,当我们被允许再次运行时,我们必须花时间在主板上重新初始化我们的程序,并准备恢复(当然,所有这些都是在幕后进行的)。

与并发相比,我们通常会有一个事件循环在运行,负责管理程序中需要运行的内容以及运行的时间。本质上,事件循环就是一系列需要运行的函数。列表顶部的函数被运行,然后是下一个,以此类推。示例 8-1 展示了一个简单的事件循环示例。

示例 8-1. 玩具事件循环
from queue import Queue
from functools import partial

eventloop = None

class EventLoop(Queue):
    def start(self):
        while True:
            function = self.get()
            function()

def do_hello():
    global eventloop
    print("Hello")
    eventloop.put(do_world)

def do_world():
    global eventloop
    print("world")
    eventloop.put(do_hello)

if __name__ == "__main__":
    eventloop = EventLoop()
    eventloop.put(do_hello)
    eventloop.start()

这可能看起来不是一个很大的改变;然而,当我们将事件循环与异步(async)I/O 操作结合使用时,可以在执行 I/O 任务时获得显著的性能提升。在这个例子中,调用 eventloop.put(do_world) 大致相当于对 do_world 函数进行异步调用。这个操作被称为非阻塞,意味着它会立即返回,但保证稍后某个时刻调用 do_world。类似地,如果这是一个带有异步功能的网络写入,它将立即返回,即使写入尚未完成。当写入完成时,一个事件触发,这样我们的程序就知道了。

将这两个概念结合起来,我们可以编写一个程序,当请求 I/O 操作时,运行其他函数,同时等待原始 I/O 操作完成。这本质上允许我们在本来会处于 I/O 等待状态时仍然进行有意义的计算。

注意

切换从一个函数到另一个函数确实是有成本的。内核必须花费时间在内存中设置要调用的函数,并且我们的缓存状态不会那么可预测。正是因为这个原因,并发在程序有大量 I/O 等待时提供了最佳结果——与通过利用 I/O 等待时间获得的收益相比,切换的成本可能要少得多。

通常,使用事件循环进行编程可以采用两种形式:回调或期约。在回调范式中,函数被调用并带有一个通常称为回调的参数。函数不返回其值,而是调用回调函数并传递该值。这样设置了一长串被调用的函数链,每个函数都得到前一个函数链中的结果(这些链有时被称为“回调地狱”)。示例 8-2 是回调范式的一个简单示例。

示例 8-2. 使用回调的示例
from functools import partial
from some_database_library import save_results_to_db

def save_value(value, callback):
    print(f"Saving {value} to database")
    save_result_to_db(result, callback)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)

def print_response(db_response):
    print("Response from database: {db_response}")

if __name__ == "__main__":
    eventloop.put(partial(save_value, "Hello World", print_response))

1

save_result_to_db 是一个异步函数;它将立即返回,并允许其他代码运行。然而,一旦数据准备好,将调用 print_response

在 Python 3.4 之前,回调范式非常流行。然而,asyncio标准库模块和 PEP 492 使期约机制成为 Python 的本地特性。通过创建处理异步 I/O 的标准 API 以及新的awaitasync关键字,定义了异步函数和等待结果的方式。

在这种范式中,异步函数返回一个 Future 对象,这是一个未来结果的承诺。因此,如果我们希望在某个时刻获取结果,我们必须等待由这种类型的异步函数返回的未来完成并填充我们期望的值(通过对其进行 await 或运行显式等待值的函数)。然而,这也意味着结果可以在调用者的上下文中可用,而在回调范式中,结果仅在回调函数中可用。在等待 Future 对象填充我们请求的数据时,我们可以进行其他计算。如果将这与生成器的概念相结合——可以暂停并稍后恢复执行的函数——我们可以编写看起来非常接近串行代码形式的异步代码:

from some_async_database_library import save_results_to_db

async def save_value(value):
    print(f"Saving {value} to database")
    db_response = await save_result_to_db(result) ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
    print("Response from database: {db_response}")

if __name__ == "__main__":
    eventloop.put(
        partial(save_value, "Hello World", print)
    )

1

在这种情况下,save_result_to_db 返回一个 Future 类型。通过 await 它,我们确保 save_value 在值准备好之前暂停,然后恢复并完成其操作。

重要的是要意识到,由 save_result_to_db 返回的 Future 对象保持了未来结果的承诺,并不持有结果本身或调用任何 save_result_to_db 代码。事实上,如果我们简单地执行 db_response_future = save_result_to_db(result),该语句会立即完成,并且我们可以对 Future 对象执行其他操作。例如,我们可以收集一个未来对象的列表,并同时等待它们的完成。

async/await 是如何工作的?

一个 async 函数(使用 async def 定义)称为协程。在 Python 中,协程的实现与生成器具有相同的哲学。这很方便,因为生成器已经有了暂停执行和稍后恢复的机制。使用这种范式,await 语句在功能上类似于 yield 语句;当前函数的执行在运行其他代码时暂停。一旦 awaityield 解析出数据,函数就会恢复执行。因此,在前面的例子中,我们的 save_result_to_db 将返回一个 Future 对象,而 await 语句会暂停函数,直到 Future 包含一个结果。事件循环负责安排在 Future 准备好返回结果后恢复 save_value 的执行。

对于基于 Python 2.7 实现的基于未来的并发,当我们尝试将协程用作实际函数时,事情可能会变得有些奇怪。请记住,生成器无法返回值,因此库以各种方式处理此问题。在 Python 3.4 中引入了新的机制,以便轻松创建协程并使它们仍然返回值。然而,许多自 Python 2.7 以来存在的异步库具有处理这种尴尬转换的遗留代码(特别是 tornadogen 模块)。

在运行并发代码时,意识到我们依赖于事件循环是至关重要的。一般来说,这导致大多数完全并发的代码的主要代码入口主要是设置和启动事件循环。然而,这假设整个程序都是并发的。在其他情况下,程序内部会创建一组 futures,然后简单地启动一个临时事件循环来管理现有的 futures,然后事件循环退出,代码可以正常恢复。这通常使用asyncio.loop模块中的loop.run_until_complete(coro)loop.run_forever()方法来完成。然而,asyncio还提供了一个便利函数(asyncio.run(coro))来简化这个过程。

本章中,我们将分析一个从具有内置延迟的 HTTP 服务器获取数据的网络爬虫。这代表了在处理 I/O 时通常会发生的响应时间延迟。我们首先会创建一个串行爬虫,查看这个问题的简单 Python 解决方案。然后,我们将通过迭代geventtorando逐步构建出一个完整的aiohttp解决方案。最后,我们将探讨如何将异步 I/O 任务与 CPU 任务结合起来,以有效地隐藏任何花费在 I/O 上的时间。

注意

我们实现的 Web 服务器可以同时支持多个连接。对于大多数需要进行 I/O 操作的服务来说,这通常是真实的情况——大多数数据库可以支持同时进行多个请求,大多数 Web 服务器支持 10,000 个以上的同时连接。然而,当与无法处理多个连接的服务进行交互时,我们的性能将始终与串行情况相同。

串行爬虫

在我们的并发实验控制中,我们将编写一个串行网络爬虫,接收一个 URL 列表,获取页面内容并计算总长度。我们将使用一个自定义的 HTTP 服务器,它接收两个参数,namedelaydelay字段告诉服务器在响应之前暂停的时间长度(以毫秒为单位)。name字段用于记录日志。

通过控制delay参数,我们可以模拟服务器响应查询的时间。在现实世界中,这可能对应于一个响应缓慢的 Web 服务器、繁重的数据库调用,或者任何执行时间长的 I/O 调用。对于串行情况,这会导致程序在 I/O 等待中耗费更多时间,但在后面的并发示例中,这将提供一个机会来利用 I/O 等待时间来执行其他任务。

在示例 8-3 中,我们选择使用requests模块执行 HTTP 调用。我们之所以选择这个模块,是因为它的简单性。我们在本节中通常使用 HTTP,因为它是 I/O 的一个简单示例,可以轻松执行。一般来说,可以用任何 I/O 替换对 HTTP 库的任何调用。

示例 8-3. 串行 HTTP 抓取器
import random
import string

import requests

def generate_urls(base_url, num_urls):
    """
 We add random characters to the end of the URL to break any caching
 mechanisms in the requests library or the server
 """
    for i in range(num_urls):
        yield base_url + "".join(random.sample(string.ascii_lowercase, 10))

def run_experiment(base_url, num_iter=1000):
    response_size = 0
    for url in generate_urls(base_url, num_iter):
        response = requests.get(url)
        response_size += len(response.text)
    return response_size

if __name__ == "__main__":
    import time

    delay = 100
    num_iter = 1000
    base_url = f"http://127.0.0.1:8080/add?name=serial&delay={delay}&"

    start = time.time()
    result = run_experiment(base_url, num_iter)
    end = time.time()
    print(f"Result: {result}, Time: {end - start}")

运行此代码时,一个有趣的度量标准是查看每个请求在 HTTP 服务器中的开始和结束时间。这告诉我们我们的代码在 I/O 等待期间的效率有多高——因为我们的任务是发起 HTTP 请求并汇总返回的字符数,我们应该能够在等待其他请求完成时发起更多的 HTTP 请求并处理任何响应。

我们可以在 图 8-2 中看到,正如预期的那样,我们的请求没有交错。我们一次只执行一个请求,并在移动到下一个请求之前等待前一个请求完成。实际上,串行进程的总运行时间在这种情况下是完全合理的。由于每个请求都需要 0.1 秒(因为我们的 delay 参数),而我们要执行 500 个请求,我们预计运行时间大约为 50 秒。

串行爬虫请求时间

图 8-2. 示例 8-3 请求时间

Gevent

最简单的异步库之一是 gevent。它遵循异步函数返回未来任务的范例,这意味着您的代码中的大部分逻辑可以保持不变。此外,gevent 修补了标准 I/O 函数以支持异步,因此大多数情况下,您可以简单地使用标准 I/O 包并从异步行为中受益。

Gevent 提供了两种机制来实现异步编程——如前所述,它通过异步 I/O 函数修补了标准库,并且它有一个 Greenlets 对象可用于并发执行。Greenlet 是一种协程类型,可以看作是线程(请参阅 第 9 章 中有关线程的讨论);然而,所有的 Greenlet 都在同一个物理线程上运行。我们有一个事件循环在单个 CPU 上,在 I/O 等待期间能够在它们之间切换。大部分时间,gevent 试图通过使用 wait 函数尽可能透明地处理事件循环。wait 函数将启动一个事件循环,并运行它直到所有的 Greenlet 完成为止。因此,大部分 gevent 代码将会串行运行;然后在某个时刻,您会设置许多 Greenlet 来执行并发任务,并使用 wait 函数启动事件循环。在 wait 函数执行期间,您排队的所有并发任务都将运行直到完成(或某些停止条件),然后您的代码将恢复为串行。

未来任务是通过 gevent.spawn 创建的,该函数接受一个函数及其参数,并启动一个负责运行该函数的绿色线程(greenlet)。绿色线程可以被视为一个未来任务,因为一旦我们指定的函数完成,其值将包含在绿色线程的 value 字段中。

这种对 Python 标准模块的修补可能会使控制正在进行的细微变化变得更加困难。例如,在进行异步 I/O 时,我们要确保不要同时打开太多文件或连接。如果这样做,我们可能会使远程服务器过载,或者通过不得不在太多操作之间进行上下文切换来减慢我们的进程。

为了手动限制打开文件的数量,我们使用信号量一次只允许 100 个绿色线程进行 HTTP 请求。信号量通过确保只有一定数量的协程可以同时进入上下文块来工作。因此,我们立即启动所有需要获取 URL 的绿色线程;但是每次只有 100 个线程可以进行 HTTP 调用。信号量是各种并行代码流中经常使用的一种锁定机制类型。通过基于各种规则限制代码的执行顺序,锁定可以帮助您确保程序的各个组件不会互相干扰。

现在,我们已经设置好了所有的未来并且放入了一个锁机制来控制绿色线程的流程,我们可以使用gevent.iwait函数等待,该函数将获取一个准备好的项目序列并迭代它们。相反,我们也可以使用gevent.wait,它将阻塞程序的执行,直到所有请求都完成。

我们费力地使用信号量来分组我们的请求,而不是一次性发送它们,因为过载事件循环可能会导致性能下降(对于所有异步编程都是如此)。此外,我们与之通信的服务器将限制同时响应的并发请求数量。

通过实验(见图 8-3](#conn_num_concurrent_requests)),我们通常看到一次大约 100 个开放连接对于约 50 毫秒响应时间的请求是最佳的。如果我们使用更少的连接,我们仍然会在 I/O 等待期间浪费时间。而使用更多连接时,我们在事件循环中频繁切换上下文,并给程序增加了不必要的开销。我们可以看到,对于 50 毫秒请求,400 个并发请求的情况下,这种效果就显现出来了。话虽如此,这个 100 的值取决于许多因素——计算机运行代码的机器、事件循环的实现、远程主机的属性、远程服务器的预期响应时间等。我们建议在做出选择之前进行一些实验。

对不同请求时间进行实验

图 8-3. 对不同数量的并发请求进行实验,针对不同的请求时间。

在示例 8-4,我们通过使用信号量来实现gevent爬虫,以确保一次只有 100 个请求。

示例 8-4. gevent HTTP 爬虫
import random
import string
import urllib.error
import urllib.parse
import urllib.request
from contextlib import closing

import gevent
from gevent import monkey
from gevent.lock import Semaphore

monkey.patch_socket()

def generate_urls(base_url, num_urls):
    for i in range(num_urls):
        yield base_url + "".join(random.sample(string.ascii_lowercase, 10))

def download(url, semaphore):
    with semaphore:  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
        with closing(urllib.request.urlopen(url)) as data:
            return data.read()

def chunked_requests(urls, chunk_size=100):
    """
    Given an iterable of urls, this function will yield back the contents of the
    URLs. The requests will be batched up in "chunk_size" batches using a
    semaphore
    """
    semaphore = Semaphore(chunk_size)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
    requests = [gevent.spawn(download, u, semaphore) for u in urls]  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png)
    for response in gevent.iwait(requests):
        yield response

def run_experiment(base_url, num_iter=1000):
    urls = generate_urls(base_url, num_iter)
    response_futures = chunked_requests(urls, 100)  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/4.png)
    response_size = sum(len(r.value) for r in response_futures)
    return response_size

if __name__ == "__main__":
    import time

    delay = 100
    num_iter = 1000
    base_url = f"http://127.0.0.1:8080/add?name=gevent&delay={delay}&"

    start = time.time()
    result = run_experiment(base_url, num_iter)
    end = time.time()
    print(f"Result: {result}, Time: {end - start}")

1

在这里,我们生成一个信号量,允许chunk_size个下载同时进行。

2

通过使用信号量作为上下文管理器,我们确保一次只能运行chunk_size个绿色线程。

3

我们可以排队尽可能多的绿色线程,知道没有一个会在我们用waitiwait启动事件循环之前运行。

4

response_futures现在持有已完成的期货生成器,所有这些期货都在.value属性中包含了我们需要的数据。

一个重要的事情需要注意的是,我们使用了gevent来使我们的 I/O 请求异步化,但在 I/O 等待期间我们不进行任何非 I/O 计算。然而,在图 8-4 中,我们可以看到我们得到的大幅加速(见表 8-1)。通过在等待前一个请求完成时发起更多请求,我们能够实现 90 倍的加速!我们可以明确地看到在代表请求的堆叠水平线上一次性发出请求,之前的请求完成之前。这与串行爬虫的情况形成鲜明对比(参见图 8-2),在那里一条线仅在前一条线结束时开始。

此外,我们可以在图 8-4 中看到更多有趣的效果,反映在gevent请求时间线的形状上。例如,在大约第 100 次请求时,我们看到了一个暂停,此时没有启动新的请求。这是因为这是第一次我们的信号量被触发,并且我们能够在任何之前的请求完成之前锁定信号量。此后,信号量进入平衡状态:在另一个请求完成时刚好锁定和解锁它。

gevent 爬虫请求时间。红线标示第 100 次请求,我们可以看到后续请求之前的暂停。

Figure 8-4. gevent爬虫的请求时间——红线标示第 100 次请求,我们可以看到后续请求之前的暂停。

龙卷风

另一个经常在 Python 中用于异步 I/O 的包是tornado,最初由 Facebook 开发,主要用于 HTTP 客户端和服务器。该框架自 Python 3.5 引入async/await以来就存在,并最初使用回调系统组织异步调用。然而,最近,项目的维护者选择采用协程,并在asyncio模块的架构中起到了重要作用。

目前,tornado 可以通过使用 async/await 语法(这是 Python 中的标准)或使用 Python 的 tornado.gen 模块来使用。这个模块作为 Python 中原生协程的前身提供。它通过提供一个装饰器将方法转换为协程(即,获得与使用 async def 定义函数相同结果的方法)以及各种实用工具来管理协程的运行时来实现。当前,只有在您打算支持早于 3.5 版本的 Python 时,才需要使用这种装饰器方法。¹

提示

使用 tornado 时,请确保已安装 pycurl。它是 tornado 的可选后端,但性能更好,特别是在 DNS 请求方面,优于默认后端。

在 示例 8-5 中,我们实现了与 gevent 相同的网络爬虫,但是我们使用了 tornado 的 I/O 循环(它的版本是事件循环)和 HTTP 客户端。这使我们不必批量处理请求和处理代码的其他更底层的方面。

示例 8-5。tornado HTTP 爬虫
import asyncio
import random
import string
from functools import partial

from tornado.httpclient import AsyncHTTPClient

AsyncHTTPClient.configure(
    "tornado.curl_httpclient.CurlAsyncHTTPClient",
    max_clients=100  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
)

def generate_urls(base_url, num_urls):
    for i in range(num_urls):
        yield base_url + "".join(random.sample(string.ascii_lowercase, 10))

async def run_experiment(base_url, num_iter=1000):
    http_client = AsyncHTTPClient()
    urls = generate_urls(base_url, num_iter)
    response_sum = 0
    tasks = [http_client.fetch(url) for url in urls]  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
    for task in asyncio.as_completed(tasks):  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png)
        response = await task  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/4.png)
        response_sum += len(response.body)
    return response_sum

if __name__ == "__main__":
    import time

    delay = 100
    num_iter = 1000
    run_func = partial(
        run_experiment,
        f"http://127.0.0.1:8080/add?name=tornado&delay={delay}&",
        num_iter,
    )

    start = time.time()
    result = asyncio.run(run_func)  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/5.png)
    end = time.time()
    print(f"Result: {result}, Time: {end - start}")

1

我们可以配置我们的 HTTP 客户端,并选择我们希望使用的后端库以及我们希望将多少个请求一起批处理。Tornado 默认最多同时进行 10 个并发请求。

2

我们生成许多 Future 对象来排队获取 URL 内容的任务。

3

这将运行在 tasks 列表中排队的所有协程,并在它们完成时将它们作为结果返回。

4

由于协程已经完成,因此此处的 await 语句立即返回最早完成的任务的结果。

5

ioloop.run_sync 将启动 IOLoop,并在指定函数的运行时段内持续运行。另一方面,ioloop.start() 启动一个必须手动终止的 IOLoop

在 示例 8-5 中的 tornado 代码与 示例 8-4 中的 gevent 代码之间的一个重要区别是事件循环的运行方式。对于 gevent,事件循环仅在 iwait 函数运行时才会运行。另一方面,在 tornado 中,事件循环始终运行,并控制程序的完整执行流程,而不仅仅是异步 I/O 部分。

这使得 tornado 成为大多数 I/O 密集型应用和大多数(如果不是全部)应用都应该是异步的理想选择。这是 tornado 最为人所知的地方,作为一款高性能的 Web 服务器。事实上,Micha 在许多情况下都是用 tornado 支持的数据库和数据结构来进行大量 I/O。²

另一方面,由于 gevent 对整个程序没有任何要求,因此它是主要用于基于 CPU 的问题的理想解决方案,有时涉及大量 I/O,例如对数据集进行大量计算,然后必须将结果发送回数据库进行存储。由于大多数数据库都具有简单的 HTTP API,因此甚至可以使用 grequests 进行简化。

另一个有趣的区别在于 geventtornado 在内部更改请求调用图的方式。将 图 8-5 与 图 8-4 进行比较。对于 gevent 的调用图,我们看到一个非常均匀的调用图,即当信号量中的插槽打开时,新请求会立即发出。另一方面,tornado 的调用图则非常起伏不定。这意味着限制打开连接数的内部机制未能及时响应请求完成。调用图中那些看起来比平时更细或更粗的区域表示事件循环未能有效地执行其工作的时段——即我们要么未充分利用资源,要么过度利用资源。

注意

对于所有使用 asyncio 运行事件循环的库,我们实际上可以更改正在使用的后端库。例如,uvloop 项目提供了一个替换 asyncio 事件循环的即插即用解决方案,声称大幅提升速度。这些速度提升主要在服务器端可见;在本章概述的客户端示例中,它们只提供了小幅性能提升。然而,由于只需额外两行代码即可使用此事件循环,几乎没有理由不使用它!

我们可以开始理解这种减速的原因,考虑到我们一遍又一遍地学到的教训:通用代码之所以有用,是因为它能很好地解决所有问题,但没有完美解决任何一个单独的问题。当处理大型 Web 应用程序或代码库中可能在许多不同位置进行 HTTP 请求时,限制一百个正在进行的连接的机制非常有用。一种简单的配置保证总体上我们不会打开超过定义的连接数。然而,在我们的情况下,我们可以从处理方式非常具体的好处中受益(就像我们在 gevent 示例中所做的那样)。

tornado 爬虫请求时间

图 8-5. 示例 8-5 的 HTTP 请求时间的时间轴

aiohttp

针对使用异步功能处理重型 IO 系统的普及,Python 3.4+引入了对旧的asyncio标准库模块的改进。然而,这个模块当时相当低级,提供了所有用于第三方库创建易于使用的异步库的底层机制。aiohttp作为第一个完全基于新asyncio库构建的流行库应运而生。它提供 HTTP 客户端和服务器功能,并使用与熟悉tornado的人类似的 API。整个项目aio-libs提供了广泛用途的原生异步库。在示例 8-6 中,我们展示了如何使用aiohttp实现asyncio爬虫。

示例 8-6. asyncio HTTP 爬虫
import asyncio
import random
import string

import aiohttp

def generate_urls(base_url, num_urls):
    for i in range(num_urls):
        yield base_url + "".join(random.sample(string.ascii_lowercase, 10))

def chunked_http_client(num_chunks):
    """
    Returns a function that can fetch from a URL, ensuring that only
    "num_chunks" of simultaneous connects are made.
    """
    semaphore = asyncio.Semaphore(num_chunks)  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)

    async def http_get(url, client_session):  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
        nonlocal semaphore
        async with semaphore:
            async with client_session.request("GET", url) as response:
                return await response.content.read()

    return http_get

async def run_experiment(base_url, num_iter=1000):
    urls = generate_urls(base_url, num_iter)
    http_client = chunked_http_client(100)
    responses_sum = 0
    async with aiohttp.ClientSession() as client_session:
        tasks = [http_client(url, client_session) for url in urls]  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png)
        for future in asyncio.as_completed(tasks):  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/4.png)
            data = await future
            responses_sum += len(data)
    return responses_sum

if __name__ == "__main__":
    import time

    loop = asyncio.get_event_loop()
    delay = 100
    num_iter = 1000

    start = time.time()
    result = loop.run_until_complete(
        run_experiment(
            f"http://127.0.0.1:8080/add?name=asyncio&delay={delay}&", num_iter
        )
    )
    end = time.time()
    print(f"Result: {result}, Time: {end - start}")

1

gevent示例中一样,我们必须使用信号量来限制请求的数量。

2

我们返回一个新的协程,将异步下载文件并尊重信号量的锁定。

3

函数http_client返回 futures。为了跟踪进度,我们将 futures 保存到列表中。

4

gevent一样,我们可以等待 futures 变为就绪并对其进行迭代。

对这段代码的一个直接反应是async withasync defawait调用的数量。在http_get的定义中,我们使用异步上下文管理器以并发友好的方式访问共享资源。也就是说,通过使用async with,我们允许其他协程在等待获取我们请求的资源时运行。因此,可以更有效地共享诸如开放的信号量插槽或已经打开的连接到我们主机的东西,比我们在使用tornado时经历的更有效。

实际上,图 8-6 中的调用图显示了与图 8-4 中的gevent类似的平滑过渡。此外,总体上,asyncio的代码运行速度略快于gevent(1.10 秒对比 1.14 秒—参见表 8-1),尽管每个请求的时间稍长。这只能通过信号量暂停的协程或等待 HTTP 客户端的更快恢复来解释。

异步 IO 爬虫的请求时间

图 8-6. 示例 8-6 的 HTTP 请求的年表

这段代码示例还展示了使用aiohttp和使用tornado之间的巨大区别,因为使用aiohttp时,我们对事件循环以及我们正在进行的请求的各种微妙之处有很大的控制。例如,我们手动获取客户端会话,负责缓存打开的连接,以及手动从连接中读取。如果我们愿意,我们可以改变连接缓存的时间或者决定仅向服务器写入而不读取其响应。

尽管对于这样一个简单的示例来说,这种控制可能有点过度,但在实际应用中,我们可以使用它来真正调整我们应用程序的性能。任务可以轻松地添加到事件循环中而无需等待其响应,并且我们可以轻松地为任务添加超时,使其运行时间受限;我们甚至可以添加在任务完成时自动触发的函数。这使我们能够创建复杂的运行时模式,以最优化地利用通过能够在 I/O 等待期间运行代码而获得的时间。特别是,当我们运行一个 Web 服务时(例如,一个可能需要为每个请求执行计算任务的 API),这种控制可以使我们编写“防御性”代码,知道如何在新请求到达时将运行时间让步给其他任务。我们将在 “完全异步” 中更多地讨论这个方面。

表 8-1. 爬虫的总运行时间比较

序号 gevent tornado aiohttp
运行时间(秒) 102.684 1.142 1.171 1.101

共享 CPU-I/O 负载

为了使前述示例更具体化,我们将创建另一个玩具问题,在这个问题中,我们有一个需要频繁与数据库通信以保存结果的 CPU 绑定问题。CPU 负载可以是任何东西;在这种情况下,我们正在使用较大和较大的工作负载因子对随机字符串进行bcrypt哈希以增加 CPU 绑定工作的量(请参见表 8-2 以了解“难度”参数如何影响运行时间)。这个问题代表了任何需要程序进行大量计算,并且这些计算的结果必须存储到数据库中的问题,可能会导致严重的 I/O 惩罚。我们对数据库的唯一限制如下:

  • 它有一个 HTTP API,因此我们可以使用早期示例中的代码。³

  • 响应时间在 100 毫秒的数量级上。

  • 数据库可以同时满足许多请求。⁴

这个“数据库”的响应时间被选择得比通常要高,以夸大问题的转折点,即执行 CPU 任务中的一个所需的时间长于执行 I/O 任务中的一个。对于只用于存储简单值的数据库,响应时间大于 10 毫秒应被认为是慢的!

表 8-2. 计算单个哈希的时间

难度参数 8 10 11 12
每次迭代秒数 0.0156 0.0623 0.1244 0.2487

串行

我们从一些简单的代码开始,计算字符串的 bcrypt 哈希,并在计算结果时每次向数据库的 HTTP API 发送请求:

import random
import string

import bcrypt
import requests

def do_task(difficulty):
    """
    Hash a random 10 character string using bcrypt with a specified difficulty
    rating.
    """
    passwd = ("".join(random.sample(string.ascii_lowercase, 10))  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
                .encode("utf8"))
    salt = bcrypt.gensalt(difficulty)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
    result = bcrypt.hashpw(passwd, salt)
    return result.decode("utf8")

def save_result_serial(result):
    url = f"http://127.0.0.1:8080/add"
    response = requests.post(url, data=result)
    return response.json()

def calculate_task_serial(num_iter, task_difficulty):
    for i in range(num_iter):
        result = do_task(task_difficulty)
        save_number_serial(result)

1

我们生成一个随机的 10 字符字节数组。

2

difficulty 参数设置了生成密码的难度,通过增加哈希算法的 CPU 和内存需求来实现。

正如我们的串行示例中一样(示例 8-3),每个数据库保存的请求时间(100 毫秒)不会叠加,我们必须为每个结果支付这个惩罚。因此,以 8 的任务难度进行六百次迭代需要 71 秒。然而,由于我们串行请求的方式,我们至少要花费 40 秒在 I/O 上!我们程序运行时的 56% 时间都在做 I/O,并且仅仅是在“I/O 等待”时,而它本可以做一些其他事情!

当然,随着 CPU 问题所需时间越来越长,做这种串行 I/O 的相对减速也会减少。这只是因为在每个任务后都暂停 100 毫秒的成本,相比于完成这个计算所需的长时间来说微不足道(正如我们在 图 8-7 中所见)。这一事实突显了在考虑进行哪些优化之前了解工作负载的重要性。如果您有一个需要几小时的 CPU 任务和仅需要几秒钟的 I/O 任务,那么加速 I/O 任务所带来的巨大提速可能并不会达到您所期望的效果!

串行代码与无 I/O 的 CPU 任务比较

图 8-7. 串行代码与无 I/O 的 CPU 任务比较

批处理结果

而不是立即转向完全的异步解决方案,让我们尝试一个中间解决方案。如果我们不需要立即知道数据库中的结果,我们可以批量处理结果,并以小的异步突发方式将它们发送到数据库。为此,我们创建一个 AsyncBatcher 对象,负责将结果排队,以便在没有 CPU 任务的 I/O 等待期间发送它们。在这段时间内,我们可以发出许多并发请求,而不是逐个发出它们:

import asyncio
import aiohttp

class AsyncBatcher(object):
    def __init__(self, batch_size):
        self.batch_size = batch_size
        self.batch = []
        self.client_session = None
        self.url = f"http://127.0.0.1:8080/add"

    def __enter__(self):
        return self

    def __exit__(self, *args, **kwargs):
        self.flush()

    def save(self, result):
        self.batch.append(result)
        if len(self.batch) == self.batch_size:
            self.flush()

    def flush(self):
        """
        Synchronous flush function which starts an IOLoop for the purposes of
        running our async flushing function
        """
        loop = asyncio.get_event_loop()
        loop.run_until_complete(self.__aflush())  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)

    async def __aflush(self):  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
        async with aiohttp.ClientSession() as session:
            tasks = [self.fetch(result, session) for result in self.batch]
            for task in asyncio.as_completed(tasks):
                await task
        self.batch.clear()

    async def fetch(self, result, session):
        async with session.post(self.url, data=result) as response:
            return await response.json()

1

我们能够启动一个事件循环来运行单个异步函数。事件循环将一直运行,直到异步函数完成,然后代码将恢复正常运行。

2

该函数与 示例 8-6 几乎相同。

现在我们几乎可以和以前做法一样继续。主要区别在于我们将结果添加到我们的AsyncBatcher中,并让它负责何时发送请求。请注意,我们选择将此对象作为上下文管理器,以便一旦我们完成批处理,最终的flush()将被调用。如果我们没有这样做,可能会出现一些结果仍在排队等待触发刷新的情况:

def calculate_task_batch(num_iter, task_difficulty):
    with AsyncBatcher(100) as batcher: ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
        for i in range(num_iter):
            result = do_task(i, task_difficulty)
            batcher.save(result)

1

我们选择以 100 个请求为一批处理的原因,类似于图 8-3 中所示的情况。

通过这一变更,我们将难度为 8 的运行时间缩短到了 10.21 秒。这代表了 6.95 倍的加速,而我们几乎没有做什么额外的工作。在像实时数据管道这样的受限环境中,这种额外的速度可能意味着系统能否跟得上需求的差异,并且这种情况下可能需要一个队列;在第十章中会学习到这些内容。

要理解此批处理方法的时间计时,请考虑可能影响批处理方法时间的变量。如果我们的数据库吞吐量无限(即,我们可以同时发送无限数量的请求而没有惩罚),我们可以利用我们的AsyncBatcher满时执行刷新时只有 100 毫秒的惩罚。在这种情况下,我们可以在计算完成时一次性将所有请求保存到数据库并执行它们,从而获得最佳性能。

然而,在现实世界中,我们的数据库有最大的吞吐量限制,限制了它们可以处理的并发请求数量。在这种情况下,我们的服务器每秒限制在 100 个请求,这意味着我们必须每 100 个结果刷新我们的批处理器,并在那时进行 100 毫秒的惩罚。这是因为批处理器仍然会暂停程序的执行,就像串行代码一样;但在这个暂停的时间内,它执行了许多请求而不是只有一个。

如果我们试图将所有结果保存到最后然后一次性发出它们,服务器一次只会处理一百个,而且我们会因为同时发出所有这些请求而额外增加开销,这会导致数据库超载,可能会导致各种不可预测的减速。

另一方面,如果我们的服务器吞吐量非常差,一次只能处理一个请求,我们可能还是会串行运行我们的代码!即使我们将我们的批处理保持在每批 100 个结果,当我们实际去发起请求时,每次只会有一个请求被响应,有效地使我们所做的任何批处理无效。

这种批处理结果的机制,也被称为流水线处理,在试图降低 I/O 任务负担时非常有帮助(如图 8-8 所示)。它在异步 I/O 速度和编写串行程序的便利性之间提供了很好的折衷方案。然而,确定一次性流水线处理多少内容非常依赖于具体情况,并且需要一些性能分析和调整才能获得最佳性能。

批处理请求与不进行任何 I/O 的比较

图 8-8. 批处理请求与不进行任何 I/O 的比较

完全异步

在某些情况下,我们可能需要实现一个完全异步的解决方案。如果 CPU 任务是较大的 I/O 绑定程序的一部分,例如 HTTP 服务器,则可能会发生这种情况。想象一下,你有一个 API 服务,对于其中一些端点的响应,必须执行繁重的计算任务。我们仍然希望 API 能够处理并发请求,并在其任务中表现良好,但我们也希望 CPU 任务能够快速运行。

在示例 8-7 中实现此解决方案的代码与示例 8-6 的代码非常相似。

示例 8-7. 异步 CPU 负载
def save_result_aiohttp(client_session):
    sem = asyncio.Semaphore(100)

    async def saver(result):
        nonlocal sem, client_session
        url = f"http://127.0.0.1:8080/add"
        async with sem:
            async with client_session.post(url, data=result) as response:
                return await response.json()

    return saver

async def calculate_task_aiohttp(num_iter, task_difficulty):
    tasks = []
    async with aiohttp.ClientSession() as client_session:
        saver = save_result_aiohttp(client_session)
        for i in range(num_iter):
            result = do_task(i, task_difficulty)
            task = asyncio.create_task(saver(result))  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
            tasks.append(task)
            await asyncio.sleep(0)  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
        await asyncio.wait(tasks)  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png)

1

我们不会立即await数据库保存,而是使用asyncio.create_task将其排入事件循环,并跟踪它,以确保任务在函数结束前已完成。

2

这可能是函数中最重要的一行。在这里,我们暂停主函数,以便事件循环处理任何未完成的任务。如果没有这个,我们排队的任务将直到函数结束才会运行。

3

在这里,我们等待任何尚未完成的任务。如果我们在for循环中没有执行asyncio.sleep,那么所有的保存操作将在这里发生!

在我们讨论此代码的性能特征之前,我们应该先谈谈asyncio.sleep(0)语句的重要性。让函数睡眠零秒可能看起来很奇怪,但这是一种将函数推迟到事件循环并允许其他任务运行的方法。在异步代码中,每次运行await语句时都会发生这种推迟。由于我们通常不会在 CPU 绑定的代码中await,所以强制进行这种推迟非常重要,否则在 CPU 绑定的代码完成之前不会运行任何其他任务。在这种情况下,如果没有睡眠语句,所有的 HTTP 请求将会暂停,直到asyncio.wait语句,然后所有的请求将会立即发出,这绝对不是我们想要的!

有了这种控制权,我们可以选择最佳时间推迟回到事件循环。在这样做时有很多考虑因素。由于程序的运行状态在推迟时发生变化,我们不希望在计算过程中进行推迟,可能会改变我们的 CPU 缓存。此外,推迟到事件循环会产生额外的开销,所以我们不希望太频繁地这样做。然而,在我们忙于 CPU 任务时,我们无法执行任何 I/O 任务。因此,如果我们的整个应用程序是一个 API,那么在 CPU 时间内不能处理任何请求!

我们的一般经验法则是尝试在期望每 50 到 100 毫秒迭代一次的任何循环中发出asyncio.sleep(0)。有些应用程序使用time.perf_counter,允许 CPU 任务在强制休眠之前具有特定的运行时间。对于这种情况,由于我们可以控制 CPU 和 I/O 任务的数量,我们只需确保休眠之间的时间与待处理的 I/O 任务完成所需的时间相符即可。

完全异步解决方案的一个主要性能优势是,在执行 CPU 工作的同时,我们可以执行所有的 I/O 操作,有效地隐藏它们不计入总运行时间(正如我们从图 8-9 中重叠的线条中可以看到的)。虽然由于事件循环的开销成本,它永远不会完全隐藏,但我们可以非常接近。事实上,对于难度为 8 的 600 次迭代,我们的代码运行速度比串行代码快 7.3 倍,并且执行其总 I/O 工作负载比批处理代码快 2 倍(而且这种优势随着迭代次数的增加而增加,因为批处理代码每次需要暂停 CPU 任务来刷新批次时都会浪费时间)。

使用解决方案进行 25 个难度为 8 的 CPU 任务的调用图。红线代表工作在 CPU 任务上的时间,蓝线代表发送结果到服务器的时间

图 8-9. 使用aiohttp解决方案进行 25 个难度为 8 的 CPU 任务的调用图—红线代表工作在 CPU 任务上的时间,蓝线代表发送结果到服务器的时间。

在调用时间线中,我们可以真正看到发生了什么。我们所做的是标记了难度为 8 的 25 个 CPU 任务的每个 CPU 和 I/O 任务的开始和结束。前几个 I/O 任务是最慢的,花费了一些时间来建立与我们服务器的初始连接。由于我们使用aiohttpClientSession,这些连接被缓存,后续对同一服务器的所有连接都要快得多。

此后,如果我们只关注蓝线,它们似乎在 CPU 任务之间非常规律地发生,几乎没有暂停。事实上,我们在任务之间并没有看到来自 HTTP 请求的 100 毫秒延迟。相反,我们看到 HTTP 请求在每个 CPU 任务的末尾快速发出,并在另一个 CPU 任务的末尾被标记为完成。

不过,我们确实看到每个单独的 I/O 任务所需的时间比服务器的 100 毫秒响应时间长。这种较长的等待时间是由我们的 asyncio.sleep(0) 语句的频率决定的(因为每个 CPU 任务有一个 await,而每个 I/O 任务有三个),以及事件循环决定下一个任务的方式。对于 I/O 任务来说,这种额外的等待时间是可以接受的,因为它不会中断手头的 CPU 任务。事实上,在运行结束时,我们可以看到 I/O 运行时间缩短,直到最后一个 I/O 任务运行。这最后的蓝线是由 asyncio.wait 语句触发的,并且因为它是唯一剩下的任务,且永远不需要切换到其他任务,所以运行非常快速。

在图 8-10 和 8-11 中,我们可以看到这些变化如何影响不同工作负载下我们代码的运行时间总结。异步代码在串行代码上的加速效果显著,尽管我们离原始 CPU 问题的速度还有一段距离。要完全解决这个问题,我们需要使用诸如 multiprocessing 这样的模块,以便有一个完全独立的进程来处理我们程序的 I/O 负担,而不会减慢 CPU 部分的问题。

串行 I/O、批量异步 I/O、完全异步 I/O 和完全禁用 I/O 之间的处理时间差异

图 8-10. 串行 I/O、批量异步 I/O、完全异步 I/O 和完全禁用 I/O 之间的处理时间差异

批量异步、完全异步 I/O 和禁用 I/O 之间的处理时间差异

图 8-11. 批量异步、完全异步 I/O 和禁用 I/O 之间的处理时间差异

结语

在解决实际和生产系统中的问题时,通常需要与外部源通信。这个外部源可以是运行在另一台服务器上的数据库,另一个工作计算机,或者是提供必须处理的原始数据的数据服务。在这种情况下,你的问题很快可能会成为 I/O 绑定,这意味着大部分运行时间都受输入/输出处理的影响。

并发通过允许您将计算与可能的多个 I/O 操作交错,有助于处理 I/O 绑定的问题。这使您能够利用 I/O 和 CPU 操作之间的基本差异,以加快总体运行时间。

正如我们所看到的,gevent 提供了最高级别的异步 I/O 接口。另一方面,tornadoaiohttp 允许完全控制异步 I/O 堆栈。除了各种抽象级别外,每个库还使用不同的范式来表示其语法。然而,asyncio是异步解决方案的绑定胶水,并提供了控制它们所有的基本机制。

我们还看到了如何将 CPU 和 I/O 任务合并在一起,以及如何考虑每个任务的各种性能特征,以便提出解决问题的好方法。虽然立即转向完全异步代码可能很吸引人,但有时中间解决方案几乎可以达到同样的效果,而不需要太多的工程负担。

在下一章中,我们将把这种从 I/O 绑定问题中交错计算的概念应用到 CPU 绑定问题上。有了这种新能力,我们不仅可以同时执行多个 I/O 操作,还可以执行许多计算操作。这种能力将使我们能够开始制作完全可扩展的程序,通过简单地添加更多的计算资源,每个资源都可以处理问题的一部分,从而实现更快的速度。

¹ 我们确信你没有这样做!

² 例如,fuggetaboutit 是一种特殊类型的概率数据结构(参见“概率数据结构”),它使用tornado IOLoop来安排基于时间的任务。

³ 这不是必需的;它只是为了简化我们的代码。

⁴ 这对于所有分布式数据库和其他流行的数据库(例如 Postgres,MongoDB 等)都是正确的。

第九章:多进程模块

CPython 默认不使用多个 CPU。这部分是因为 Python 是在单核时代设计的,部分原因是并行化实际上可能相当难以高效实现。Python 给了我们实现的工具,但是让我们自己做选择。看到你的多核机器在长时间运行的进程中只使用一个 CPU 真是痛苦,所以在本章中,我们将回顾一些同时使用所有机器核心的方法。

注意

我们刚提到了CPython——这是我们所有人都使用的常见实现。Python 语言本身并不阻止其在多核系统上的使用。CPython 的实现不能有效地利用多核,但是未来的实现可能不受此限制。

我们生活在一个多核世界——笔记本电脑通常有 4 个核心,而 32 核心的桌面配置也是常见的。如果你的工作可以分解为在多个 CPU 上运行,而且不需要太多的工程工作,那么这是一个明智的方向值得考虑。

当 Python 用于在一组 CPU 上并行化问题时,你可以期待最多达到n倍的加速,其中n为核心数。如果你有一台四核机器,并且可以将所有四个核心用于任务,那么运行时间可能只有原始运行时间的四分之一。你不太可能看到超过 4 倍的加速;实际上,你可能会看到 3 到 4 倍的提升。

每增加一个进程都会增加通信开销并减少可用 RAM,所以你很少能获得完全n倍的加速。取决于你正在解决的问题,通信开销甚至可能非常大,以至于可以看到非常显著的减速。这些问题通常是任何并行编程的复杂性所在,通常需要算法的改变。这就是为什么并行编程通常被认为是一门艺术。

如果你对Amdahl's law不熟悉,值得进行一些背景阅读。该定律表明,如果你的代码只有一小部分可以并行化,那么无论你投入多少 CPU,整体速度提升都不会太大。即使你的运行时间的大部分可以并行化,也有一个有限数量的 CPU 可以有效地用于使整个过程在到达收益递减点之前更快地运行。

multiprocessing模块允许您使用基于进程和线程的并行处理,在队列中共享工作并在进程之间共享数据。它主要专注于单机多核并行性(在多机并行性方面有更好的选择)。非常常见的用法是将任务并行化到一组进程中,用于处理 CPU 密集型问题。您也可以使用 OpenMP 来并行化 I/O 密集型问题,但正如我们在第八章中所看到的,这方面有更好的工具(例如 Python 3 中的新asyncio模块和tornado)。

注意

OpenMP 是一个面向多核的低级接口——您可能会想集中精力在它上面,而不是multiprocessing。我们在第七章中使用 Cython 引入了它,但在本章中我们没有涉及。multiprocessing在更高的层面上工作,共享 Python 数据结构,而 OpenMP 在您编译为 C 后使用 C 原始对象(例如整数和浮点数)工作。仅当您正在编译代码时才使用它是有意义的;如果您不编译代码(例如,如果您使用高效的numpy代码并且希望在许多核心上运行),那么坚持使用multiprocessing可能是正确的方法。

要并行化您的任务,您必须以与编写串行进程的正常方式有所不同的方式思考。您还必须接受调试并行化任务更加困难—通常情况下,这可能会非常令人沮丧。我们建议保持并行性尽可能简单(即使您并没有从计算机中挤出最后一滴性能),这样可以保持您的开发速度。

并行系统中一个特别困难的话题是共享状态——感觉应该很容易,但会带来很多开销,并且很难正确实现。有许多使用情况,每种情况都有不同的权衡,所以绝对没有一个适合所有人的解决方案。在“使用进程间通信验证质数”中,我们将关注共享状态并考虑同步成本。避免共享状态将使您的生活变得更加轻松。

实际上,几乎完全可以通过要共享的状态量来分析算法在并行环境中的性能表现。例如,如果我们可以有多个 Python 进程同时解决相同的问题而不互相通信(这种情况称为尴尬并行),那么随着我们添加越来越多的 Python 进程,将不会产生太大的惩罚。

另一方面,如果每个进程都需要与其他每个 Python 进程通信,通信开销将会逐渐压倒处理并减慢速度。这意味着随着我们添加越来越多的 Python 进程,我们实际上可能会降低整体性能。

因此,有时候必须进行一些反直觉的算法更改,以有效地并行解决问题。例如,在并行解决扩散方程(第六章)时,每个进程实际上都会做一些另一个进程也在做的冗余工作。这种冗余减少了所需的通信量,并加快了整体计算速度!

以下是multiprocessing模块的一些典型用途:

  • 使用ProcessPool对象对 CPU 密集型任务进行并行化处理

  • 使用(奇怪命名的)dummy模块在Pool中使用线程并行化 I/O 密集型任务

  • 通过Queue共享序列化工作

  • 在并行化的工作者之间共享状态,包括字节、基本数据类型、字典和列表

如果你来自一个使用线程进行 CPU 绑定任务的语言(例如 C++或 Java),你应该知道,虽然 Python 中的线程是操作系统本地的(它们不是模拟的——它们是真实的操作系统线程),但它们受到 GIL 的限制,因此一次只有一个线程可以与 Python 对象交互。

通过使用进程,我们可以并行地运行多个 Python 解释器,每个解释器都有一个私有的内存空间,有自己的 GIL,并且每个解释器都是连续运行的(因此没有 GIL 之间的竞争)。这是在 Python 中加速 CPU 密集型任务的最简单方法。如果我们需要共享状态,我们需要增加一些通信开销;我们将在“使用进程间通信验证质数”中探讨这个问题。

如果你使用numpy数组,你可能会想知道是否可以创建一个更大的数组(例如,一个大型的二维矩阵),并让进程并行地处理数组的段。你可以,但是通过反复试验发现是很困难的,因此在“使用多进程共享 numpy 数据”中,我们将通过在四个 CPU 之间共享一个 25 GB 的numpy数组来解决这个问题。与其发送数据的部分副本(这样至少会将 RAM 中所需的工作大小翻倍,并创建大量的通信开销),我们将数组的底层字节在进程之间共享。这是在一台机器上在本地工作者之间共享一个大型数组的理想方法。

在本章中,我们还介绍了Joblib库——这建立在multiprocessing库的基础上,并提供了改进的跨平台兼容性,一个简单的用于并行化的 API,以及方便的缓存结果的持久性。Joblib 专为科学使用而设计,我们建议你去了解一下。

注意

在这里,我们讨论*nix-based(本章是使用 Ubuntu 编写的;代码应该在 Mac 上不变)机器上的multiprocessing。自 Python 3.4 以来,出现在 Windows 上的怪癖已经被处理。Joblib 比multiprocessing具有更强的跨平台支持,我们建议您在使用multiprocessing之前先查看它。

在本章中,我们将硬编码进程的数量(NUM_PROCESSES=4)以匹配 Ian 笔记本电脑上的四个物理核心。默认情况下,multiprocessing将使用它能够看到的尽可能多的核心(机器呈现出八个——四个 CPU 和四个超线程)。通常情况下,除非你专门管理你的资源,否则你应该避免硬编码要创建的进程数量。

multiprocessing模块的概述

multiprocessing模块提供了一个低级别的接口,用于进程和基于线程的并行处理。它的主要组件如下:

Process

当前进程的分叉副本;这将创建一个新的进程标识符,并在操作系统中作为独立的子进程运行任务。您可以启动并查询Process的状态,并为其提供一个target方法来运行。

Pool

Processthreading.ThreadAPI 包装为一个方便的工作池,共享一块工作并返回聚合结果。

Queue

允许多个生产者和消费者的 FIFO 队列。

Pipe

一个单向或双向通信通道,用于两个进程之间的通信。

Manager

一个高级管理接口,用于在进程之间共享 Python 对象。

ctypes

允许在进程分叉后共享原始数据类型(例如整数、浮点数和字节)。

同步原语

用于在进程之间同步控制流的锁和信号量。

注意

在 Python 3.2 中,通过PEP 3148引入了concurrent.futures模块;这提供了multiprocessing的核心行为,接口更简单,基于 Java 的java.util.concurrent。它作为一个回退到早期 Python 版本的扩展可用。我们期望multiprocessing在 CPU 密集型工作中继续被偏爱,并且如果concurrent.futures在 I/O 绑定任务中变得更受欢迎,我们也不会感到惊讶。

在本章的其余部分,我们将介绍一系列示例,展示使用multiprocessing模块的常见方法。

我们将使用一组进程或线程的Pool,使用普通 Python 和numpy以蒙特卡洛方法估算圆周率。这是一个简单的问题,复杂性易于理解,因此可以很容易地并行化;我们还可以看到使用线程与numpy时的意外结果。接下来,我们将使用相同的Pool方法搜索质数;我们将调查搜索质数的不可预测复杂性,并查看如何有效(以及无效!)地分割工作负载以最佳利用我们的计算资源。我们将通过切换到队列来完成质数搜索,在那里我们使用Process对象代替Pool并使用一系列工作和毒丸来控制工作者的生命周期。

接下来,我们将处理进程间通信(IPC),以验证一小组可能的质数。通过将每个数字的工作负载跨多个 CPU 分割,我们使用 IPC 提前结束搜索,如果发现因子,以便显著超过单 CPU 搜索进程的速度。我们将涵盖共享 Python 对象、操作系统原语和 Redis 服务器,以调查每种方法的复杂性和能力折衷。

我们可以在四个 CPU 上共享一个 25 GB 的numpy数组,以分割大型工作负载,无需复制数据。如果您有具有可并行操作的大型数组,这种技术应该能显著加快速度,因为您需要在 RAM 中分配更少的空间并复制更少的数据。最后,我们将看看如何在进程之间同步访问文件和变量(作为Value)而不会损坏数据,以说明如何正确锁定共享状态。

注意

PyPy(在第七章中讨论)完全支持multiprocessing库,尽管在撰写本文时的numpy示例尚未完全支持。如果您仅使用 CPython 代码(没有 C 扩展或更复杂的库)进行并行处理,PyPy 可能是一个快速的胜利。

使用蒙特卡洛方法估算π

我们可以通过向单位圆表示的“飞镖板”投掷数千次想象中的飞镖来估算π。落入圆边缘内的飞镖数量与落入圆外的数量之间的关系将允许我们近似π的值。

这是一个理想的首个问题,因为我们可以将总工作负载均匀分配到多个进程中,每个进程在单独的 CPU 上运行。由于每个进程的工作负载相等,因此它们将同时结束,因此我们可以在增加新的 CPU 和超线程时探索可用的加速效果。

在图 9-1 中,我们向单位正方形投掷了 10,000 个飞镖,其中一部分落入了绘制的单位圆的四分之一中。这个估计相当不准确——10,000 次飞镖投掷不能可靠地给出三位小数的结果。如果您运行您自己的代码,您会看到每次运行这个估计在 3.0 到 3.2 之间变化。

要确保前三位小数的准确性,我们需要生成 10,000,000 次随机飞镖投掷。¹ 这是非常低效的(估算π的更好方法存在),但用来演示使用multiprocessing进行并行化的好处非常方便。

使用蒙特卡洛方法,我们使用毕达哥拉斯定理来测试一个飞镖是否落入我们的圆内:

x 2 + y 2 1 2 = 1用蒙特卡洛方法估算π

图 9-1. 使用蒙特卡洛方法估算π

我们将在示例 9-1 中查看一个循环版本。我们将实现一个普通的 Python 版本和稍后的numpy版本,并使用线程和进程来并行化解决方案。

使用进程和线程估算π

在本节中,我们将从一个普通的 Python 实现开始,这样更容易理解,使用循环中的浮点对象。我们将通过进程并行化,以利用所有可用的 CPU,并且在使用更多 CPU 时可视化机器的状态。

使用 Python 对象

Python 实现易于跟踪,但每个 Python 浮点对象都需要被管理、引用和依次同步,这带来了一些额外开销。这种开销减慢了我们的运行时,但却为我们赢得了思考时间,因为实现起来非常快速。通过并行化这个版本,我们可以在几乎不增加额外工作的情况下获得额外的加速。

图 9-2 展示了 Python 示例的三种实现方式:

  • 不使用 multiprocessing(称为“串行”)——在主进程中使用一个 for 循环

  • 使用线程

  • 使用进程

未设置

图 9-2. 串行工作,使用线程和进程

当我们使用多个线程或进程时,我们要求 Python 计算相同数量的飞镖投掷,并在工作人员之间均匀分配工作。如果我们希望使用我们的 Python 实现总共 1 亿次飞镖投掷,并且使用两个工作者,我们将要求两个线程或两个进程生成每个工作者 5000 万次飞镖投掷。

使用一个线程大约需要 71 秒,使用更多线程时没有加速。通过使用两个或更多进程,我们使运行时更短。不使用进程或线程的成本(串行实现)与使用一个进程的成本相同。

通过使用进程,在 Ian 的笔记本上使用两个或四个核心时,我们获得线性加速。对于八个工作者的情况,我们使用了英特尔的超线程技术——笔记本只有四个物理核心,因此在运行八个进程时,速度几乎没有变化。

示例 9-1 展示了我们 pi 估算器的 Python 版本。如果我们使用线程,每个指令都受 GIL 限制,因此尽管每个线程可以在不同的 CPU 上运行,但只有在没有其他线程运行时才会执行。进程版本不受此限制,因为每个分叉进程都有一个作为单个线程运行的私有 Python 解释器——没有 GIL 竞争,因为没有共享对象。我们使用 Python 的内置随机数生成器,但请参阅“并行系统中的随机数”以了解并行化随机数序列的危险注意事项。

示例 9-1. 在 Python 中使用循环估算 pi
def estimate_nbr_points_in_quarter_circle(nbr_estimates):
    """Monte Carlo estimate of the number of points in a
 quarter circle using pure Python"""
    print(f"Executing estimate_nbr_points_in_quarter_circle \
 with {nbr_estimates:,} on pid {os.getpid()}")
    nbr_trials_in_quarter_unit_circle = 0
    for step in range(int(nbr_estimates)):
        x = random.uniform(0, 1)
        y = random.uniform(0, 1)
        is_in_unit_circle = x * x + y * y <= 1.0
        nbr_trials_in_quarter_unit_circle += is_in_unit_circle

    return nbr_trials_in_quarter_unit_circle

示例 9-2 显示了__main__块。请注意,在启动计时器之前,我们构建了Pool。生成线程相对即时;生成进程涉及分叉,这需要测量时间的一部分。我们在图 9-2 中忽略了这个开销,因为这个成本将是整体执行时间的一小部分。

示例 9-2. 使用循环估计圆周率的主要代码
from multiprocessing import Pool
...

if __name__ == "__main__":
    nbr_samples_in_total = 1e8
    nbr_parallel_blocks = 4
    pool = Pool(processes=nbr_parallel_blocks)
    nbr_samples_per_worker = nbr_samples_in_total / nbr_parallel_blocks
    print("Making {:,} samples per {} worker".format(nbr_samples_per_worker,
                                                     nbr_parallel_blocks))
    nbr_trials_per_process = [nbr_samples_per_worker] * nbr_parallel_blocks
    t1 = time.time()
    nbr_in_quarter_unit_circles = pool.map(estimate_nbr_points_in_quarter_circle,
                                           nbr_trials_per_process)
    pi_estimate = sum(nbr_in_quarter_unit_circles) * 4 / float(nbr_samples_in_total)
    print("Estimated pi", pi_estimate)
    print("Delta:", time.time() - t1)

我们创建一个包含nbr_estimates除以工作程序数的列表。这个新参数将发送给每个工作程序。执行后,我们将收到相同数量的结果;我们将这些结果相加以估计单位圆中的飞镖数。

我们从multiprocessing中导入基于进程的Pool。我们也可以使用from multiprocessing.dummy import Pool来获取一个线程化版本。 “dummy”名称相当误导(我们承认我们不理解为什么它以这种方式命名);它只是一个围绕threading模块的轻量级包装,以呈现与基于进程的Pool相同的接口。

警告

我们创建的每个进程都会从系统中消耗一些 RAM。您可以预期使用标准库的分叉进程将占用大约 10-20 MB 的 RAM;如果您使用许多库和大量数据,则可能每个分叉副本将占用数百兆字节。在具有 RAM 约束的系统上,这可能是一个重大问题 - 如果 RAM 用完,系统将回到使用磁盘的交换空间,那么任何并行化优势都将因缓慢的 RAM 回写到磁盘而大量丧失!

下图绘制了 Ian 笔记本电脑的四个物理核心及其四个关联的超线程的平均 CPU 利用率(每个超线程在物理核心中的未使用硅上运行)。这些图表收集的数据包括第一个 Python 进程的启动时间和启动子进程的成本。CPU 采样器记录笔记本电脑的整个状态,而不仅仅是此任务使用的 CPU 时间。

请注意,以下图表是使用比图 9-2 慢一些的采样率创建的,因此整体运行时间略长。

在图 9-3 中,使用一个进程在Pool(以及父进程)中的执行行为显示出一些开销,在创建Pool时首几秒钟内,然后在整个运行过程中保持接近 100%的 CPU 利用率。使用一个进程,我们有效地利用了一个核心。

使用列表和一个进程估计圆周率

图 9-3. 使用 Python 对象和一个进程估计圆周率

接下来,我们将添加第二个进程,相当于说Pool(processes=2)。如你在图 9-4 中所见,添加第二个进程将执行时间大致减半至 37 秒,并且两个 CPU 完全被占用。这是我们能期待的最佳结果——我们已经高效地利用了所有新的计算资源,而且没有因通信、分页到磁盘或与竞争使用相同 CPU 的其他进程的争用等开销而损失速度。

使用列表和 2 个进程估算 Pi

图 9-4. 使用 Python 对象和两个进程估算 Pi

图 9-5 显示了在使用所有四个物理 CPU 时的结果——现在我们正在使用这台笔记本电脑的全部原始计算能力。执行时间大约是单进程版本的四分之一,为 19 秒。

使用列表和 4 个进程估算 Pi

图 9-5. 使用 Python 对象和四个进程估算 Pi

通过切换到八个进程,如图 9-6 所示,与四进程版本相比,我们不能实现更大的速度提升。这是因为四个超线程只能从 CPU 上的备用硅中挤出一点额外的处理能力,而四个 CPU 已经达到最大利用率。

使用列表和 8 个进程估算 Pi

图 9-6. 使用 Python 对象和八个进程估算 Pi,但额外收益微乎其微

这些图表显示,我们在每个步骤中都有效地利用了更多的可用 CPU 资源,并且超线程资源是一个糟糕的补充。在使用超线程时最大的问题是 CPython 使用了大量 RAM——超线程对缓存不友好,因此每个芯片上的备用资源利用非常低效。正如我们将在下一节看到的,numpy更好地利用了这些资源。

注意

根据我们的经验,如果有足够的备用计算资源,超线程可以提供高达 30%的性能增益如果有浮点和整数算术的混合而不仅仅是我们这里的浮点操作。通过混合资源需求,超线程可以同时调度更多 CPU 硅工作。一般来说,我们将超线程视为额外的奖励而不是需要优化的资源,因为添加更多 CPU 可能比调整代码(增加支持开销)更经济。

现在我们将切换到一个进程中使用线程,而不是多个进程。

图 9-7 显示了在同一个代码中运行相同的代码的结果,我们用线程代替进程。尽管使用了多个 CPU,但它们轻度共享负载。如果每个线程都在没有 GIL 的情况下运行,那么我们将在四个 CPU 上看到 100%的 CPU 利用率。相反,每个 CPU 都被部分利用(因为有 GIL)。

使用列表和 4 个线程估算 Pi

图 9-7. 使用 Python 对象和四个线程估算 Pi

用 Joblib 替换 multiprocessing

Joblib 是multiprocessing的改进版本,支持轻量级流水线处理,专注于简化并行计算和透明的基于磁盘的缓存结果。它专注于科学计算中的 NumPy 数组。如果您正在使用纯 Python 编写,无论是否使用 NumPy,来处理可以简单并行化的循环,它可能会为您带来快速成功。

  • 使用 Python 纯代码时,无论是否使用 NumPy,都可以处理可以使人尴尬的并行化循环。

  • 调用昂贵的没有副作用的函数,其中输出可以在会话之间缓存到磁盘

  • 能够在进程之间共享 NumPy 数据,但不知道如何操作(并且您尚未阅读过“使用多进程共享 NumPy 数据”)。

Joblib 基于 Loky 库构建(它本身是 Python concurrent.futures 的改进),并使用cloudpickle来实现在交互作用域中定义函数的序列化。这解决了使用内置multiprocessing库时遇到的几个常见问题。

对于并行计算,我们需要Parallel类和delayed装饰器。Parallel类设置了一个进程池,类似于我们在前一节中使用的multiprocessingpooldelayed装饰器包装了我们的目标函数,使其可以通过迭代器应用于实例化的Parallel对象。

语法有点令人困惑——看看示例 9-3。调用写在一行上;这包括我们的目标函数estimate_nbr_points_in_quarter_circle和迭代器(delayed(...)(nbr_samples_per_worker) for sample_idx in range(nbr_parallel_blocks))。让我们来分解一下这个过程。

示例 9-3. 使用 Joblib 并行化估算 Pi
...
from joblib import Parallel, delayed

if __name__ == "__main__":
    ...
    nbr_in_quarter_unit_circles = Parallel(n_jobs=nbr_parallel_blocks, verbose=1) \
          (delayed(estimate_nbr_points_in_quarter_circle)(nbr_samples_per_worker) \
           for sample_idx in range(nbr_parallel_blocks))
    ...

Parallel是一个类;我们可以设置参数,如n_jobs来指定将运行多少进程,以及像verbose这样的可选参数来获取调试信息。其他参数可以设置超时时间,选择线程或进程之间切换,更改后端(这可以帮助加速某些极端情况),并配置内存映射。

Parallel具有一个可调用方法__call__,接受一个可迭代对象。我们在以下圆括号中提供了可迭代对象(... for sample_idx in range(...))。该可调用对象迭代每个delayed(estimate_nbr_points_in_quarter_circle)函数,批处理这些函数的参数执行(在本例中为nbr_samples_per_worker)。Ian 发现逐步构建并行调用非常有帮助,从一个没有参数的函数开始,根据需要逐步构建参数。这样可以更容易地诊断错误步骤。

nbr_in_quarter_unit_circles将是一个包含每次调用正例计数的列表,如前所述。示例 9-4 显示了八个并行块的控制台输出;每个进程 ID(PID)都是全新创建的,并且在输出末尾打印了进度条的摘要。总共需要 19 秒,与我们在前一节中创建自己的Pool时相同的时间。

提示

避免传递大结构;将大型 Pickle 对象传递给每个进程可能会很昂贵。Ian 曾经有过一个预先构建的 Pandas DataFrame 字典对象的情况;通过Pickle模块对其进行序列化的成本抵消了并行化带来的收益,而串行版本实际上总体上运行得更快。在这种情况下的解决方案是使用 Python 的内置shelve模块构建 DataFrame 缓存,并将字典存储到文件中。每次调用目标函数时,使用shelve加载单个 DataFrame;几乎不需要传递任何东西给函数,然后Joblib的并行化效益变得明显。

示例 9-4. Joblib调用的输出
Making 12,500,000 samples per 8 worker
[Parallel(n_jobs=8)]: Using backend LokyBackend with 8 concurrent workers.
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10313
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10315
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10311
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10316
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10312
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10314
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10317
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10318
[Parallel(n_jobs=8)]: Done   2 out of   8 | elapsed:   18.9s remaining:   56.6s
[Parallel(n_jobs=8)]: Done   8 out of   8 | elapsed:   19.3s finished
Estimated pi 3.14157744
Delta: 19.32842755317688
提示

为了简化调试,我们可以设置n_jobs=1,并且并行化代码被禁用。您不必进一步修改代码,可以在函数中放置一个breakpoint()调用以便轻松调试。

函数调用结果的智能缓存

Joblib 中一个有用的功能是Memory缓存;这是一个基于输入参数将函数结果缓存到磁盘缓存的装饰器。此缓存在 Python 会话之间持久存在,因此如果关闭计算机然后第二天运行相同的代码,将使用缓存的结果。

对于我们的π估计,这提出了一个小问题。我们不会向estimate_nbr_points_in_quarter_circle传递唯一的参数;对于每次调用,我们都会传递nbr_estimates,因此调用签名相同,但我们寻求的是不同的结果。

在这种情况下,一旦第一次调用完成(大约需要 19 秒),任何使用相同参数的后续调用都将获得缓存的结果。这意味着如果我们第二次运行代码,它将立即完成,但每次调用只使用八个样本结果中的一个作为结果——这显然破坏了我们的蒙特卡洛抽样!如果最后一个完成的进程导致在四分之一圆中有9815738个点,则函数调用的缓存将始终回答这个结果。重复调用八次将生成[9815738, 9815738, 9815738, 9815738, 9815738, 9815738, 9815738, 9815738],而不是八个唯一的估计值。

示例 9-5 中的解决方案是传入第二个参数idx,它接受 0 到nbr_parallel_blocks-1之间的值。这些唯一参数的组合将允许缓存存储每个正计数,因此在第二次运行时,我们得到与第一次运行相同的结果,但无需等待。

这是通过Memory配置的,它需要一个用于持久化函数结果的文件夹。这种持久性在 Python 会话之间保持;如果更改被调用的函数或清空缓存文件夹中的文件,则会刷新它。

请注意,此刷新仅适用于已装饰函数的更改(在本例中为estimate_nbr_points_in_quarter_circle_with_idx),而不适用于从该函数内部调用的任何子函数。

示例 9-5. 使用 Joblib 缓存结果
...
from joblib import Memory

memory = Memory("./joblib_cache", verbose=0)

@memory.cache
def estimate_nbr_points_in_quarter_circle_with_idx(nbr_estimates, idx):
    print(f"Executing estimate_nbr_points_in_quarter_circle with \
 {nbr_estimates} on sample {idx} on pid {os.getpid()}")
    ...

if __name__ == "__main__":
    ...
    nbr_in_quarter_unit_circles = Parallel(n_jobs=nbr_parallel_blocks) \
       (delayed(
	    estimate_nbr_points_in_quarter_circle_with_idx) \
        (nbr_samples_per_worker, idx) for idx in range(nbr_parallel_blocks))
    ...

在示例 9-6 中,我们可以看到,第一次调用花费了 19 秒,而第二次调用仅花费了几分之一秒,并且估算的π值相同。在这次运行中,估计值为[9817605, 9821064, 9818420, 9817571, 9817688, 9819788, 9816377, 9816478]

示例 9-6. 由于缓存结果,代码的零成本第二次调用
$ python pi_lists_parallel_joblib_cache.py
Making 12,500,000 samples per 8 worker
Executing estimate_nbr_points_in_... with 12500000 on sample 0 on pid 10672
Executing estimate_nbr_points_in_... with 12500000 on sample 1 on pid 10676
Executing estimate_nbr_points_in_... with 12500000 on sample 2 on pid 10677
Executing estimate_nbr_points_in_... with 12500000 on sample 3 on pid 10678
Executing estimate_nbr_points_in_... with 12500000 on sample 4 on pid 10679
Executing estimate_nbr_points_in_... with 12500000 on sample 5 on pid 10674
Executing estimate_nbr_points_in_... with 12500000 on sample 6 on pid 10673
Executing estimate_nbr_points_in_... with 12500000 on sample 7 on pid 10675
Estimated pi 3.14179964
Delta: 19.28862953186035

$ python %run pi_lists_parallel_joblib_cache.py
Making 12,500,000 samples per 8 worker
Estimated pi 3.14179964
Delta: 0.02478170394897461

Joblib 用一个简单(尽管有点难以阅读)的接口包装了许多multiprocessing功能。Ian 已经开始使用 Joblib 来替代multiprocessing,他建议你也试试。

并行系统中的随机数

生成良好的随机数序列是一个棘手的问题,如果尝试自行实现很容易出错。在并行中快速获得良好的序列更难——突然间,您必须担心是否会在并行进程中获得重复或相关的序列。

我们在示例 9-1 中使用了 Python 内置的随机数生成器,在下一节中的示例 9-7 中,我们将使用numpy的随机数生成器。在这两种情况下,随机数生成器都在其分叉进程中进行了种子化。对于 Python 的random示例,种子化由multiprocessing内部处理——如果在分叉时看到random存在于命名空间中,它将强制调用以在每个新进程中种子化生成器。

提示

在并行化函数调用时设置 numpy 种子。在接下来的 numpy 示例中,我们必须显式设置随机数种子。如果您忘记使用 numpy 设置随机数序列的种子,每个分叉进程将生成相同的随机数序列 —— 它看起来会按照您期望的方式工作,但在背后每个并行进程将以相同的结果演化!

如果您关心并行进程中使用的随机数质量,请务必研究这个话题。可能 numpy 和 Python 的随机数生成器已经足够好,但如果重要的结果依赖于随机序列的质量(例如医疗或金融系统),那么您必须深入了解这个领域。

在 Python 3 中,使用 Mersenne Twister 算法 —— 它具有长周期,因此序列在很长时间内不会重复。它经过了大量测试,因为它也被其他语言使用,并且是线程安全的。但可能不适合用于加密目的。

使用 numpy

在本节中,我们转而使用 numpy。我们的投镖问题非常适合 numpy 的向量化操作——我们的估算比之前的 Python 示例快 25 倍。

numpy 比纯 Python 解决同样问题更快的主要原因是,numpy 在连续的 RAM 块中以非常低的级别创建和操作相同的对象类型,而不是创建许多需要单独管理和寻址的更高级别的 Python 对象。

由于 numpy 更加友好于缓存,当使用四个超线程时我们也会得到轻微的速度提升。在纯 Python 版本中,我们没有得到这个好处,因为较大的 Python 对象未能有效利用缓存。

在 图 9-8 中,我们看到三种情况:

  • 不使用 multiprocessing(名为“串行”)

  • 使用线程

  • 使用进程

串行和单工作者版本的执行速度相同——使用 numpy 时没有使用线程的额外开销(并且只有一个工作者时也没有收益)。

当使用多个进程时,我们看到每个额外 CPU 的经典 100% 利用率。结果与图 9-3、9-4、9-5 和 9-6 中显示的情况相似,但使用 numpy 的代码速度要快得多。

有趣的是,线程版本随着线程数的增加运行得更快。正如在 SciPy wiki 上讨论的那样,通过在全局解释器锁之外工作,numpy 可以实现一定程度的额外加速。

串行工作、使用线程和使用 numpy 的进程

图 9-8. 使用 numpy 串行工作、使用线程和使用进程

使用进程给我们带来可预测的加速,就像在纯 Python 示例中一样。第二个 CPU 会将速度提高一倍,而使用四个 CPU 会将速度提高四倍。

示例 9-7 展示了我们代码的向量化形式。请注意,当调用此函数时,随机数生成器会被种子化。对于多线程版本,这不是必要的,因为每个线程共享同一个随机数生成器,并且它们是串行访问的。对于进程版本,由于每个新进程都是一个分支,所有分叉版本都将共享相同的状态。这意味着每个版本中的随机数调用将返回相同的序列!

提示

记得使用numpy为每个进程调用seed()来确保每个分叉进程生成唯一的随机数序列,因为随机源用于为每次调用设置种子。回顾一下“并行系统中的随机数”中有关并行随机数序列危险的注意事项。

示例 9-7. 使用 numpy 估算 π
def estimate_nbr_points_in_quarter_circle(nbr_samples):
    """Estimate pi using vectorized numpy arrays"""
    np.random.seed() # remember to set the seed per process
    xs = np.random.uniform(0, 1, nbr_samples)
    ys = np.random.uniform(0, 1, nbr_samples)
    estimate_inside_quarter_unit_circle = (xs * xs + ys * ys) <= 1
    nbr_trials_in_quarter_unit_circle = np.sum(estimate_inside_quarter_unit_circle)
    return nbr_trials_in_quarter_unit_circle

简短的代码分析表明,在此计算机上,使用多个线程执行时,对random的调用速度稍慢,而对(xs * xs + ys * ys) <= 1的调用可以很好地并行化。对随机数生成器的调用受制于 GIL,因为内部状态变量是一个 Python 对象。

理解这个过程是基本但可靠的:

  1. 注释掉所有numpy行,并使用串行版本在线程下运行。运行多次,并在__main__中使用time.time()记录执行时间。

  2. 在添加一行返回代码(我们首先添加了xs = np.random.uniform(...))并运行多次,再次记录完成时间。

  3. 添加下一行代码(现在添加ys = ...),再次运行,并记录完成时间。

  4. 重复,包括nbr_trials_in_quarter_unit_circle = np.sum(...)行。

  5. 再次重复此过程,但这次使用四个线程。逐行重复。

  6. 比较无线程和四个线程在每个步骤的运行时差异。

因为我们正在并行运行代码,所以使用line_profilercProfile等工具变得更加困难。记录原始运行时间并观察使用不同配置时的行为差异需要耐心,但可以提供可靠的证据来得出结论。

注意

如果你想了解uniform调用的串行行为,请查看numpy源码中的mtrand代码,并跟随在mtrand.pyx中的def uniform调用。如果你以前没有查看过numpy源代码,这是一个有用的练习。

在构建numpy时使用的库对于某些并行化机会非常重要。取决于构建numpy时使用的底层库(例如是否包含了英特尔数学核心库或 OpenBLAS 等),您将看到不同的加速行为。

您可以使用numpy.show_config()检查您的numpy配置。如果您对可能性感到好奇,Stack Overflow 上有一些示例时间。只有一些numpy调用会从外部库的并行化中受益。

寻找素数

接下来,我们将测试大范围内的素数。这与估算π的问题不同,因为工作量取决于您在数字范围中的位置,每个单独数字的检查具有不可预测的复杂性。我们可以创建一个串行程序来检查素数性质,然后将可能的因子集传递给每个进程进行检查。这个问题是令人尴尬地并行的,这意味着没有需要共享的状态。

multiprocessing 模块使得控制工作负载变得容易,因此我们将调查如何调整工作队列以使用(和滥用!)我们的计算资源,并探索一种更有效地利用我们资源的简单方法。这意味着我们将关注负载平衡,试图将我们变化复杂度的任务有效地分配给我们固定的资源集。

我们将使用一个与本书中稍有不同的算法(见“理想化计算与 Python 虚拟机”);如果我们有一个偶数,它会提前退出——见示例 9-8。

示例 9-8. 使用 Python 查找素数
def check_prime(n):
    if n % 2 == 0:
        return False
    for i in range(3, int(math.sqrt(n)) + 1, 2):
        if n % i == 0:
            return False
    return True

用这种方法测试素数时,我们能看到工作负载的多大变化?图 9-9 显示了检查素数的时间成本随可能是素数的n10,000增加到1,000,000而增加。

大多数数字都不是素数;它们用一个点表示。有些检查起来很便宜,而其他则需要检查许多因子。素数用一个x表示,并形成浓密的黑带;检查它们是最昂贵的。随着n的增加,检查一个数字的时间成本增加,因为要检查的可能因子范围随着n的平方根增加。素数序列是不可预测的,因此我们无法确定一系列数字的预期成本(我们可以估计,但无法确定其复杂性)。

对于图表,我们对每个n进行了两百次测试,并选择最快的结果来消除结果的抖动。如果我们只取一个结果,由于其他进程的系统负载,计时会出现广泛变化;通过多次读数并保留最快的结果,我们可以看到预期的最佳情况计时。

检查素数所需时间

图 9-9. 随着n的增加,检查素数所需的时间

当我们将工作分配给一个进程池时,我们可以指定每个工作人员处理多少工作。我们可以均匀分配所有工作并争取一次通过,或者我们可以制作许多工作块并在 CPU 空闲时将它们传递出去。这由chunksize参数控制。更大的工作块意味着更少的通信开销,而更小的工作块意味着更多地控制资源分配方式。

对于我们的素数查找器,一个单独的工作是由check_prime检查的数字nchunksize10意味着每个进程处理一个包含 10 个整数的列表,一次处理一个列表。

在 图 9-10 中,我们可以看到从1(每个作业是单独的工作)到64(每个作业是包含 64 个数字的列表)变化chunksize的效果。尽管有许多微小的作业给了我们最大的灵活性,但也带来了最大的通信开销。四个 CPU 将被有效利用,但是通信管道会成为瓶颈,因为每个作业和结果都通过这个单一通道传递。如果我们将chunksize加倍到2,我们的任务完成速度将加快一倍,因为通信管道上的竞争减少了。我们可能天真地假设通过增加chunksize,我们将继续改善执行时间。然而,正如你在图中看到的,我们最终会遇到收益递减的点。

notset

图 9-10. 选择合理的chunksize

我们可以继续增加chunksize,直到我们开始看到行为恶化。在 图 9-11 中,我们扩展了chunksize范围,使它们不仅小而且巨大。在较大的端点上,最坏的结果显示为 1.08 秒,我们要求chunksize50000——这意味着我们的 100,000 个项目被分成两个工作块,使得两个 CPU 在整个通过过程中空闲。使用chunksize10000项,我们正在创建十个工作块;这意味着四个工作块将在并行中运行两次,然后是剩下的两个工作块。这在第三轮工作中使得两个 CPU 空闲,这是资源使用的低效方式。

在这种情况下的最佳解决方案是将总作业数除以 CPU 数量。这是multiprocessing的默认行为,显示为图中的“default”蓝点。

作为一般规则,默认行为是明智的;只有当您预计真正获益时才调整它,并且一定要针对默认行为确认您的假设。

与蒙特卡罗π问题不同,我们的素数测试计算具有不同的复杂性——有时一个工作很快退出(检测到偶数),有时数字很大且是素数(这需要更长时间来检查)。

notset

图 9-11. 选择合理的chunksize值(续)

如果我们随机化我们的工作序列会发生什么?对于这个问题,我们挤出了 2% 的性能增益,正如您在图 9-12 中所看到的。通过随机化,我们减少了最后一个作业花费更长时间的可能性,使除一个 CPU 外的所有 CPU 都保持活跃。

正如我们之前使用chunksize10000的示例所示,将工作量与可用资源数量不匹配会导致效率低下。在那种情况下,我们创建了三轮工作:前两轮使用了资源的 100%,而最后一轮仅使用了 50%。

notset

图 9-12. 随机化工作序列

图 9-13 展示了当我们将工作块的数量与处理器数量不匹配时出现的奇特效果。不匹配会导致可用资源利用不足。当只创建一个工作块时,总运行时间最慢:这样会使得三个处理器未被利用。两个工作块会使得两个 CPU 未被利用,依此类推;只有当我们有四个工作块时,我们才能充分利用所有资源。但是如果我们添加第五个工作块,我们又会浪费资源 —— 四个 CPU 将处理它们的工作块,然后一个 CPU 将用于计算第五个工作块。

随着工作块数量的增加,我们看到效率的不足减少 —— 在 29 和 32 个工作块之间的运行时间差约为 0.03 秒。一般规则是为了有效利用资源,如果您的工作具有不同的运行时间,则制作大量小型工作。

notset

图 9-13. 选择不合适的工作块数量的危险

以下是一些有效使用multiprocessing处理尴尬并行问题的策略:

  • 将你的工作分成独立的工作单元。

  • 如果您的工作人员需要不同的时间量,请考虑随机化工作序列(另一个例子是处理不同大小的文件)。

  • 对工作队列进行排序,以便最慢的工作先进行,可能是一个同样有效的策略。

  • 除非您已验证调整的原因,否则请使用默认的chunksize

  • 将工作数量与物理 CPU 数量对齐。(再次强调,默认的chunksize会为您处理这个问题,尽管它默认使用超线程,这可能不会提供额外的增益。)

注意,默认情况下,multiprocessing将超线程视为额外的 CPU。这意味着在 Ian 的笔记本电脑上,它会分配八个进程,但只有四个进程会以 100% 的速度运行。额外的四个进程可能会占用宝贵的内存,而几乎没有提供额外的速度增益。

使用Pool,我们可以将预定义的工作块提前分配给可用的 CPU。然而,如果我们有动态工作负载,特别是随时间到达的工作负载,则这种方法帮助较少。对于这种类型的工作负载,我们可能需要使用下一节介绍的Queue

工作队列

multiprocessing.Queue对象提供给我们非持久化队列,可以在进程之间发送任何可 pickle 的 Python 对象。它们带来一些额外开销,因为每个对象必须被 pickled 以便发送,然后在消费者端进行反序列化(还伴随一些锁操作)。在接下来的示例中,我们将看到这种成本是不可忽视的。然而,如果您的工作进程处理的是较大的作业,通信开销可能是可以接受的。

使用队列进行工作相当容易。在本示例中,我们将通过消费候选数列表来检查素数,并将确认的质数发布回definite_primes_queue。我们将以单进程、双进程、四进程和八进程运行此示例,并确认后三种方法的时间比仅运行检查相同范围的单个进程更长。

Queue为我们提供了使用本地 Python 对象进行大量进程间通信的能力。如果您传递的对象具有大量状态,这可能非常有用。然而,由于Queue缺乏持久性,您可能不希望将其用于可能需要在面对故障时保持鲁棒性的作业(例如,如果断电或硬盘损坏)。

示例 9-9 展示了check_prime函数。我们已经熟悉基本的素数测试。我们在一个无限循环中运行,在possible_primes_queue.get()上阻塞(等待直到有可用的工作),以消费队列中的项目。由于Queue对象负责同步访问,因此一次只能有一个进程获取项目。如果队列中没有工作,.get()将阻塞,直到有任务可用。当找到质数时,它们会被putdefinite_primes_queue,供父进程消费。

示例 9-9。使用两个队列进行进程间通信(IPC)
FLAG_ALL_DONE = b"WORK_FINISHED"
FLAG_WORKER_FINISHED_PROCESSING = b"WORKER_FINISHED_PROCESSING"

def check_prime(possible_primes_queue, definite_primes_queue):
    while True:
        n = possible_primes_queue.get()
        if n == FLAG_ALL_DONE:
            # flag that our results have all been pushed to the results queue
            definite_primes_queue.put(FLAG_WORKER_FINISHED_PROCESSING)
            break
        else:
            if n % 2 == 0:
                continue
            for i in range(3, int(math.sqrt(n)) + 1, 2):
                if n % i == 0:
                    break
            else:
                definite_primes_queue.put(n)

我们定义了两个标志:一个由父进程作为毒丸喂入,指示没有更多工作可用,另一个由工作进程确认它已看到毒丸并关闭自身。第一个毒丸也被称为sentinel,因为它保证了处理循环的终止。

处理工作队列和远程工作者时,使用这些标志记录毒丸的发送并检查子进程在合理时间窗口内发送的响应可以很有帮助,表明它们正在关闭。我们在这里不处理这个过程,但是添加一些时间记录是代码的一个相当简单的补充。接收这些标志的情况可以在调试期间记录或打印。

Queue 对象是在 示例 9-10 中由 Manager 创建的。我们将使用构建 Process 对象列表的熟悉过程,每个对象都包含一个分叉的进程。这两个队列作为参数发送,并且 multiprocessing 处理它们的同步。启动了新进程后,我们将一系列作业交给 possible_primes_queue,并以每个进程一个毒丸结束。作业将以 FIFO 顺序消耗,最后留下毒丸。在 check_prime 中,我们使用阻塞的 .get(),因为新进程必须等待队列中出现工作。由于我们使用标志,我们可以添加一些工作,处理结果,然后通过稍后添加毒丸迭代添加更多工作,并通过稍后添加毒丸来标志工作人员的生命结束。

示例 9-10. 为 IPC 构建两个队列
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Project description")
    parser.add_argument(
        "nbr_workers", type=int, help="Number of workers e.g. 1, 2, 4, 8"
    )
    args = parser.parse_args()
    primes = []

    manager = multiprocessing.Manager()
    possible_primes_queue = manager.Queue()
    definite_primes_queue = manager.Queue()

    pool = Pool(processes=args.nbr_workers)
    processes = []
    for _ in range(args.nbr_workers):
        p = multiprocessing.Process(
            target=check_prime, args=(possible_primes_queue,
                                      definite_primes_queue)
        )
        processes.append(p)
        p.start()

    t1 = time.time()
    number_range = range(100_000_000, 101_000_000)

    # add jobs to the inbound work queue
    for possible_prime in number_range:
        possible_primes_queue.put(possible_prime)

    # add poison pills to stop the remote workers
    for n in range(args.nbr_workers):
        possible_primes_queue.put(FLAG_ALL_DONE)

要消费结果,我们在 示例 9-11 中启动另一个无限循环,并在 definite_primes_queue 上使用阻塞的 .get()。如果找到 finished-processing 标志,则计算已经信号退出的进程数。如果没有,则表示有一个新的质数,我们将其添加到 primes 列表中。当所有进程都已经信号退出时,我们退出无限循环。

示例 9-11. 使用两个队列进行 IPC
    processors_indicating_they_have_finished = 0
    while True:
        new_result = definite_primes_queue.get()  # block while waiting for results
        if new_result == FLAG_WORKER_FINISHED_PROCESSING:
            processors_indicating_they_have_finished += 1
            if processors_indicating_they_have_finished == args.nbr_workers:
                break
        else:
            primes.append(new_result)
    assert processors_indicating_they_have_finished == args.nbr_workers

    print("Took:", time.time() - t1)
    print(len(primes), primes[:10], primes[-10:])

使用 Queue 存在相当大的开销,这是由于 pickling 和同步造成的。正如您在 图 9-14 中所见,使用 Queue 的单进程解决方案明显快于使用两个或更多进程。在本例中的原因是因为我们的工作负载非常轻——通信成本主导了此任务的整体时间。使用 Queue,两个进程完成这个示例比一个进程稍快,而四个和八个进程则都较慢。

notset

图 9-14. 使用队列对象的成本

如果您的任务完成时间较长(至少占据几分之一秒),但通信量很少,则使用 Queue 方法可能是正确的选择。您需要验证通信成本是否足够使用此方法。

您可能会想知道如果我们移除作业队列的多余一半(所有偶数——在 check_prime 中这些会被非常快速地拒绝),会发生什么。减少输入队列的大小会减少每种情况下的执行时间,但仍然无法超过单进程非 Queue 示例!这有助于说明通信成本在此问题中是主导因素。

异步向队列添加作业

通过在主进程中添加一个Thread,我们可以将作业异步地放入possible_primes_queue中。在示例 9-12 中,我们定义了一个feed_new_jobs函数:它执行与我们之前在__main__中设置的作业设置例程相同的工作,但是它在一个单独的线程中执行。

示例 9-12. 异步作业供给函数
def feed_new_jobs(number_range, possible_primes_queue, nbr_poison_pills):
    for possible_prime in number_range:
        possible_primes_queue.put(possible_prime)
    # add poison pills to stop the remote workers
    for n in range(nbr_poison_pills):
        possible_primes_queue.put(FLAG_ALL_DONE)

现在,在示例 9-13 中,我们的__main__将使用possible_primes_queue设置Thread,然后继续到结果收集阶段之前发出任何工作。异步作业供给器可以从外部源(例如数据库或 I/O 限制通信)消耗工作,而__main__线程则处理每个处理过的结果。这意味着输入序列和输出序列不需要预先创建;它们都可以即时处理。

示例 9-13. 使用线程设置异步作业供给器
if __name__ == "__main__":
    primes = []
    manager = multiprocessing.Manager()
    possible_primes_queue = manager.Queue()

    ...

    import threading
    thrd = threading.Thread(target=feed_new_jobs,
                            args=(number_range,
                                  possible_primes_queue,
                                  NBR_PROCESSES))
    thrd.start()

    # deal with the results

如果你想要稳健的异步系统,几乎可以肯定要使用asyncio或者像tornado这样的外部库。关于这些方法的全面讨论,请查看第 8 章。我们在这里看到的例子可以帮助你入门,但实际上它们对于非常简单的系统和教育而言更有用,而不是用于生产系统。

非常注意异步系统需要特别耐心——在调试时你可能会抓狂。我们建议如下操作:

  • 应用“保持简单愚蠢”原则

  • 如果可能,应避免使用异步自包含系统(如我们的示例),因为它们会变得越来越复杂并很快难以维护

  • 使用像gevent这样的成熟库(在上一章中描述),这些库为处理某些问题集提供了经过验证的方法

此外,我们强烈建议使用提供队列状态外部可见性的外部队列系统(例如,在“NSQ 用于稳健的生产集群”中讨论的 NSQ、ZeroMQ 或 Celery)。这需要更多的思考,但可能会因提高调试效率和生产系统的更好可见性而节省您的时间。

提示

考虑使用任务图形以增强韧性。需要长时间运行队列的数据科学任务通常通过在无环图中指定工作流来有效服务。两个强大的库是AirflowLuigi。这些在工业环境中广泛使用,支持任意任务链接、在线监控和灵活扩展。

使用进程间通信验证质数

质数是除了它们自己和 1 之外没有其他因子的数字。可以说最常见的因子是 2(每个偶数都不可能是质数)。之后,低质数(例如 3、5、7)成为较大的非质数(例如 9、15 和 21)的常见因子。

假设我们有一个大数,并被要求验证它是否是质数。我们可能会有一个大量的因子空间需要搜索。图 9-15 显示了非质数的每个因子在 10,000,000 以内的频率。低因子比高因子更有可能出现,但没有可预测的模式。

notset

图 9-15. 非质数因子的频率

让我们定义一个新问题——假设我们有一个数字集,我们的任务是有效地使用 CPU 资源来逐个确定每个数字是否是质数。可能我们只有一个大数需要测试。现在不再有必要使用一个 CPU 来进行检查;我们希望跨多个 CPU 协调工作。

对于这一部分,我们将查看一些更大的数字,一个有 15 位数,四个有 18 位数:

  • 小非质数:112,272,535,095,295

  • 大非质数 1:100,109,100,129,100,369

  • 大非质数 2:100,109,100,129,101,027

  • 质数 1:100,109,100,129,100,151

  • 质数 2:100,109,100,129,162,907

通过使用一个较小的非质数和一些较大的非质数,我们得以验证我们选择的处理过程不仅更快地检查质数,而且在检查非质数时也不会变慢。我们假设我们不知道被给定的数字的大小或类型,因此我们希望对所有用例都获得尽可能快的结果。

注意

如果您拥有这本书的旧版,您可能会惊讶地发现,使用 CPython 3.7 的这些运行时间稍慢于上一版中在较慢的笔记本电脑上运行的 CPython 2.7 的运行时间。这里的代码是一个特例,Python 3.x目前比 CPython 2.7 慢。这段代码依赖于整数操作;CPython 2.7 混合使用系统整数和“long”整数(可以存储任意大小的数字,但速度较慢)。CPython 3.x对所有操作只使用“long”整数。这个实现已经经过优化,但在某些情况下仍然比旧的(和更复杂的)实现慢。

我们从不必担心正在使用的“种类”整数,并且在 CPython 3.7 中,我们因此会稍微降低速度。这是一个微型基准测试,几乎不可能影响您自己的代码,因为 CPython 3.x在许多其他方面都比 CPython 2.x更快。我们的建议是不要担心这个问题,除非您大部分执行时间都依赖于整数操作——在这种情况下,我们强烈建议您查看 PyPy,它不会受到这种减速的影响。

合作是有代价的——同步数据和检查共享数据的成本可能会非常高。我们将在这里讨论几种可以用于任务协调的不同方法。

注意,我们在这里涵盖有些专业的消息传递接口(MPI);我们关注的是一些内置的模块和 Redis(非常常见)。如果你想使用 MPI,我们假设你已经知道你在做什么。MPI4PY 项目可能是一个很好的起点。当许多进程协作时,如果你想控制延迟,无论是一台还是多台机器,它是一种理想的技术。

在以下运行中,每个测试重复进行 20 次,并取最小时间以显示可能的最快速度。在这些示例中,我们使用各种技术来共享一个标志(通常为 1 字节)。我们可以使用像Lock这样的基本对象,但是这样只能共享 1 位状态。我们选择向您展示如何共享原始类型,以便进行更多表达式状态共享(即使对于此示例我们不需要更多表达式状态)。

我们必须强调,共享状态往往会使事情变得复杂——你很容易陷入另一种令人头疼的状态。要小心,并尽量保持事情尽可能简单。也许更有效的资源使用效果会被开发人员在其他挑战上的时间所超越。

首先我们将讨论结果,然后我们将详细阅读代码。

图 9-16 展示了尝试使用进程间通信更快地测试素性的初步方法。基准是串行版本,它不使用任何进程间通信;我们尝试加速代码的每一次尝试至少比这个版本更快。

notset

图 9-16. 用于验证素性的 IPC 较慢的方法

Less Naive Pool 版本具有可预测的(并且良好的)速度。它足够好,非常难以超越。在寻找高速解决方案时不要忽视显而易见的东西——有时一个愚蠢但足够好的解决方案就是你需要的。

Less Naive Pool 解决方案的方法是将我们要测试的数按可能的因子范围均匀分配给可用的 CPU,然后将工作分配给每个 CPU。如果任何 CPU 找到因子,它将提前退出,但它不会传达这一事实;其他 CPU 将继续处理它们范围内的工作。这意味着对于一个 18 位数(我们的四个较大示例),无论它是素数还是非素数,搜索时间都是相同的。

当测试大量因子以确定素性时,Redis 和Manager解决方案由于通信开销而较慢。它们使用共享标志来指示是否已找到因子并且搜索应该停止。

Redis 让您不仅可以与其他 Python 进程共享状态,还可以与其他工具和其他计算机共享状态,甚至可以通过 Web 浏览器界面公开该状态(这对于远程监视可能很有用)。Managermultiprocessing 的一部分;它提供了一组高级同步的 Python 对象(包括原语、listdict)。

对于更大的非素数情况,尽管检查共享标志会产生一些成本,但这个成本在早期发现因子并发出信号的搜索时间节省中微不足道。

对于素数情况,无法提前退出,因为不会找到任何因子,所以检查共享标志的成本将成为主导成本。

提示

一点思考往往就足够了。在这里,我们探讨了各种基于 IPC 的解决方案,以使素数验证任务更快。就“打字分钟”与“收益增加”而言,第一步——引入天真的并行处理——为我们带来了最大的收益,而付出的努力却最小。后续的收益需要进行大量额外的实验。始终考虑最终运行时间,特别是对于临时任务。有时,让一个循环运行整个周末来完成一个临时任务比优化代码以更快地运行更容易。

图 9-17 显示,通过一些努力,我们可以获得一个明显更快的结果。较不天真的 Pool 结果仍然是我们的基准线,但 RawValue 和 MMap(内存映射)的结果比以前的 Redis 和 Manager 结果快得多。真正的魔法来自于采取最快的解决方案,并执行一些不那么明显的代码操作,使得几乎最佳的 MMap 解决方案比 Less Naive Pool 解决方案在非素数情况下更快,并且在素数情况下几乎一样快。

在接下来的章节中,我们将通过各种方式来使用 Python 中的 IPC 来解决我们的协同搜索问题。我们希望您能看到 IPC 虽然相当容易,但通常会带来一些成本。

notset

图 9-17. 使用 IPC 进行验证素数的更快方法

串行解决方案

我们将从之前使用过的相同的串行因子检查代码开始,如 示例 9-14 中再次显示的那样。正如之前注意到的那样,对于任何具有较大因子的非素数,我们可以更有效地并行搜索因子空间。但是,串行扫描将为我们提供一个明智的基准线。

示例 9-14. 串行验证
def check_prime(n):
    if n % 2 == 0:
        return False
    from_i = 3
    to_i = math.sqrt(n) + 1
    for i in range(from_i, int(to_i), 2):
        if n % i == 0:
            return False
    return True

Naive Pool 解决方案

Naive Pool 解决方案使用了一个 multiprocessing.Pool,类似于我们在 “寻找素数” 和 “使用进程和线程估算π” 中看到的,有四个 forked 进程。我们有一个要测试素数性的数字,我们将可能的因子范围分成了四个子范围的元组,并将这些发送到 Pool 中。

在示例 9-15 中,我们使用了一个新方法create_range.create(我们不会展示它——它相当无聊),它将工作空间分割成大小相等的区域。ranges_to_check 中的每个项目是一对下限和上限,用于搜索之间。对于第一个 18 位数的非质数(100,109,100,129,100,369),使用四个进程,我们将得到因子范围 ranges_to_check == [(3, 79_100_057), (79_100_057, 158_200_111), (158_200_111, 237_300_165), (237_300_165, 316_400_222)](其中 316,400,222 是 100,109,100,129,100,369 的平方根加 1)。在__main__中,我们首先建立一个Pool;然后check_prime通过map方法将ranges_to_check拆分为每个可能的质数n。如果结果为False,则找到了一个因子,这不是一个质数。

示例 9-15. 幼稚池解决方案
def check_prime(n, pool, nbr_processes):
    from_i = 3
    to_i = int(math.sqrt(n)) + 1
    ranges_to_check = create_range.create(from_i, to_i, nbr_processes)
    ranges_to_check = zip(len(ranges_to_check) * [n], ranges_to_check)
    assert len(ranges_to_check) == nbr_processes
    results = pool.map(check_prime_in_range, ranges_to_check)
    if False in results:
        return False
    return True

if __name__ == "__main__":
    NBR_PROCESSES = 4
    pool = Pool(processes=NBR_PROCESSES)
    ...

我们修改了前面示例 9-16 中的check_prime,以获取要检查范围的下限和上限。传递完整的可能因子列表没有意义,因此通过传递仅定义我们范围的两个数字,我们节省了时间和内存。

示例 9-16. check_prime_in_range
def check_prime_in_range(n_from_i_to_i):
    (n, (from_i, to_i)) = n_from_i_to_i
    if n % 2 == 0:
        return False
    assert from_i % 2 != 0
    for i in range(from_i, int(to_i), 2):
        if n % i == 0:
            return False
    return True

对于“小非质数”情况,通过Pool验证的时间为 0.1 秒,远远长于串行解决方案中的原始 0.000002 秒。尽管有一个更差的结果,整体结果是全面加速。也许我们可以接受一个较慢的结果不是问题——但如果我们可能有很多小非质数需要检查呢?事实证明我们可以避免这种减速;接下来我们将看到更为成熟的池解决方案。

更为成熟的池解决方案

先前的解决方案在验证较小的非质数时效率低下。对于任何较小(少于 18 位数)的非质数,由于发送分区工作的开销和不知道是否会找到一个非常小的因子(这是更有可能的因子),它可能比串行方法更慢。如果找到一个小因子,该过程仍然必须等待其他更大因子的搜索完成。

我们可以开始在进程之间发信号,表明已找到一个小因子,但由于这种情况非常频繁,这将增加大量的通信开销。示例 9-17 中提出的解决方案是一种更加实用的方法——快速执行串行检查以查找可能的小因子,如果没有找到,则启动并行搜索。在启动相对更昂贵的并行操作之前进行串行预检查是避免一些并行计算成本的常见方法。

示例 9-17. 改进小非质数情况下的幼稚池解决方案
def check_prime(n, pool, nbr_processes):
    # cheaply check high-probability set of possible factors
    from_i = 3
    to_i = 21
    if not check_prime_in_range((n, (from_i, to_i))):
        return False

    # continue to check for larger factors in parallel
    from_i = to_i
    to_i = int(math.sqrt(n)) + 1
    ranges_to_check = create_range.create(from_i, to_i, nbr_processes)
    ranges_to_check = zip(len(ranges_to_check) * [n], ranges_to_check)
    assert len(ranges_to_check) == nbr_processes
    results = pool.map(check_prime_in_range, ranges_to_check)
    if False in results:
        return False
    return True

对于我们的每个测试数字,这种解决方案的速度要么相等,要么优于原始串行搜索。这是我们的新基准。

重要的是,这种 Pool 方法为素数检查提供了一个最佳案例。如果我们有一个素数,就没有办法提前退出;我们必须在退出之前手动检查所有可能的因子。

检查这些因素没有更快的方法:任何增加复杂性的方法都会有更多的指令,因此检查所有因素的情况将导致执行最多的指令。参见 “使用 mmap 作为标志” 中涵盖的各种 mmap 解决方案,讨论如何尽可能接近当前用于素数的结果。

使用 Manager.Value 作为标志

multiprocessing.Manager() 允许我们在进程之间共享更高级别的 Python 对象作为托管共享对象;较低级别的对象被包装在代理对象中。包装和安全性会增加速度成本,但也提供了极大的灵活性。可以共享较低级别的对象(例如整数和浮点数)以及列表和字典。

在 示例 9-18 中,我们创建了一个 Manager,然后创建了一个 1 字节(字符)的 manager.Value(b"c", FLAG_CLEAR) 标志。如果需要共享字符串或数字,可以创建任何 ctypes 原语(与 array.array 原语相同)。

注意 FLAG_CLEARFLAG_SET 被分配了一个字节(b'0'b'1')。我们选择使用前缀 b 来显式说明(如果不加 b,可能会根据您的环境和 Python 版本默认为 Unicode 或字符串对象)。

现在我们可以在所有进程中传播一个因子已被发现的标志,因此可以提前结束搜索。难点在于平衡读取标志的成本与可能的速度节省。由于标志是同步的,我们不希望过于频繁地检查它 —— 这会增加更多的开销。

示例 9-18. 将 Manager.Value 对象作为标志传递
SERIAL_CHECK_CUTOFF = 21
CHECK_EVERY = 1000
FLAG_CLEAR = b'0'
FLAG_SET = b'1'
print("CHECK_EVERY", CHECK_EVERY)

if __name__ == "__main__":
    NBR_PROCESSES = 4
    manager = multiprocessing.Manager()
    value = manager.Value(b'c', FLAG_CLEAR)  # 1-byte character
    ...

check_prime_in_range 现在将意识到共享的标志,并且该例程将检查是否有其他进程发现了素数。即使我们尚未开始并行搜索,我们必须像 示例 9-19 中所示那样在开始串行检查之前清除标志。完成串行检查后,如果我们没有找到因子,我们知道标志必须仍然为假。

示例 9-19. 使用 Manager.Value 清除标志
def check_prime(n, pool, nbr_processes, value):
    # cheaply check high-probability set of possible factors
    from_i = 3
    to_i = SERIAL_CHECK_CUTOFF
    value.value = FLAG_CLEAR
    if not check_prime_in_range((n, (from_i, to_i), value)):
        return False

    from_i = to_i
    ...

我们应该多频繁地检查共享标志?每次检查都有成本,因为我们将更多指令添加到紧密的内部循环中,并且检查需要对共享变量进行锁定,这会增加更多成本。我们选择的解决方案是每一千次迭代检查一次标志。每次检查时,我们都会查看value.value是否已设置为FLAG_SET,如果是,我们会退出搜索。如果在搜索中进程找到一个因子,则会将value.value = FLAG_SET并退出(参见 示例 9-20)。

示例 9-20. 传递一个Manager.Value对象作为标志
def check_prime_in_range(n_from_i_to_i):
    (n, (from_i, to_i), value) = n_from_i_to_i
    if n % 2 == 0:
        return False
    assert from_i % 2 != 0
    check_every = CHECK_EVERY
    for i in range(from_i, int(to_i), 2):
        check_every -= 1
        if not check_every:
            if value.value == FLAG_SET:
                return False
            check_every = CHECK_EVERY

        if n % i == 0:
            value.value = FLAG_SET
            return False
    return True

这段代码中的千次迭代检查是使用check_every本地计数器执行的。事实证明,尽管可读性强,但速度不佳。在本节结束时,我们将用一种可读性较差但显著更快的方法来替换它。

您可能会对我们检查共享标志的总次数感到好奇。对于两个大质数的情况,使用四个进程我们检查了 316,405 次标志(在所有后续示例中我们都会检查这么多次)。由于每次检查都因锁定而带来开销,这种成本真的会累积起来。

使用 Redis 作为标志

Redis 是一个键/值内存存储引擎。它提供了自己的锁定机制,每个操作都是原子的,因此我们无需担心从 Python(或任何其他接口语言)内部使用锁。

通过使用 Redis,我们使数据存储与语言无关—任何具有与 Redis 接口的语言或工具都可以以兼容的方式共享数据。您可以轻松在 Python、Ruby、C++和 PHP 之间共享数据。您可以在本地机器上或通过网络共享数据;要共享到其他机器,您只需更改 Redis 默认仅在localhost上共享的设置。

Redis 允许您存储以下内容:

  • 字符串的列表

  • 字符串的集合

  • 字符串的排序集合

  • 字符串的哈希

Redis 将所有数据存储在 RAM 中,并进行快照到磁盘(可选使用日志记录),并支持主/从复制到一组实例的集群。Redis 的一个可能应用是将其用于在集群中分享工作负载,其中其他机器读取和写入状态,而 Redis 充当快速的集中式数据存储库。

我们可以像以前使用 Python 标志一样读取和写入文本字符串(Redis 中的所有值都是字符串)。我们创建一个StrictRedis接口作为全局对象,它与外部 Redis 服务器通信。我们可以在check_prime_in_range内部创建一个新连接,但这样做会更慢,并且可能耗尽可用的有限 Redis 句柄数量。

我们使用类似字典的访问方式与 Redis 服务器通信。我们可以使用rds[SOME_KEY] = SOME_VALUE设置一个值,并使用rds[SOME_KEY]读取字符串返回。

示例 9-21 与之前的Manager示例非常相似——我们使用 Redis 替代了本地的Manager。它具有类似的访问成本。需要注意的是,Redis 支持其他(更复杂的)数据结构;它是一个强大的存储引擎,我们仅在此示例中使用它来共享一个标志。我们鼓励您熟悉其特性。

示例 9-21. 使用外部 Redis 服务器作为我们的标志
FLAG_NAME = b'redis_primes_flag'
FLAG_CLEAR = b'0'
FLAG_SET = b'1'

rds = redis.StrictRedis()

def check_prime_in_range(n_from_i_to_i):
    (n, (from_i, to_i)) = n_from_i_to_i
    if n % 2 == 0:
        return False
    assert from_i % 2 != 0
    check_every = CHECK_EVERY
    for i in range(from_i, int(to_i), 2):
        check_every -= 1
        if not check_every:
            flag = rds[FLAG_NAME]
            if flag == FLAG_SET:
                return False
            check_every = CHECK_EVERY

        if n % i == 0:
            rds[FLAG_NAME] = FLAG_SET
            return False
    return True

def check_prime(n, pool, nbr_processes):
    # cheaply check high-probability set of possible factors
    from_i = 3
    to_i = SERIAL_CHECK_CUTOFF
    rds[FLAG_NAME] = FLAG_CLEAR
    if not check_prime_in_range((n, (from_i, to_i))):
        return False

    ...
    if False in results:
        return False
    return True

要确认数据存储在这些 Python 实例之外,我们可以像在示例 9-22 中那样,在命令行上调用redis-cli,并获取存储在键redis_primes_flag中的值。您会注意到返回的项是一个字符串(而不是整数)。从 Redis 返回的所有值都是字符串,因此如果您想在 Python 中操作它们,您需要先将它们转换为适当的数据类型。

示例 9-22. redis-cli
$ redis-cli
redis 127.0.0.1:6379> GET "redis_primes_flag"
"0"

支持将 Redis 用于数据共享的一个强有力的论点是它存在于 Python 世界之外——您团队中不熟悉 Python 的开发人员也能理解它,并且存在许多针对它的工具。在阅读代码时,他们可以查看其状态,了解发生了什么(尽管不一定运行和调试)。从团队效率的角度来看,尽管使用 Redis 会增加沟通成本,但这可能对您来说是一个巨大的胜利。尽管 Redis 是项目中的额外依赖,但需要注意的是它是一个非常常见的部署工具,经过了良好的调试和理解。考虑将其作为增强您武器库的强大工具。

Redis 有许多配置选项。默认情况下,它使用 TCP 接口(这就是我们正在使用的),尽管基准文档指出套接字可能更快。它还指出,虽然 TCP/IP 允许您在不同类型的操作系统之间共享数据网络,但其他配置选项可能更快(但也可能限制您的通信选项):

当服务器和客户端基准程序在同一台计算机上运行时,可以同时使用 TCP/IP 回环和 Unix 域套接字。这取决于平台,但 Unix 域套接字在 Linux 上可以实现大约比 TCP/IP 回环高出 50%的吞吐量。redis-benchmark 的默认行为是使用 TCP/IP 回环。与 TCP/IP 回环相比,Unix 域套接字的性能优势在大量使用流水线时倾向于减少(即长流水线)。

Redis 文档

Redis 在工业界广泛使用,成熟且信任。如果您对这个工具不熟悉,我们强烈建议您了解一下;它在您的高性能工具包中占据一席之地。

使用 RawValue 作为标志

multiprocessing.RawValue 是围绕 ctypes 字节块的薄包装。它缺乏同步原语,因此在我们寻找在进程之间设置标志位的最快方法时,几乎不会有阻碍。它几乎和下面的 mmap 示例一样快(它只慢了一点,因为多了几条指令)。

同样地,我们可以使用任何 ctypes 原始类型;还有一个 RawArray 选项用于共享一组原始对象(它们的行为类似于 array.array)。RawValue 避免了任何锁定——使用起来更快,但你不能获得原子操作。

通常情况下,如果避免了 Python 在 IPC 期间提供的同步,你可能会遇到麻烦(再次回到那种让你抓狂的情况)。但是,在这个问题中,如果一个或多个进程同时设置标志位并不重要——标志位只会单向切换,并且每次读取时,只是用来判断是否可以终止搜索。

因为我们在并行搜索过程中从未重置标志位的状态,所以我们不需要同步。请注意,这可能不适用于你的问题。如果你避免同步,请确保你是出于正确的原因这样做。

如果你想做类似更新共享计数器的事情,请查看 Value 的文档,并使用带有 value.get_lock() 的上下文管理器,因为 Value 上的隐式锁定不允许原子操作。

这个示例看起来与之前的 Manager 示例非常相似。唯一的区别是在 示例 9-23 中,我们将 RawValue 创建为一个字符(字节)的标志位。

示例 9-23. 创建和传递 RawValue
if __name__ == "__main__":
    NBR_PROCESSES = 4
    value = multiprocessing.RawValue('b', FLAG_CLEAR)  # 1-byte character
    pool = Pool(processes=NBR_PROCESSES)
    ...

multiprocessing 中,使用受控和原始值的灵活性是数据共享清洁设计的一个优点。

使用 mmap 作为标志位

最后,我们来讨论最快的字节共享方式。示例 9-24 展示了使用 mmap 模块的内存映射(共享内存)解决方案。共享内存块中的字节不同步,并且带有非常少的开销。它们的行为类似于文件——在这种情况下,它们是一个带有文件接口的内存块。我们必须 seek 到某个位置,然后顺序读取或写入。通常情况下,mmap 用于在较大文件中创建一个短视图(内存映射),但在我们的情况下,作为第一个参数而不是指定文件号,我们传递 -1 表示我们想要一个匿名内存块。我们还可以指定我们是想要只读或只写访问(我们两者都要,这是默认值)。

示例 9-24. 使用 mmap 进行共享内存标志
sh_mem = mmap.mmap(-1, 1)  # memory map 1 byte as a flag

def check_prime_in_range(n_from_i_to_i):
    (n, (from_i, to_i)) = n_from_i_to_i
    if n % 2 == 0:
        return False
    assert from_i % 2 != 0
    check_every = CHECK_EVERY
    for i in range(from_i, int(to_i), 2):
        check_every -= 1
        if not check_every:
            sh_mem.seek(0)
            flag = sh_mem.read_byte()
            if flag == FLAG_SET:
                return False
            check_every = CHECK_EVERY

        if n % i == 0:
            sh_mem.seek(0)
            sh_mem.write_byte(FLAG_SET)
            return False
    return True

def check_prime(n, pool, nbr_processes):
    # cheaply check high-probability set of possible factors
    from_i = 3
    to_i = SERIAL_CHECK_CUTOFF
    sh_mem.seek(0)
    sh_mem.write_byte(FLAG_CLEAR)
    if not check_prime_in_range((n, (from_i, to_i))):
        return False

    ...
    if False in results:
        return False
    return True

mmap支持多种方法,可用于在其表示的文件中移动(包括findreadlinewrite)。我们正在以最基本的方式使用它 —— 每次读取或写入前,我们都会seek到内存块的开头,并且由于我们只共享 1 字节,所以我们使用read_bytewrite_byte以明确方式。

没有 Python 锁定的开销,也没有数据的解释;我们直接与操作系统处理字节,因此这是我们最快的通信方法。

使用 mmap 作为旗帜的再现

尽管先前的mmap结果在整体上表现最佳,但我们不禁要考虑是否能够回到最昂贵的素数案例的天真池结果。目标是接受内部循环没有早期退出,并尽量减少任何不必要的成本。

本节提出了一个稍微复杂的解决方案。虽然我们看到了基于其他基于标志的方法的相同变化,但这个mmap结果仍然是最快的。

在我们之前的示例中,我们使用了CHECK_EVERY。这意味着我们有check_next本地变量来跟踪、递减和在布尔测试中使用 — 每个操作都会在每次迭代中增加一点额外的时间。在验证大素数的情况下,这种额外的管理开销发生了超过 300,000 次。

第一个优化,在示例 9-25 中展示,是意识到我们可以用一个预先查看的值替换递减的计数器,然后我们只需要在内循环中进行布尔比较。这样就可以去掉一个递减操作,因为 Python 的解释风格,这样做相当慢。这种优化在 CPython 3.7 中有效,但不太可能在更智能的编译器(如 PyPy 或 Cython)中带来任何好处。在检查我们的一个大素数时,这一步节省了 0.1 秒。

示例 9-25. 开始优化我们昂贵逻辑
def check_prime_in_range(n_from_i_to_i):
    (n, (from_i, to_i)) = n_from_i_to_i
    if n % 2 == 0:
        return False
    assert from_i % 2 != 0
    check_next = from_i + CHECK_EVERY
    for i in range(from_i, int(to_i), 2):
        if check_next == i:
            sh_mem.seek(0)
            flag = sh_mem.read_byte()
            if flag == FLAG_SET:
                return False
            check_next += CHECK_EVERY

        if n % i == 0:
            sh_mem.seek(0)
            sh_mem.write_byte(FLAG_SET)
            return False
    return True

我们还可以完全替换计数器表示的逻辑,如示例 9-26 所示,将我们的循环展开为两个阶段的过程。首先,外循环按步长覆盖预期范围,但在CHECK_EVERY上。其次,一个新的内循环替换了check_every逻辑 —— 它检查因子的本地范围,然后完成。这等同于if not check_every:测试。我们紧随其后的是先前的sh_mem逻辑,以检查早期退出标志。

示例 9-26. 优化我们昂贵逻辑的方法
def check_prime_in_range(n_from_i_to_i):
    (n, (from_i, to_i)) = n_from_i_to_i
    if n % 2 == 0:
        return False
    assert from_i % 2 != 0
    for outer_counter in range(from_i, int(to_i), CHECK_EVERY):
        upper_bound = min(int(to_i), outer_counter + CHECK_EVERY)
        for i in range(outer_counter, upper_bound, 2):
            if n % i == 0:
                sh_mem.seek(0)
                sh_mem.write_byte(FLAG_SET)
                return False
        sh_mem.seek(0)
        flag = sh_mem.read_byte()
        if flag == FLAG_SET:
            return False
    return True

速度影响是显著的。即使是我们的非素数案例也进一步改进,但更重要的是,我们的素数检查案例几乎与较不天真的池版本一样快(现在只慢了 0.1 秒)。考虑到我们在进程间通信中做了大量额外的工作,这是一个有趣的结果。请注意,这只适用于 CPython,并且在编译器中运行时不太可能带来任何收益。

在书的最后一版中,我们通过一个最终示例进一步展示了循环展开和全局对象的局部引用,以牺牲可读性换取了更高的性能。在 Python 3 中,这个例子稍微慢了一点,所以我们将其删除了。我们对此感到高兴——为了得到最高性能的示例,不需要跳过太多障碍,前面的代码更可能在团队中得到正确支持,而不是进行特定于实现的代码更改。

提示

这些示例在 PyPy 中运行得非常好,比在 CPython 中快大约七倍。有时候,更好的解决方案是调查其他运行时,而不是在 CPython 的兔子洞里跳来跳去。

使用多处理共享 numpy 数据

当处理大型 numpy 数组时,你可能会想知道是否可以在进程之间共享数据以进行读写访问,而无需复制。虽然有点棘手,但是这是可能的。我们要感谢 Stack Overflow 用户 pv,他的灵感激发了这个演示。²

警告

不要使用此方法来重新创建 BLAS、MKL、Accelerate 和 ATLAS 的行为。这些库在它们的基本操作中都支持多线程,并且它们可能比你创建的任何新例程更经过充分调试。它们可能需要一些配置来启用多线程支持,但在你投入时间(和在调试中浪费的时间!)编写自己的代码之前,最好看看这些库是否可以为你提供免费的加速。

在进程之间共享大型矩阵有几个好处:

  • 只有一个副本意味着没有浪费的 RAM。

  • 没有浪费时间复制大块 RAM。

  • 你可以在进程之间共享部分结果。

回想起在“使用 numpy”中使用 numpy 估算 pi 的演示,我们遇到的问题是随机数生成是一个串行进程。在这里,我们可以想象分叉进程,它们共享一个大数组,每个进程使用不同的种子随机数生成器填充数组的一部分,从而比单进程更快地生成一个大的随机块。

为了验证这一点,我们修改了即将展示的演示,创建了一个大型随机矩阵(10,000 × 320,000 元素)作为串行进程,并将矩阵分成四段,在并行调用 random(在这两种情况下,每次一行)。串行进程花费了 53 秒,而并行版本只花了 29 秒。请参考“并行系统中的随机数”了解一些并行随机数生成的潜在风险。

在本节的其余部分,我们将使用一个简化的演示来说明这一点,同时保持易于验证。

在 图 9-18 中,您可以看到 Ian 笔记本电脑上 htop 的输出。显示父进程(PID 27628)的四个子进程,这五个进程共享一个 10,000 × 320,000 元素的 numpy 双精度数组。这个数组的一个副本占用了 25.6 GB,而笔记本只有 32 GB 内存 —— 您可以在 htop 中看到,进程仪表显示 Mem 读数最大为 31.1 GB RAM。

notset

图 9-18. htop 显示 RAM 和交换使用情况

要理解这个演示,我们首先会浏览控制台输出,然后查看代码。在 示例 9-27 中,我们启动父进程:它分配了一个大小为 25.6 GB 的双精度数组,尺寸为 10,000 × 320,000,用值零填充。这 10,000 行将作为索引传递给工作函数,工作函数将依次操作每个 320,000 项的列。分配完数组后,我们将它填充为生命、宇宙和一切的答案 (42!)。我们可以在工作函数中测试,我们收到的是修改后的数组,而不是填充为 0 的版本,以确认此代码的行为是否符合预期。

示例 9-27. 设置共享数组
$ python np_shared.py
Created shared array with 25,600,000,000 nbytes
Shared array id is 139636238840896 in PID 27628
Starting with an array of 0 values:
[[ 0\.  0\.  0\. ...,  0\.  0\.  0.]
 ...,
 [ 0\.  0\.  0\. ...,  0\.  0\.  0.]]

Original array filled with value 42:
[[ 42\.  42\.  42\. ...,  42\.  42\.  42.]
 ...,
 [ 42\.  42\.  42\. ...,  42\.  42\.  42.]]
Press a key to start workers using multiprocessing...

在 示例 9-28 中,我们启动了四个进程来处理这个共享数组。没有复制数组;每个进程都在查看相同的大内存块,并且每个进程有一组不同的索引来操作。每隔几千行,工作进程输出当前索引和其 PID,以便我们观察其行为。工作进程的工作是微不足道的 —— 它将检查当前元素是否仍设置为默认值(这样我们就知道没有其他进程已经修改它),然后将该值覆盖为当前 PID。一旦工作进程完成,我们返回到父进程并再次打印数组。这次,我们看到它填满了 PID,而不是 42

示例 9-28. 在共享数组上运行 worker_fn
 worker_fn: with idx 0
  id of local_nparray_in_process is 139636238840896 in PID 27751
 worker_fn: with idx 2000
  id of local_nparray_in_process is 139636238840896 in PID 27754
 worker_fn: with idx 1000
  id of local_nparray_in_process is 139636238840896 in PID 27752
 worker_fn: with idx 4000
  id of local_nparray_in_process is 139636238840896 in PID 27753
 ...
 worker_fn: with idx 8000
  id of local_nparray_in_process is 139636238840896 in PID 27752

The default value has been overwritten with worker_fn's result:
[[27751\. 27751\. 27751\. ... 27751\. 27751\. 27751.]
 ...
 [27751\. 27751\. 27751\. ... 27751\. 27751\. 27751.]]

最后,在 示例 9-29 中,我们使用 Counter 来确认数组中每个 PID 的频率。由于工作被均匀分配,我们期望看到四个 PID 各自表示相等次数。在我们的 32 亿元素数组中,我们看到四组 8 亿次 PID。表格输出使用 PrettyTable 呈现。

示例 9-29. 验证共享数组的结果
Verification - extracting unique values from 3,200,000,000 items
in the numpy array (this might be slow)...
Unique values in main_nparray:
+---------+-----------+
|   PID   |   Count   |
+---------+-----------+
| 27751.0 | 800000000 |
| 27752.0 | 800000000 |
| 27753.0 | 800000000 |
| 27754.0 | 800000000 |
+---------+-----------+
Press a key to exit...

完成后,程序退出,数组被删除。

我们可以通过使用 pspmap 在 Linux 下查看每个进程的详细信息。示例 9-30 显示了调用 ps 的结果。分解这个命令行:

  • ps 告诉我们关于进程的信息。

  • -A 列出所有进程。

  • -o pid,size,vsize,cmd 输出 PID、大小信息和命令名称。

  • grep用于过滤所有其他结果,仅保留演示的行。

父进程(PID 27628)及其四个分叉子进程显示在输出中。结果类似于我们在htop中看到的。我们可以使用pmap查看每个进程的内存映射,并使用-x请求扩展输出。我们使用grep筛选标记为共享的内存块的模式s-。在父进程和子进程中,我们看到一个共享的 25,000,000 KB(25.6 GB)块。

示例 9-30。使用 pmapps 来调查操作系统对进程的视图
$ ps -A -o pid,size,vsize,cmd | grep np_shared
27628 279676 25539428 python np_shared.py
27751 279148 25342688 python np_shared.py
27752 279148 25342688 python np_shared.py
27753 279148 25342688 python np_shared.py
27754 279148 25342688 python np_shared.py

ian@ian-Latitude-E6420 $ pmap -x 27628 | grep s-
Address           Kbytes     RSS   Dirty Mode   Mapping
00007ef9a2853000 25000000 25000000 2584636 rw-s- pym-27628-npfjsxl6 (deleted)
...
ian@ian-Latitude-E6420 $ pmap -x 27751 | grep s-
Address           Kbytes     RSS   Dirty Mode   Mapping
00007ef9a2853000 25000000 6250104 1562508 rw-s- pym-27628-npfjsxl6 (deleted)
...

我们将使用 multiprocessing.Array 来分配一个共享的内存块作为一个 1D 数组,然后从这个对象实例化一个 numpy 数组并将其重塑为一个 2D 数组。现在我们有一个可以在进程之间共享并像普通 numpy 数组一样访问的 numpy 包装的内存块。numpy 不管理 RAM;multiprocessing.Array 在管理它。

在 示例 9-31 中,您可以看到每个分叉进程都可以访问全局的main_nparray。虽然分叉的进程拥有numpy对象的副本,但对象访问的底层字节存储为共享内存。我们的worker_fn将使用当前进程标识符覆盖选择的行(通过idx)。

示例 9-31。使用 multiprocessing 共享 numpy 数组的 worker_fn
import os
import multiprocessing
from collections import Counter
import ctypes
import numpy as np
from prettytable import PrettyTable

SIZE_A, SIZE_B = 10_000, 320_000  # 24GB

def worker_fn(idx):
    """Do some work on the shared np array on row idx"""
    # confirm that no other process has modified this value already
    assert main_nparray[idx, 0] == DEFAULT_VALUE
    # inside the subprocess print the PID and ID of the array
    # to check we don't have a copy
    if idx % 1000 == 0:
        print(" {}: with idx {}\n id of local_nparray_in_process is {} in PID {}"\
            .format(worker_fn.__name__, idx, id(main_nparray), os.getpid()))
    # we can do any work on the array; here we set every item in this row to
    # have the value of the process ID for this process
    main_nparray[idx, :] = os.getpid()

在我们的 __main__ 中 示例 9-32,我们将通过三个主要阶段来进行工作:

  1. 构建一个共享的 multiprocessing.Array 并将其转换为一个 numpy 数组。

  2. 将默认值设置到数组中,并生成四个进程以并行处理数组。

  3. 在进程返回后验证数组的内容。

通常,您会设置一个numpy数组并在单个进程中处理它,可能会执行类似于arr = np.array((100, 5), dtype=np.float_)的操作。在单个进程中这样做没问题,但是您无法将这些数据跨进程进行读写共享。

制作共享字节块的技巧之一是创建multiprocessing.Array。默认情况下,Array 被包装在一个锁中以防止并发编辑,但我们不需要这个锁,因为我们将小心处理我们的访问模式。为了清楚地向其他团队成员传达这一点,明确设置lock=False是值得的。

如果不设置 lock=False,您将得到一个对象而不是字节的引用,您需要调用 .get_obj() 来获取字节。通过调用 .get_obj(),您绕过了锁,因此在一开始明确这一点是非常重要的。

接下来,我们将这一块可共享的字节块使用 frombuffer 包装成一个 numpy 数组。dtype 是可选的,但由于我们传递的是字节,显式指定类型总是明智的。我们使用 reshape 来将字节地址化为二维数组。默认情况下,数组的值被设置为 0。示例 9-32 显示了我们的 __main__ 完整内容。

示例 9-32. __main__ 用于设置 numpy 数组以供共享
if __name__ == '__main__':
    DEFAULT_VALUE = 42
    NBR_OF_PROCESSES = 4

    # create a block of bytes, reshape into a local numpy array
    NBR_ITEMS_IN_ARRAY = SIZE_A * SIZE_B
    shared_array_base = multiprocessing.Array(ctypes.c_double,
                                              NBR_ITEMS_IN_ARRAY, lock=False)
    main_nparray = np.frombuffer(shared_array_base, dtype=ctypes.c_double)
    main_nparray = main_nparray.reshape(SIZE_A, SIZE_B)
    # assert no copy was made
    assert main_nparray.base.base is shared_array_base
    print("Created shared array with {:,} nbytes".format(main_nparray.nbytes))
    print("Shared array id is {} in PID {}".format(id(main_nparray), os.getpid()))
    print("Starting with an array of 0 values:")
    print(main_nparray)
    print()

为了确认我们的进程是否在我们开始时的同一块数据上运行,我们将每个项目设置为一个新的 DEFAULT_VALUE(我们再次使用 42,生命、宇宙和一切的答案)。您可以在 示例 9-33 的顶部看到。接下来,我们构建了一个进程池(这里是四个进程),然后通过调用 map 发送批次的行索引。

示例 9-33. 使用 multiprocessing 共享 numpy 数组的 __main__
    # Modify the data via our local numpy array
    main_nparray.fill(DEFAULT_VALUE)
    print("Original array filled with value {}:".format(DEFAULT_VALUE))
    print(main_nparray)

    input("Press a key to start workers using multiprocessing...")
    print()

    # create a pool of processes that will share the memory block
    # of the global numpy array, share the reference to the underlying
    # block of data so we can build a numpy array wrapper in the new processes
    pool = multiprocessing.Pool(processes=NBR_OF_PROCESSES)
    # perform a map where each row index is passed as a parameter to the
    # worker_fn
    pool.map(worker_fn, range(SIZE_A))

当并行处理完成后,我们返回到父进程验证结果(示例 9-34)。验证步骤通过数组的展平视图来执行(请注意,视图不会进行复制;它只是在二维数组上创建一个一维可迭代视图),计算每个进程 ID 的频率。最后,我们执行一些 assert 检查以确保得到预期的计数。

示例 9-34. 用于验证共享结果的 __main__
    print("Verification - extracting unique values from {:,} items\n in the numpy \
 array (this might be slow)...".format(NBR_ITEMS_IN_ARRAY))
    # main_nparray.flat iterates over the contents of the array, it doesn't
    # make a copy
    counter = Counter(main_nparray.flat)
    print("Unique values in main_nparray:")
    tbl = PrettyTable(["PID", "Count"])
    for pid, count in list(counter.items()):
        tbl.add_row([pid, count])
    print(tbl)

    total_items_set_in_array = sum(counter.values())

    # check that we have set every item in the array away from DEFAULT_VALUE
    assert DEFAULT_VALUE not in list(counter.keys())
    # check that we have accounted for every item in the array
    assert total_items_set_in_array == NBR_ITEMS_IN_ARRAY
    # check that we have NBR_OF_PROCESSES of unique keys to confirm that every
    # process did some of the work
    assert len(counter) == NBR_OF_PROCESSES

    input("Press a key to exit...")

刚刚我们创建了一个字节的一维数组,将其转换为二维数组,共享该数组给四个进程,并允许它们同时处理同一块内存区域。这个技巧将帮助您在许多核心上实现并行化。不过要注意并发访问相同的数据点——如果想避免同步问题,就必须使用 multiprocessing 中的锁,但这会减慢您的代码执行速度。

同步文件和变量访问

在接下来的示例中,我们将看到多个进程共享和操作状态——在这种情况下,四个进程递增一个共享计数器一定次数。如果没有同步过程,计数将不正确。如果您要以一致的方式共享数据,您总是需要一种同步读写数据的方法,否则会出现错误。

通常,同步方法特定于您使用的操作系统,并且通常特定于您使用的语言。在这里,我们使用 Python 库来进行基于文件的同步,并在 Python 进程之间共享整数对象。

文件锁定

在本节中,文件读写将是数据共享中最慢的例子。

您可以在 示例 9-35 中看到我们的第一个 work 函数。该函数迭代一个本地计数器。在每次迭代中,它打开一个文件并读取现有值,将其增加一,然后将新值写入旧值所在的位置。在第一次迭代中,文件将为空或不存在,因此它将捕获异常并假定该值应为零。

提示

这里给出的示例是简化的—在实践中,更安全的做法是使用上下文管理器打开文件,例如 with open(*filename*, "r") as f:。如果上下文中引发异常,文件 f 将被正确关闭。

示例 9-35. 没有锁定的 work 函数
def work(filename, max_count):
    for n in range(max_count):
        f = open(filename, "r")
        try:
            nbr = int(f.read())
        except ValueError as err:
            print("File is empty, starting to count from 0, error: " + str(err))
            nbr = 0
        f = open(filename, "w")
        f.write(str(nbr + 1) + '\n')
        f.close()

让我们使用一个进程运行这个示例。您可以在 示例 9-36 中看到输出。work 被调用一千次,预期的是它能正确计数而不丢失任何数据。在第一次读取时,它看到一个空文件。这导致了 int()invalid literal for int() 错误(因为在空字符串上调用了 int())。这个错误只会发生一次;之后我们总是有一个有效的值来读取并转换为整数。

示例 9-36. 在没有锁定并且使用一个进程的文件计数的时间安排
$ python ex1_nolock1.py
Starting 1 process(es) to count to 1000
File is empty, starting to count from 0,
error: invalid literal for int() with base 10: ''
Expecting to see a count of 1000
count.txt contains:
1000

现在我们将使用四个并发进程运行相同的 work 函数。我们没有任何锁定代码,因此我们预计会得到一些奇怪的结果。

提示

在您查看以下代码之前,请思考当两个进程同时从同一文件读取或写入时,您可以期望看到什么 两种 类型的错误?考虑代码的两个主要状态(每个进程的执行开始和每个进程的正常运行状态)。

看一下 示例 9-37 以查看问题。首先,当每个进程启动时,文件为空,因此每个进程都试图从零开始计数。其次,当一个进程写入时,另一个进程可以读取部分写入的结果,无法解析。这会导致异常,并且将回写一个零。这反过来导致我们的计数器不断被重置!您能看到两个并发进程写入了 \n 和两个值到同一个打开的文件,导致第三个进程读取到一个无效的条目吗?

示例 9-37. 在没有锁定并且使用四个进程的文件计数的时间安排
$ python ex1_nolock4.py
Starting 4 process(es) to count to 4000
File is empty, starting to count from 0,
error: invalid literal for int() with base 10: ''
*# many errors like these*
File is empty, starting to count from 0,
error: invalid literal for int() with base 10: ''
Expecting to see a count of 4000
count.txt contains:
112

$ python -m timeit -s "import ex1_nolock4" "ex1_nolock4.run_workers()"
2 loops, best of 5: 158 msec per loop

示例 9-38 展示了调用带有四个进程的 workmultiprocessing 代码。请注意,我们不是使用 map,而是在建立 Process 对象的列表。虽然在此处我们没有使用这个功能,但 Process 对象使我们能够审视每个 Process 的状态。我们鼓励您 阅读文档 了解为什么您可能希望使用 Process

示例 9-38. run_workers 设置四个进程
import multiprocessing
import os

...
MAX_COUNT_PER_PROCESS = 1000
FILENAME = "count.txt"
...

def run_workers():
    NBR_PROCESSES = 4
    total_expected_count = NBR_PROCESSES * MAX_COUNT_PER_PROCESS
    print("Starting {} process(es) to count to {}".format(NBR_PROCESSES,

														                                            total_expected_count))
    # reset counter
    f = open(FILENAME, "w")
    f.close()

    processes = []
    for process_nbr in range(NBR_PROCESSES):
        p = multiprocessing.Process(target=work, args=(FILENAME,

													                                          MAX_COUNT_PER_PROCESS))
        p.start()
        processes.append(p)

    for p in processes:
        p.join()

    print("Expecting to see a count of {}".format(total_expected_count))
    print("{} contains:".format(FILENAME))
    os.system('more ' + FILENAME)

if __name__ == "__main__":
    run_workers()

使用fasteners模块,我们可以引入一种同步方法,这样一次只有一个进程可以写入,其他进程则各自等待它们的轮次。因此整个过程运行速度较慢,但不会出错。您可以在示例 9-39 中看到正确的输出。请注意,锁定机制特定于 Python,因此查看此文件的其他进程将不会关心此文件的“锁定”性质。

示例 9-39. 带锁和四个进程的文件计数时间
$ python ex1_lock.py
Starting 4 process(es) to count to 4000
File is empty, starting to count from 0,
error: invalid literal for int() with base 10: ''
Expecting to see a count of 4000
count.txt contains:
4000
$ python -m timeit -s "import ex1_lock" "ex1_lock.run_workers()"
10 loops, best of 3: 401 msec per loop

使用fasteners在示例 9-40 中加入了一行代码,使用@fasteners.interprocess_locked装饰器;文件名可以是任何东西,但最好与您想要锁定的文件名称相似,这样在命令行中进行调试会更容易。请注意,我们没有必要更改内部函数;装饰器在每次调用时获取锁,并且在进入work之前会等待能够获取锁。

示例 9-40. 带锁的work函数
@fasteners.interprocess_locked('/tmp/tmp_lock')
def work(filename, max_count):
    for n in range(max_count):
        f = open(filename, "r")
        try:
            nbr = int(f.read())
        except ValueError as err:
            print("File is empty, starting to count from 0, error: " + str(err))
            nbr = 0
        f = open(filename, "w")
        f.write(str(nbr + 1) + '\n')
        f.close()

锁定一个值

multiprocessing模块为在进程之间共享 Python 对象提供了几种选项。我们可以使用低通信开销共享原始对象,还可以使用Manager共享更高级的 Python 对象(例如字典和列表),但请注意同步成本会显著减慢数据共享的速度。

在这里,我们将使用一个multiprocessing.Value对象来在进程之间共享整数。虽然Value有一个锁,但锁并不完全符合您的期望——它防止同时读取或写入,但提供原子增量。示例 9-41 说明了这一点。您可以看到我们最终得到了一个不正确的计数;这与我们之前查看的基于文件的未同步示例类似。

示例 9-41. 无锁导致计数不正确
$ python ex2_nolock.py
Expecting to see a count of 4000
We have counted to 2340
$ python -m timeit -s "import ex2_nolock" "ex2_nolock.run_workers()"
20 loops, best of 5: 9.97 msec per loop

数据未损坏,但我们错过了一些更新。如果您从一个进程向Value写入并在其他进程中消耗(但不修改)该Value,这种方法可能适合。

共享Value的代码显示在示例 9-42 中。我们必须指定数据类型和初始化值——使用Value("i", 0),我们请求一个带有默认值0的有符号整数。这作为常规参数传递给我们的Process对象,后者在幕后负责在进程之间共享同一块字节。要访问Value持有的原始对象,我们使用.value。请注意,我们要求原地添加——我们期望这是一个原子操作,但Value不支持这一点,因此我们的最终计数低于预期。

示例 9-42. 没有Lock的计数代码
import multiprocessing

def work(value, max_count):
    for n in range(max_count):
        value.value += 1

def run_workers():
...
    value = multiprocessing.Value('i', 0)
    for process_nbr in range(NBR_PROCESSES):
        p = multiprocessing.Process(target=work, args=(value, MAX_COUNT_PER_PROCESS))
        p.start()
        processes.append(p)
...

你可以在示例 9-43 中看到使用multiprocessing.Lock的正确同步计数。

示例 9-43. 使用Lock同步对Value的写入
*`# lock on the update, but this isn't atomic`*
$ `python` `ex2_lock``.``py`
Expecting to see a count of 4000
We have counted to 4000
$ `python` `-``m` `timeit` `-``s` `"``import ex2_lock``"` `"``ex2_lock.run_workers()``"`
20 loops, best of 5: 19.3 msec per loop

在示例 9-44 中,我们使用了上下文管理器(with Lock)来获取锁定。

示例 9-44. 使用上下文管理器获取Lock
import multiprocessing

def work(value, max_count, lock):
    for n in range(max_count):
        with lock:
            value.value += 1

def run_workers():
...
    processes = []
    lock = multiprocessing.Lock()
    value = multiprocessing.Value('i', 0)
    for process_nbr in range(NBR_PROCESSES):
        p = multiprocessing.Process(target=work,
                                    args=(value, MAX_COUNT_PER_PROCESS, lock))
        p.start()
        processes.append(p)
...

如果我们避免使用上下文管理器,直接用acquirerelease包装我们的增量,我们可以稍微快一点,但与使用上下文管理器相比,代码可读性较差。我们建议坚持使用上下文管理器以提高可读性。示例 9-45 中的片段显示了如何acquirerelease Lock对象。

示例 9-45. 内联锁定而不使用上下文管理器
lock.acquire()
value.value += 1
lock.release()

由于Lock无法提供我们所需的粒度级别,它提供的基本锁定会不必要地浪费一些时间。我们可以将Value替换为RawValue,如示例 9-46 中所示,并实现渐进式加速。如果你对查看这种更改背后的字节码感兴趣,请阅读Eli Bendersky 的博客文章

示例 9-46. 显示更快RawValueLock方法的控制台输出
*`# RawValue has no lock on it`*
$ `python` `ex2_lock_rawvalue``.``py`
Expecting to see a count of 4000
We have counted to 4000
$ `python` `-``m` `timeit` `-``s` `"``import ex2_lock_rawvalue``"` `"``ex2_lock_rawvalue.run_workers()``"`
50 loops, best of 5: 9.49 msec per loop

要使用RawValue,只需将其替换为Value,如示例 9-47 所示。

示例 9-47. 使用RawValue整数的示例
...
def run_workers():
...
    lock = multiprocessing.Lock()
    value = multiprocessing.RawValue('i', 0)
    for process_nbr in range(NBR_PROCESSES):
        p = multiprocessing.Process(target=work,
                                    args=(value, MAX_COUNT_PER_PROCESS, lock))
        p.start()
        processes.append(p)

如果我们共享的是原始对象数组,则可以使用RawArray代替multiprocessing.Array

我们已经看过了在单台机器上将工作分配给多个进程的各种方式,以及在这些进程之间共享标志和同步数据共享的方法。但请记住,数据共享可能会带来麻烦—尽量避免。让一台机器处理所有状态共享的边缘情况是困难的;当你第一次必须调试多个进程的交互时,你会意识到为什么公认的智慧是尽可能避免这种情况。

考虑编写运行速度稍慢但更容易被团队理解的代码。使用像 Redis 这样的外部工具来共享状态,可以使系统在运行时由开发人员之外的其他人检查—这是一种强大的方式,可以使你的团队了解并掌控并行系统的运行情况。

一定要记住,经过调整的高性能 Python 代码不太可能被团队中较新手的成员理解—他们要么会对此感到恐惧,要么会破坏它。为了保持团队的速度,避免这个问题(并接受速度上的牺牲)。

总结

在本章中,我们涵盖了很多内容。首先,我们看了两个尴尬的并行问题,一个具有可预测的复杂性,另一个具有不可预测的复杂性。当我们讨论聚类时,我们将在多台机器上再次使用这些示例。

接下来,我们看了看multiprocessing中的Queue支持及其开销。总的来说,我们建议使用外部队列库,以便队列的状态更透明。最好使用易于阅读的作业格式,以便易于调试,而不是使用 pickled 数据。

IPC 讨论应该让您了解到使用 IPC 有效地有多困难,以及仅仅使用天真的并行解决方案(没有 IPC)可能是有道理的。购买一台更快的具有更多核心的计算机可能比尝试使用 IPC 来利用现有机器更为务实。

在并行情况下共享numpy矩阵而不复制对于只有少数一些问题非常重要,但在关键时刻,它将真正重要。这需要写入一些额外的代码,并需要一些合理性检查,以确保在进程之间确实没有复制数据。

最后,我们讨论了使用文件和内存锁来避免数据损坏的问题——这是一个难以察觉和难以追踪错误的来源,本节向您展示了一些强大且轻量级的解决方案。

在下一章中,我们将讨论使用 Python 进行聚类。通过使用集群,我们可以摆脱单机并行性,并利用一组机器上的 CPU。这引入了一种新的调试痛苦的世界——不仅您的代码可能存在错误,而且其他机器也可能存在错误(无论是由于错误的配置还是由于硬件故障)。我们将展示如何使用 Parallel Python 模块并如何在 IPython 中运行研究代码,以在 IPython 集群中并行化 pi 估算演示。

¹ 参见Brett Foster 的 PowerPoint 演示,讲解如何使用蒙特卡洛方法估算 pi。

² 请参阅 Stack Overflow 主题

第十章:集群和作业队列

集群通常被认为是一组共同工作以解决共同任务的计算机的集合。从外部看,它可以被视为一个更大的单一系统。

在上世纪九十年代,使用本地区域网络上的廉价个人电脑集群进行集群处理的概念,即被称为贝奥武夫集群,变得流行起来。后来,Google通过在自己的数据中心使用廉价个人电脑集群,特别是用于运行 MapReduce 任务,进一步推动了这一实践。在另一个极端,TOP500 项目每年排名最强大的计算机系统;这些系统通常采用集群设计,而最快的机器都使用 Linux。

Amazon Web Services(AWS)通常用于在云中构建工程生产集群以及为机器学习等短期任务构建按需集群。通过 AWS,您可以租用从微型到大型机器,拥有 10 个 CPU 和高达 768 GB 的 RAM,每小时租金为 1 到 15 美元。可以额外支付租用多个 GPU。如果您想要探索 AWS 或其他提供商在计算密集或内存密集任务上的临时集群,可以查看“使用 IPython 并行支持研究”和 ElastiCluster 包。

不同的计算任务在集群中需要不同的配置、大小和功能。我们将在本章中定义一些常见场景。

在您转移到集群解决方案之前,请确保您已经完成了以下工作:

  • 分析您的系统,以了解瓶颈

  • 利用像 Numba 和 Cython 这样的编译器解决方案

  • 利用单台机器上的多个核心(可能是一台具有多个核心的大型机器),使用 Joblib 或multiprocessing

  • 利用技术来减少 RAM 使用

将系统保持在一台机器上可以让您的生活更加轻松(即使这台“一台机器”是一台配置非常强大、内存和 CPU 都很多的计算机)。如果您确实需要大量的 CPU 或者能够并行处理数据从磁盘读取的能力,或者您有高可靠性和快速响应的生产需求,请迁移到一个集群。大多数研究场景不需要弹性或可扩展性,并且仅限于少数人,因此通常最简单的解决方案是最明智的选择。

留在一个机器上的好处是像 Dask 这样的工具可以快速并行化您的 Pandas 或纯 Python 代码,而无需网络复杂性。Dask 还可以控制一组机器以并行化处理 Pandas、NumPy 和纯 Python 问题。Swifter 通过在 Dask 上共享负载,自动并行化一些多核单机案例。我们稍后在本章介绍 Dask 和 Swifter 两者。

集群的好处

集群最明显的好处是您可以轻松扩展计算需求 — 如果您需要处理更多数据或更快地获得答案,只需添加更多机器(或节点)。

通过添加机器,您还可以提高可靠性。每台机器的组件有一定的故障可能性,但是通过良好的设计,多个组件的故障不会停止集群的运行。

集群还用于创建动态扩展的系统。一个常见的用例是将一组处理网络请求或相关数据的服务器集群化(例如,调整用户照片大小、转码视频或转录语音),并在一天中某些时间段内随着需求增加而激活更多的服务器。

动态扩展是处理非均匀使用模式的一种非常经济高效的方式,只要机器激活时间足够快以应对需求变化的速度。

提示

考虑建立集群的投入与回报。虽然集群的并行化增益可能会令人心动,但请考虑与构建和维护集群相关的成本。它们非常适合生产环境中长时间运行的流程或明确定义且经常重复的研发任务。对于变量和短期的研发任务,它们则不那么吸引人。

集群的一个微妙的好处是,集群可以在地理上分开但仍然集中控制。如果一个地理区域遭受故障(例如洪水或停电),另一个集群可以继续工作,也许会增加更多处理单元来处理需求。集群还允许您运行异构软件环境(例如不同版本的操作系统和处理软件),这可能会提高整个系统的鲁棒性——但请注意,这绝对是一个高级话题!

集群的缺点

迁移到集群解决方案需要改变思维方式。这是从串行代码到并行代码所需的思维变革的进化,就像我们在第九章中介绍的那样。突然间,你不得不考虑当你有多台机器时会发生什么——你有机器之间的延迟,你需要知道你的其他机器是否在工作,并且你需要保持所有机器运行相同版本的软件。系统管理可能是你面临的最大挑战。

此外,通常你必须深思熟虑正在实施的算法,以及一旦所有这些额外的运动部分可能需要保持同步时会发生什么。这种额外的规划可能会带来沉重的心理负担;它很可能会让你分心,一旦系统变得足够大,你可能需要增加一个专门的工程师到你的团队中。

注意

在本书中,我们尝试专注于有效使用一台计算机,因为我们认为只处理一台计算机比处理集合更容易(尽管我们承认玩集群可能会更有趣——直到它出问题为止)。如果您可以垂直扩展(购买更多的 RAM 或更多的 CPU),则值得调查这种方法是否优于集群。当然,您的处理需求可能超出了垂直扩展的可能性,或者集群的稳健性可能比拥有单一机器更重要。然而,如果您是一个单独的人在处理这个任务,也要记住运行一个集群将会占用您的一些时间。

当设计集群解决方案时,您需要记住每台机器的配置可能不同(每台机器的负载和本地数据都不同)。您如何将所有正确的数据传送到正在处理作业的机器上?将作业和数据移动涉及的延迟是否会成为问题?您的作业是否需要相互通信部分结果?如果一个进程失败或者一台机器故障,或者一些硬件在多个作业运行时自行清除,会发生什么情况?如果您不考虑这些问题,可能会引入故障。

您还应考虑到故障可以被接受的可能性。例如,当您运行基于内容的网络服务时,可能不需要 99.999%的可靠性——如果偶尔作业失败(例如,图片无法及时调整大小)并要求用户重新加载页面,那是每个人都已经习惯了的事情。这可能不是您想要给用户的解决方案,但通常接受一点失败可以显著降低工程和管理成本。另一方面,如果高频交易系统出现故障,糟糕的股市交易成本可能是可观的!

维护固定基础设施可能会变得昂贵。购买机器相对便宜,但它们有一个令人头痛的毛病——自动软件升级可能会出现故障,网络卡故障,磁盘写入错误,电源供应器可能提供干扰数据的尖峰电源,宇宙射线可能会在 RAM 模块中翻转位。您拥有的计算机越多,处理这些问题所需的时间就会越多。迟早您会想要引入一个能够处理这些问题的系统工程师,所以预算中要再增加$100,000。使用基于云的集群可以减少许多这些问题(成本更高,但无需处理硬件维护),一些云服务提供商还提供按需市场定价,用于获取便宜但临时的计算资源。

随着时间的推移,有机生长的集群中存在的一个阴险问题是,如果一切都关闭了,可能没有人记录如何安全地重新启动它。如果没有记录的重新启动计划,你应该假设在最糟糕的时候将不得不编写一个(我们的一位作者曾在圣诞前夕处理这种问题 —— 这可不是你想要的圣诞礼物!)。此时,你还将了解系统中每个部分启动需要多长时间 —— 每个集群部分可能需要几分钟来启动和处理作业,因此如果有 10 个部分依次操作,整个系统从冷启动到运行可能需要一个小时。其结果是你可能有一个小时的积压数据。那么你是否有必要的容量及时处理这些积压数据呢?

懈怠的行为可能导致昂贵的错误,复杂和难以预料的行为可能导致意外和昂贵的后果。让我们看看两个高调的集群故障,并学习其中的教训。

通过糟糕的集群升级策略导致的 462 亿美元华尔街损失

2012 年,高频交易公司 Knight Capital 在集群软件升级期间引入错误后,损失了 4.62 亿美元。软件下达了比客户要求的更多的股票订单。

在交易软件中,一个旧的标志被重新用于新的功能。升级已经在八台实时机器中的七台上推出,但第八台机器使用旧代码处理标志,导致交易错误。证券交易委员会(SEC)指出,Knight Capital 没有让第二位技术人员审查该升级,实际上也没有建立审查此类升级的流程。

这个根本性错误似乎有两个原因。第一个原因是软件开发过程没有移除一个已过时的特性,因此陈旧的代码仍然存在。第二个原因是没有设置手动审查流程来确认升级是否成功完成。

技术债务会增加一笔成本,最终必须付清 —— 最好在无压力的时候花时间清除债务。无论是构建还是重构代码,始终使用单元测试。在系统升级过程中缺乏书面检查清单和第二双眼睛,可能会导致昂贵的失败。飞行员在起飞前要按照清单逐项检查是有原因的:这意味着无论之前做过多少次,都不会漏掉重要步骤!

Skype 全球 24 小时服务中断

Skype 在 2010 年经历了24 小时全球范围内的故障。在幕后,Skype 由对等网络支持。系统的某一部分(用于处理离线即时消息)过载导致 Windows 客户端的响应延迟;某些版本的 Windows 客户端未能正确处理延迟响应而崩溃。总体而言,大约 40%的活跃客户端崩溃,包括 25%的公共超级节点。超级节点对网络中的数据路由至关重要。

路由的 25%离线(它后来恢复了,但速度很慢),整个网络处于极大的压力之下。崩溃的 Windows 客户端节点也在重新启动并尝试重新加入网络,给已经过载的系统增加了新的流量。如果超级节点承受过多负载,它们会采取后退程序,因此它们开始响应波浪式流量而关闭。

Skype 在整整 24 小时内大部分时间都无法使用。恢复过程首先涉及设置数百个新的“超级节点”,配置以处理增加的流量,然后继续设置数千个节点。在接下来的几天里,网络逐渐恢复正常。

这一事件给 Skype 带来了很多尴尬;显然,它也改变了焦点,几天内主要集中在损害控制上。客户被迫寻找语音通话的替代解决方案,这可能成为竞争对手的市场优势。

鉴于网络的复杂性和失败的升级,这次故障很可能难以预测和计划。网络上没有所有节点失败的原因是软件的不同版本和不同平台——拥有异构网络而不是同构系统有可靠性的好处。

常见的集群设计

常见的做法是从一个局域的临时集群开始,使用相对等价的机器。你可能会想知道是否可以将旧计算机添加到临时网络中,但通常旧的 CPU 消耗大量电力并且运行非常慢,因此与一台新的高规格机器相比,它们贡献的远不如你希望的那么多。办公室内的集群需要有人来维护。在Amazon 的 EC2Microsoft 的 Azure,或者由学术机构运行的集群,硬件支持交给了服务提供商的团队。

如果你已经理解了处理需求,设计一个定制集群可能是明智的选择——也许使用 InfiniBand 高速互联代替千兆以太网,或者使用支持你的读写或容错要求的特定配置 RAID 驱动器。你可能希望在一些机器上结合 CPU 和 GPU,或者只是默认使用 CPU。

您可能需要一个像SETI@homeFolding@home项目使用的大规模分散处理集群,通过伯克利开放网络计算基础设施(BOINC)系统共享集中协调系统,但计算节点以临时方式加入和退出项目。

在硬件设计之上,您可以运行不同的软件架构。工作队列是最常见且最容易理解的。通常,作业被放入队列并由处理器消耗。处理的结果可能会进入另一个队列进行进一步处理,或者作为最终结果使用(例如,添加到数据库中)。消息传递系统略有不同——消息被放入消息总线,然后被其他机器消耗。消息可能会超时并被删除,并且可能会被多个机器消耗。在更复杂的系统中,进程通过进程间通信相互交流——这可以被认为是专家级别的配置,因为有很多方法可以设置得很糟糕,这将导致您失去理智。只有当您确实知道需要它时,才可以选择使用 IPC 路线。

如何启动一个集群解决方案

启动集群系统的最简单方法是从一个机器开始,该机器将同时运行作业服务器和作业处理器(每个 CPU 只有一个作业处理器)。如果您的任务是 CPU 绑定的,请为每个 CPU 运行一个作业处理器;如果任务是 I/O 绑定的,请为每个 CPU 运行多个作业处理器。如果它们受 RAM 限制,请小心不要用完 RAM。让您的单机解决方案使用一个处理器运行,并逐步增加更多处理器。通过不可预测的方式使您的代码失败(例如,在您的代码中执行1/0,在您的工作进程上使用kill -9 <pid>,从插座上拔下电源插头,使整个机器死机),以检查您的系统是否健壮。

显然,您需要进行比这更严格的测试——一个充满编码错误和人为异常的单元测试套件很好。Ian 喜欢引入意外事件,例如让一个处理器运行一组作业,同时一个外部进程正在系统地终止重要进程,并确认所有这些进程都能被使用的监控进程干净地重新启动。

一旦您有一个正在运行的作业处理器,添加第二个。检查您是否没有使用太多 RAM。您处理作业的速度是否比以前快两倍?

现在引入第二台机器,该新机器上只有一个作业处理器,并且协调机器上没有作业处理器。它处理作业的速度是否与您在协调机器上有处理器时一样快?如果不是,为什么?延迟是否是问题?您是否有不同的配置?也许您有不同的机器硬件,如 CPU、RAM 和缓存大小?

现在再添加另外九台计算机,并测试看看你是否比以前处理作业快 10 倍。如果没有,为什么?是不是现在出现了网络冲突,导致整体处理速度变慢?

为了在机器启动时可靠地启动集群的组件,我们倾向于使用cron任务,Circus,或者supervisord。Circus 和supervisord都是基于 Python 并且已经存在多年。cron虽然老旧,但如果你只是启动像监控进程这样的脚本,它非常可靠,可以根据需要启动子进程。

一旦你拥有一个可靠的集群,你可能想引入像 Netflix 的Chaos Monkey这样的随机杀手工具,它故意杀死系统的一部分来测试其弹性。你的进程和硬件最终会失败,了解你可能至少能够幸存你预测可能发生的错误,这不会伤害。

在使用集群时避免痛苦的方法

在伊恩经历的一个特别痛苦的经历中,集群系统中一系列队列停顿了。后续队列未被消费,因此它们堆积起来。一些机器的 RAM 用尽,导致它们的进程死亡。先前的队列正在处理,但无法将结果传递给下一个队列,因此它们崩溃了。最终,第一个队列被填充但未被消费,因此它崩溃了。之后,我们为供应商的数据付费,最终被丢弃。你必须勾勒一些注意事项,考虑你的集群可能会死亡的各种方式,以及发生时会发生什么(不是如果)。你会丢失数据(这是一个问题吗)?你会有一个太痛苦无法处理的大后台任务吗?

拥有一个易于调试的系统可能比拥有一个更快的系统更重要。工程时间和停机成本可能是你最大的开支(如果你在运行导弹防御程序,这就不是真的,但对于初创公司来说可能是真的)。与其通过使用低级压缩的二进制协议来节省几个字节,不如在传递消息时考虑使用 JSON 中的人类可读文本。这确实会增加发送和解码消息的开销,但当核心计算机着火后,你留下的部分数据库能够快速阅读重要消息时,你会庆幸能够迅速将系统恢复在线。

确保在时间和金钱上廉价地部署系统更新——无论是操作系统更新还是软件的新版本。每当集群中的任何变化发生时,如果处于分裂状态,系统就有可能以奇怪的方式响应。确保使用像FabricSaltChefPuppet这样的部署系统,或者像 Debian 的.deb,RedHat 的.rpm,或Amazon Machine Image这样的系统映像。能够强大地部署一个更新并升级整个集群(并报告任何发现的问题)大大减轻了在困难时期的压力。

积极的报告很有用。每天给某人发送一封电子邮件,详细说明集群的性能。如果这封电子邮件没有送达,那是某事发生的有用线索。你可能还希望有其他更快通知你的早期警报系统;PingdomServer Density在这方面尤为有用。一个反应于事件缺失的“死人开关”(例如,Dead Man’s Switch)是另一个有用的备份。

向团队报告集群健康情况非常有用。这可能是一个 Web 应用程序内的管理页面,或者一个单独的报告。Ganglia在这方面非常棒。Ian 看到过一个类似星际迷航 LCARS 界面的界面在办公室的一个备用 PC 上运行,当检测到问题时会播放“红色警报”声音——这对引起整个办公室的注意特别有效。我们甚至看到 Arduinos 驱动像老式锅炉压力表这样的模拟仪器(当指针移动时发出漂亮的声音!)显示系统负载。这种报告非常重要,以便每个人都明白“正常”和“可能会毁了我们周五晚上”的区别。

两种聚类解决方案

在本节中,我们介绍 IPython Parallel 和 NSQ。

IPython 集群在一台具有多个核心的机器上易于使用。由于许多研究人员将 IPython 作为他们的 shell 或通过 Jupyter Notebooks 工作,自然也会用它来进行并行作业控制。构建一个集群需要一些系统管理知识。使用 IPython Parallel 的一个巨大优势是,你可以像使用本地集群一样轻松地使用远程集群(例如亚马逊的 AWS 和 EC2)。

NSQ 是一个成熟的队列系统。它具有持久性(因此如果机器死机,作业可以由另一台机器继续进行)和强大的可伸缩机制。随着这种更强大的功能,对系统管理和工程技能的需求稍微增加。然而,NSQ 在其简单性和易用性方面表现出色。虽然存在许多排队系统(如流行的Kafka),但没有一个像 NSQ 那样具有如此低的准入门槛。

使用 IPython Parallel 支持研究

IPython 集群支持通过 IPython 并行 项目实现。IPython 成为本地和远程处理引擎的接口,数据可以在引擎之间传递,作业可以推送到远程机器。远程调试是可能的,消息传递接口(MPI)也是可选支持的。这种相同的 ZeroMQ 通信机制支持 Jupyter Notebook 接口。

这对于研究环境非常有用——您可以将作业推送到本地集群中的机器,如果有问题,可以进行交互式调试,将数据推送到机器上,并收集结果,所有这些都是交互式的。请注意,PyPy 运行 IPython 和 IPython 并行。结合起来可能非常强大(如果您不使用 numpy)。

在幕后,ZeroMQ 被用作消息中间件——请注意,ZeroMQ 的设计不提供安全性。如果您在本地网络上构建一个集群,可以避免 SSH 认证。如果您需要安全性,SSH 完全支持,但它使配置变得有点复杂——从一个本地可信网络开始,并随着学习每个组件的工作方式而逐步构建。

该项目分为四个组件。引擎是 IPython 内核的扩展;它是一个同步的 Python 解释器,用于运行你的代码。您将运行一组引擎以启用并行计算。控制器提供了一个与引擎的接口;它负责工作分配并提供了一个直接接口和一个负载平衡接口,该接口提供了一个工作调度器。中心跟踪引擎、调度器和客户端。调度器隐藏了引擎的同步性质并提供了一个异步接口。

在笔记本电脑上,我们使用 ipcluster start -n 4 启动了四个引擎。在 示例 10-1 中,我们启动了 IPython 并检查本地 Client 是否能够看到我们的四个本地引擎。我们可以使用 c[:] 来访问所有四个引擎,并且我们将一个函数应用到每个引擎上——apply_sync 接受一个可调用对象,因此我们提供了一个不带参数的lambda,它将返回一个字符串。我们的四个本地引擎中的每一个都会运行这些函数,返回相同的结果。

示例 10-1. 测试我们是否能够在 IPython 中看到本地引擎
In [1]: import ipyparallel as ipp

In [2]: c = ipp.Client()

In [3]: print(c.ids)
[0, 1, 2, 3]

In [4]: c[:].apply_sync(lambda: "Hello High Performance Pythonistas!")
Out[4]:
['Hello High Performance Pythonistas!',
 'Hello High Performance Pythonistas!',
 'Hello High Performance Pythonistas!',
 'Hello High Performance Pythonistas!']

我们构建的引擎现在处于空状态。如果我们在本地导入模块,它们不会被导入到远程引擎中。

一种干净的方式来进行本地和远程导入是使用 sync_imports 上下文管理器。在 示例 10-2 中,我们将在本地 IPython 和四个连接的引擎上都import os,然后再次在这四个引擎上调用apply_sync来获取它们的 PID。

如果我们没有进行远程导入,我们将会得到一个 NameError,因为远程引擎不会知道 os 模块。我们也可以使用 execute 在引擎上远程运行任何 Python 命令。

示例 10-2. 将模块导入到我们的远程引擎中
In [5]: dview=c[:]  # this is a direct view (not a load-balanced view)

In [6]: with dview.sync_imports():
   ....:     import os
   ....:
importing os on engine(s)

In [7]: dview.apply_sync(lambda:os.getpid())
Out[7]: [16158, 16159, 16160, 16163]

In [8]: dview.execute("import sys")  # another way to execute commands remotely

您将希望将数据推送到引擎。在 示例 10-3 中展示的 push 命令允许您发送一个字典项,这些项会添加到每个引擎的全局命名空间中。有一个对应的 pull 命令用于检索项目:您给它键,它会返回每个引擎对应的值。

示例 10-3. 将共享数据推送到引擎
In [9]: dview.push({'shared_data':[50, 100]})
Out[9]: <AsyncResult: _push>

In [10]: dview.apply_sync(lambda:len(shared_data))
Out[10]: [2, 2, 2, 2]

在 示例 10-4 中,我们使用这四个引擎来估算圆周率。这次我们使用 @require 装饰器在引擎中导入 random 模块。我们使用直接视图将工作发送到引擎;这会阻塞直到所有结果返回。然后我们像 示例 9-1 中那样估算圆周率。

示例 10-4. 使用我们的本地集群估算圆周率
import time
import ipyparallel as ipp
from ipyparallel import require

@require('random')
def estimate_nbr_points_in_quarter_circle(nbr_estimates):
    ...
    return nbr_trials_in_quarter_unit_circle

if __name__ == "__main__":
    c = ipp.Client()
    nbr_engines = len(c.ids)
    print("We're using {} engines".format(nbr_engines))
    nbr_samples_in_total = 1e8
    nbr_parallel_blocks = 4

    dview = c[:]

    nbr_samples_per_worker = nbr_samples_in_total / nbr_parallel_blocks
    t1 = time.time()
    nbr_in_quarter_unit_circles = \

	     dview.apply_sync(estimate_nbr_points_in_quarter_circle,
                         nbr_samples_per_worker)
    print("Estimates made:", nbr_in_quarter_unit_circles)

    nbr_jobs = len(nbr_in_quarter_unit_circles)
    pi_estimate = sum(nbr_in_quarter_unit_circles) * 4 / nbr_samples_in_total
    print("Estimated pi", pi_estimate)
    print("Delta:", time.time() - t1)

在 示例 10-5 中,我们在我们的四个本地引擎上运行这个。如同 图 9-5 中所示,这在笔记本电脑上大约需要 20 秒。

示例 10-5. 在 IPython 中使用我们的本地集群估算圆周率
In [1]: %run pi_ipython_cluster.py
We're using 4 engines
Estimates made: [19636752, 19634225, 19635101, 19638841]
Estimated pi 3.14179676
Delta: 20.68650197982788

IPython Parallel 提供的远不止这里展示的功能。当然,还支持异步作业和在更大输入范围上的映射。支持 MPI,可以提供高效的数据共享。在 “用 Joblib 替换多处理” 中介绍的 Joblib 库可以与 IPython Parallel 一起作为后端使用,以及 Dask(我们在 “使用 Dask 进行并行 Pandas” 中介绍)。

IPython Parallel 的一个特别强大的功能是允许您使用更大的集群环境,包括超级计算机和云服务,如亚马逊的 EC2. ElastiCluster 项目 支持常见的并行环境,如 IPython,以及包括 AWS、Azure 和 OpenStack 在内的部署目标。

使用 Dask 进行并行 Pandas

Dask 的目标是提供一套从笔记本上的单个核心到多核机器再到集群中数千个核心的并行化解决方案。把它想象成“Apache Spark 的精简版”。如果您不需要 Apache Spark 的所有功能(包括复制写入和多机故障转移),并且不想支持第二个计算和存储环境,那么 Dask 可能提供您所需要的并行化和大于内存解决方案。

为了延迟评估多种计算场景,包括纯 Python、科学 Python 和使用小、中、大数据集的机器学习,构建了一个任务图:

Bag

bag 可以对非结构化和半结构化数据进行并行计算,包括文本文件、JSON 或用户定义的对象。支持对通用 Python 对象进行 mapfiltergroupby 操作,包括列表和集合。

数组

array 能够进行分布式和大于 RAM 的 numpy 操作。支持许多常见操作,包括一些线性代数函数。不支持跨核心效率低下的操作(例如排序和许多线性代数操作)。使用线程,因为 NumPy 具有良好的线程支持,所以在并行化操作期间无需复制数据。

分布式数据框

dataframe 能够进行分布式和大于 RAM 的 Pandas 操作;在幕后,Pandas 用于表示使用其索引分区的部分数据框。操作使用 .compute() 惰性计算,并且在其他方面与其 Pandas 对应物非常相似。支持的函数包括 groupby-aggregategroupby-applyvalue_countsdrop_duplicatesmerge。默认情况下使用线程,但由于 Pandas 比 NumPy 更受 GIL 限制,您可能需要查看进程或分布式调度器选项。

Delayed

delayed 扩展了我们在 “Replacing multiprocessing with Joblib” 中介绍的与 Joblib 类似的思想,以惰性方式并行化任意 Python 函数链。visualize() 函数将绘制任务图来帮助诊断问题。

Futures

Client 接口支持即时执行和任务演变,与 delayed 不同,后者是惰性的,不允许像添加或销毁任务这样的操作。Future 接口包括 QueueLock,以支持任务协作。

Dask-ML

提供了类似于 scikit-learn 的接口以进行可扩展的机器学习。Dask-ML 为一些 scikit-learn 算法提供了集群支持,并且使用 Dask 重新实现了一些算法(例如 linear_model 集)以便在大数据上进行学习。它缩小了与 Apache Spark 分布式机器学习工具包之间的差距。还提供了支持 XGBoost 和 TensorFlow 在 Dask 集群中使用的功能。

对于 Pandas 用户,Dask 可以帮助解决两个用例:大于 RAM 的数据集和多核并行化的需求。

如果你的数据集比 Pandas 能够装入 RAM 的还要大,Dask 可以将数据集按行分割成一组分区数据框,称为 分布式数据框。这些数据框按其索引分割;可以在每个分区上执行一部分操作。例如,如果你有一组多 GB 的 CSV 文件,并且想要在所有文件上计算 value_counts,Dask 将在每个数据框(每个文件一个)上执行部分 value_counts,然后将结果合并为单一的计数集。

第二个用例是利用笔记本电脑上的多个核心(以及同样容易地在集群中使用);我们将在这里研究这个用例。回想一下,在 Example 6-24 中,我们用不同的方法计算了数据框中值行的线斜率。让我们使用两种最快的方法,并使用 Dask 进行并行化。

提示

您可以使用 Dask(以及下一节讨论的 Swifter)并行化任何无副作用的函数,通常在apply调用中使用。Ian 已经为大型 DataFrame 中的数字计算和计算文本列的多个文本度量执行了此操作。

使用 Dask 时,我们必须指定要从 DataFrame 中创建的分区数量;一个经验法则是至少使用与核心数相同的分区,以便每个核心都可以被使用。在 Example 10-6 中,我们请求了八个分区。我们使用dd.from_pandas将常规的 Pandas DataFrame 转换为一个 Dask 分布式 DataFrame,分为八个大小相等的部分。

我们在分布式 DataFrame 上调用熟悉的ddf.apply,指定我们的函数ols_lstsq和通过meta参数指定的可选的预期返回类型。Dask 要求我们使用compute()调用指定计算应该何时应用;在这里,我们指定使用processes而不是默认的threads来将工作分布到多个核心上,避免 Python 的 GIL。

Example 10-6. 使用 Dask 在多个核心上计算线斜率
import dask.dataframe as dd

N_PARTITIONS = 8
ddf = dd.from_pandas(df, npartitions=N_PARTITIONS, sort=False)
SCHEDULER = "processes"

results = ddf.apply(ols_lstsq, axis=1, meta=(None, 'float64',)). \
              compute(scheduler=SCHEDULER)

在 Ian 的笔记本电脑上,使用相同的八个分区(在四个核心和四个超线程的条件下)运行ols_lstsq_raw,从之前单线程的apply结果的 6.8 秒提高到 1.5 秒,速度几乎提升了 5 倍。

Example 10-7. 使用 Dask 在多个核心上计算线斜率
results = ddf.apply(ols_lstsq_raw, axis=1, meta=(None, 'float64',), raw=True). \
              compute(scheduler=SCHEDULER)

使用相同的八个分区运行ols_lstsq_raw,将我们从之前使用raw=True单线程apply结果的 5.3 秒提高到 1.2 秒,速度几乎提升了 5 倍。

如果我们还使用从“Numba to Compile NumPy for Pandas”中编译的 Numba 函数并使用raw=True,我们的运行时间从 0.58 秒降低到 0.3 秒,进一步提速了 2 倍。使用 Numba 在 Pandas DataFrame 上使用 NumPy 数组编译的函数非常适合与 Dask 配合使用,而且付出的努力很少。

在 Dask 上使用 Swifter 进行并行应用

Swifter基于 Dask 提供了三个并行选项,只需简单的调用—applyresamplerolling。在幕后,它会对 DataFrame 的子样本进行采样,并尝试向量化函数调用。如果成功,Swifter 将应用它;如果成功但速度慢,Swifter 将使用 Dask 在多个核心上运行它。

由于 Swifter 使用启发式方法确定如何运行您的代码,因此它可能比根本不使用它运行得慢,但尝试的“成本”仅为一行努力。评估它是非常值得的。

Swifter 根据 Dask 决定使用多少个核心以及对其评估进行采样的行数;因此,在 Example 10-8 中,我们看到对df.swifter...apply()的调用看起来就像对df.apply的常规调用。在这种情况下,我们已禁用进度条;在使用优秀的tqdm库的 Jupyter Notebook 中,进度条可以正常工作。

示例 10-8. 使用 Dask 使用多核计算线斜率
import swifter

results = df.swifter.progress_bar(False).apply(ols_lstsq_raw, axis=1, raw=True)

使用 ols_lstsq_raw 和没有分区选择的 Swifter,将我们之前的单线程结果从 5.3 秒降低到 1.6 秒。对于这个特定的函数和数据集,这并不像我们刚刚看过的稍长的 Dask 解决方案那样快,但它确实只用了一行代码就提供了 3 倍的加速。对于不同的函数和数据集,你将会看到不同的结果;进行实验看看是否可以获得非常容易的成功。

用于大于 RAM 的 DataFrame 的 Vaex

Vaex 是一个令人感兴趣的新库,提供了类似于 Pandas DataFrame 的结构,内置支持大于 RAM 的计算。它将 Pandas 和 Dask 的功能整合到一个单独的包中。

Vaex 使用惰性计算来按需计算列结果;它将仅计算用户需要的行子集。例如,如果你要求在两列之间的十亿行上进行求和,并且你只要求结果的样本,Vaex 将仅触及那个样本的数据,不会计算所有未被抽样行的总和。对于交互式工作和基于可视化的探索,这可能非常高效。

Pandas 对字符串的支持来自于 CPython;它受到 GIL 的限制,并且字符串对象是散布在内存中的较大对象,不支持矢量化操作。Vaex 使用自己的自定义字符串库,这使得基于字符串的操作速度显著提高,并具有类似 Pandas 的界面。

如果你正在处理字符串密集的 DataFrame 或大于 RAM 的数据集,Vaex 是一个显而易见的评估选择。如果你通常在 DataFrame 的子集上工作,隐式的惰性评估可能会使你的工作流程比将 Dask 添加到 Pandas DataFrame 更简单。

用于稳健生产聚类的 NSQ

在生产环境中,你将需要比我们到目前为止谈论过的其他解决方案更健壮的解决方案。这是因为在集群的日常运行过程中,节点可能变得不可用,代码可能崩溃,网络可能中断,或者其他可能发生的成千上万的问题可能发生。问题在于,所有先前的系统都有一个发出命令的计算机,以及一定数量的读取命令并执行它们的计算机。相反,我们希望有一个可以通过消息总线进行多个参与者通信的系统——这将允许我们有任意数量且不断变化的消息创建者和消费者。

一个解决这些问题的简单方法是NSQ,一个高性能的分布式消息平台。尽管它是用 GO 语言编写的,但完全是数据格式和语言无关的。因此,有许多语言的库,并且进入 NSQ 的基本接口是一个只需能够进行 HTTP 调用的 REST API。此外,我们可以以任何格式发送消息:JSON,Pickle,msgpack等等。然而最重要的是,它提供了关于消息传递的基本保证,并且所有这些都是使用两种简单的设计模式完成的:队列和发布/订阅。

注意

我们选择 NSQ 来讨论,因为它易于使用并且通常表现良好。对于我们的目的而言,最重要的是,它清楚地突显了在考虑在集群中排队和传递消息时必须考虑的因素。然而,其他解决方案如 ZeroMQ,Amazon 的 SQS,Celery,甚至 Redis 可能更适合您的应用程序。

队列

队列 是一种消息的缓冲区。每当您想将消息发送到处理管道的另一部分时,您将其发送到队列中,并且它将在那里等待,直到有可用的工作进程。当生产和消费之间存在不平衡时,队列在分布式处理中最为有用。当出现这种不平衡时,我们可以简单地通过增加更多的数据消费者来进行水平扩展,直到消息的生产速率和消费速率相等。此外,如果负责消费消息的计算机出现故障,则消息不会丢失,而是简单地排队,直到有消费者可用,从而为我们提供消息传递的保证。

例如,假设我们希望每当用户在我们的网站上对新项目进行评分时处理新的推荐。如果没有队列,"rate"操作将直接调用"recalculate-recommendations"操作,而不管处理推荐的服务器有多忙。如果突然间有成千上万的用户决定对某物品进行评分,我们的推荐服务器可能会被请求淹没,它们可能会开始超时,丢失消息,并且通常变得无响应!

另一方面,有了队列,推荐服务器在准备好时会请求更多的任务。新的"rate"操作会将新任务放入队列中,当推荐服务器准备好执行更多工作时,它将从队列中获取任务并处理它。在这种设置中,如果比正常情况下更多的用户开始对项目进行评分,我们的队列会填满并充当推荐服务器的缓冲区——它们的工作负载不会受影响,它们仍然可以处理消息,直到队列为空。

这种方式的一个潜在问题是,如果一个队列被工作完全压倒,它将会存储大量消息。NSQ 通过具有多个存储后端来解决这个问题 —— 当消息不多时,它们存储在内存中,随着更多消息的到来,消息被放置到磁盘上。

注意

一般来说,在处理排队系统时,最好尝试使下游系统(例如上面示例中的推荐系统)在正常工作负载下处于 60%的容量。这是在为问题分配过多资源和确保服务器有足够的额外能力以处理超出正常工作量的情况之间的一个很好的折衷方案。

发布/订阅

另一方面,发布/订阅(简称发布者/订阅者)描述了谁会接收到什么消息。数据发布者可以从特定主题推送数据,数据订阅者可以订阅不同的数据源。每当发布者发布一条信息时,它被发送给所有订阅者 —— 每个订阅者都会得到原始信息的相同副本。你可以把它想象成报纸:很多人可以订阅同一份报纸,每当新版报纸出来时,每个订阅者都会得到相同的副本。此外,报纸的生产者不需要知道它的报纸被发送给了所有订阅者。因此,发布者和订阅者在系统中是解耦的,这使得我们的系统在网络变化时仍然可以保持更加健壮的生产状态。

此外,NSQ 还引入了数据消费者的概念;也就是说,可以将多个进程连接到同一个数据订阅中。每当新的数据出现时,每个订阅者都会收到数据的一份副本;然而,每个订阅的消费者只会看到这些数据中的一部分。在报纸的类比中,可以将其想象为同一户中有多个人在读同一份报纸。出版者会将一份报纸送到这户人家,因为这户只有一个订阅,那么谁先看到就可以阅读该数据。每个订阅的消费者在看到消息时会进行相同的处理;然而,它们可能位于多台计算机上,从而为整个池增加了更多的处理能力。

我们可以在图 10-1 中看到这种发布/订阅/消费者范式的描绘。如果在“clicks”主题上发布了新消息,所有订阅者(或者在 NSQ 术语中,通道 —例如,“metrics”,“spam_analysis”,和“archive”)将会收到一份副本。每个订阅者由一个或多个消费者组成,代表实际处理消息的进程。在“metrics”订阅者的情况下,只有一个消费者会看到新消息。接下来的消息将传递给另一个消费者,依此类推。

hpp2 1001

图 10-1. NSQ 的发布/订阅式拓扑结构

将消息分散到可能的大量消费者之间的好处在于自动负载均衡。如果一个消息需要很长时间来处理,那么该消费者在完成之前不会向 NSQ 发出已准备好接收更多消息的信号,因此其他消费者将获得未来大部分的消息(直到原始消费者再次准备好处理)。此外,它允许现有的消费者断开连接(无论是自愿还是由于故障),并允许新的消费者连接到集群,同时仍然在特定订阅组内保持处理能力。例如,如果我们发现“指标”需要相当长的时间来处理,并且通常无法满足需求,我们可以简单地为该订阅组的消费者池添加更多进程,从而为我们提供更多的处理能力。另一方面,如果我们看到大多数进程处于空闲状态(即,没有收到任何消息),我们可以轻松地从此订阅池中删除消费者。

还需注意的是,任何东西都可以发布数据。消费者不仅仅需要是消费者,它可以从一个主题消费数据,然后将其发布到另一个主题。实际上,这种链条在涉及分布式计算范式时是一个重要的工作流程。消费者将从一个数据主题中读取数据,以某种方式转换数据,然后将数据发布到其他消费者可以进一步转换的新主题上。通过这种方式,不同的主题代表不同的数据,订阅组代表对数据的不同转换,而消费者则是实际转换个别消息的工作者。

此外,该系统提供了令人难以置信的冗余性。每个消费者连接的可能有许多 nsqd 进程,并且可能有许多消费者连接到特定的订阅。这样,即使出现多台机器消失,也不会存在单点故障,您的系统将是健壮的。我们可以在图 10-2 中看到,即使图中的计算机之一宕机,系统仍能够交付和处理消息。此外,由于 NSQ 在关闭时将待处理消息保存到磁盘上,除非硬件丢失是灾难性的,否则您的数据很可能仍然完好无损并得到交付。最后,如果消费者在回复特定消息之前关闭,NSQ 将将该消息重新发送给另一个消费者。这意味着即使消费者被关闭,我们也知道所有主题中的所有消息至少会被响应一次。¹

hpp2 1002

图 10-2. NSQ 连接拓扑

分布式素数计算

使用 NSQ 的代码通常是异步的(详见 Chapter 8 进行完整的解释),尽管并不一定必须是。² 在下面的例子中,我们将创建一个工作池,从一个名为 numbers 的主题中读取消息,这些消息只是简单的包含数字的 JSON。消费者将读取此主题,查找这些数字是否为质数,然后根据数字是否为质数将其写入另一个主题。这将给我们带来两个新主题,primenon_prime,其他消费者可以连接到这些主题以进行更多计算。³

注意

pynsq(最后发布于 2018 年 11 月 11 日)依赖于一个非常过时的 tornado 版本(4.5.3,于 2018 年 1 月 6 日发布)。这是 Docker 的一个很好的使用案例(在 “Docker” 中讨论)。

如前所述,像这样进行 CPU 绑定工作有很多好处。首先,我们拥有所有健壮性的保证,这对这个项目可能有用,也可能没用。然而更重要的是,我们获得了自动负载平衡。这意味着,如果一个消费者得到一个需要很长时间来处理的数字,其他消费者会填补空缺。

我们创建一个消费者,通过指定主题和订阅组来创建一个 nsq.Reader 对象(如在 Example 10-9 的最后部分可以看到)。我们还必须指定运行中的 nsqd 实例的位置(或者 nsqlookupd 实例,在本节中我们不会深入讨论)。此外,我们还必须指定一个 handler,这只是一个用于处理从主题接收到的每条消息的函数。为了创建一个生产者,我们创建一个 nsq.Writer 对象,并指定一个或多个要写入的 nsqd 实例的位置。这使我们能够通过指定主题名称和消息来写入到 nsq

示例 10-9. 使用 NSQ 进行分布式质数计算
import json
from functools import partial
from math import sqrt

import nsq

def is_prime(number):
    if number % 2 == 0:
        return False
    for i in range(3, int(sqrt(number)) + 1, 2):
        if number % i == 0:
            return False
    return True

def write_message(topic, data, writer):
    response = writer.pub(topic, data)
    if isinstance(response, nsq.Error):
        print("Error with Message: {}: {}".format(data, response))
        return write_message(data, writer)
    else:
        print("Published Message: ", data)

def calculate_prime(message, writer):
    data = json.loads(message.body)

    prime = is_prime(data["number"])
    data["prime"] = prime
    if prime:
        topic = "prime"
    else:
        topic = "non_prime"

    output_message = json.dumps(data).encode("utf8")
    write_message(topic, output_message, writer)
    message.finish()  ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)

if __name__ == "__main__":
    writer = nsq.Writer(["127.0.0.1:4150"])
    handler = partial(calculate_prime, writer=writer)
    reader = nsq.Reader(
        message_handler=handler,
        nsqd_tcp_addresses=["127.0.0.1:4150"],
        topic="numbers",
        channel="worker_group_a",
    )
    nsq.run()

1

当我们处理完一条消息时,我们必须通知 NSQ。这将确保在失败时消息不会重新传递给另一个读者。

注意

我们可以通过在消息接收后在消息处理程序中启用 message.enable_async() 来异步处理消息。但是请注意,NSQ 使用较旧的回调机制与 tornado 的 IOLoop(在 tornado 中讨论)。

要设置 NSQ 生态系统,在我们的本地机器上启动一个 nsqd 实例:⁵

$ nsqd
[nsqd] 2020/01/25 13:36:39.333097 INFO: nsqd v1.2.0 (built w/go1.12.9)
[nsqd] 2020/01/25 13:36:39.333141 INFO: ID: 235
[nsqd] 2020/01/25 13:36:39.333352 INFO: NSQ: persisting topic/channel metadata
                                             to nsqd.dat
[nsqd] 2020/01/25 13:36:39.340583 INFO: TCP: listening on [::]:4150
[nsqd] 2020/01/25 13:36:39.340630 INFO: HTTP: listening on [::]:4151

现在我们可以启动尽可能多的 Python 代码实例(Example 10-9)。事实上,我们可以让这些实例在其他计算机上运行,只要 nsqd_tcp_addressnsq.Reader 实例化中的引用仍然有效。这些消费者将连接到 nsqd 并等待在 numbers 主题上发布的消息。

数据可以通过多种方式发布到numbers主题。我们将使用命令行工具来完成这个任务,因为了解如何操作系统对于正确处理它至关重要。我们可以简单地使用 HTTP 接口将消息发布到主题:

$ for i in `seq 10000`
> do
>   echo {\"number\": $i} | curl -d@- "http://127.0.0.1:4151/pub?topic=numbers"
> done

当此命令开始运行时,我们正在向numbers主题中发布包含不同数字的消息。与此同时,我们所有的生产者将开始输出状态消息,指示它们已经看到并处理了消息。此外,这些数字正在发布到primenon_prime主题中的一个。这使我们能够有其他数据消费者连接到这些主题中的任何一个来获取原始数据的过滤子集。例如,只需要质数的应用程序可以简单地连接到prime主题,并不断地获得其计算所需的新质数。我们可以使用nsqdstatsHTTP 端点来查看我们的计算状态:

$ curl "http://127.0.0.1:4151/stats"
nsqd v1.2.0 (built w/go1.12.9)
start_time 2020-01-25T14:16:35Z
uptime 26.087839544s

Health: OK

Memory:
   heap_objects                 25973
   heap_idle_bytes              61399040
   heap_in_use_bytes            4661248
   heap_released_bytes          0
   gc_pause_usec_100            43
   gc_pause_usec_99             43
   gc_pause_usec_95             43
   next_gc_bytes                4194304
   gc_total_runs                6

Topics:
   [non_prime      ] depth: 902   be-depth: 0     msgs: 902      e2e%:

   [numbers        ] depth: 0     be-depth: 0     msgs: 3009     e2e%:
      [worker_group_a           ] depth: 1926  be-depth: 0     inflt: 1
                                  def: 0    re-q: 0     timeout: 0
                                  msgs: 3009     e2e%:
        [V2 electron             ] state: 3 inflt: 1    rdy: 1    fin: 1082
                                   re-q: 0    msgs: 1083     connected: 15s

   [prime          ] depth: 180   be-depth: 0     msgs: 180      e2e%:

Producers:
   [V2 electron             ] msgs: 1082     connected: 15s
      [prime          ] msgs: 180
      [non_prime      ] msgs: 902

我们可以看到numbers主题有一个订阅组,worker_group_a,有一个消费者。此外,订阅组有一个很大的深度,有 1,926 条消息,这意味着我们正在将消息放入 NSQ 的速度比我们处理它们的速度快。这将提示我们增加更多的消费者,以便我们有更多的处理能力来处理更多的消息。此外,我们可以看到此特定消费者已连接了 15 秒,已处理了 1,083 条消息,并且当前有 1 条消息正在传输中。此状态端点为调试您的 NSQ 设置提供了相当多的信息!最后,我们看到primenon_prime主题,它们没有订阅者或消费者。这意味着消息将被存储,直到有订阅者请求数据为止。

注意

在生产系统中,您可以使用更强大的工具nsqadmin,它提供了一个具有非常详细的所有主题/订阅者和消费者概览的 Web 界面。此外,它允许您轻松地暂停和删除订阅者和主题。

要实际看到消息,我们将为prime(或non_prime)主题创建一个新的消费者,简单地将结果存档到文件或数据库中。或者,我们可以使用nsq_tail工具来窥探数据并查看其内容:

$ nsq_tail --topic prime -n 5 --nsqd-tcp-address=127.0.0.1:4150
2020/01/25 14:34:17 Adding consumer for topic: prime
2020/01/25 14:34:17 INF    1 [prime/tail574169#ephemeral] (127.0.0.1:4150)
                    connecting to nsqd
{"number": 1, "prime": true}
{"number": 3, "prime": true}
{"number": 5, "prime": true}
{"number": 7, "prime": true}
{"number": 11, "prime": true}

其他要注意的集群工具

使用队列的作业处理系统自计算机科学行业开始就存在,当时计算机速度非常慢,需要处理大量作业。因此,有许多队列库,其中许多可以在集群配置中使用。我们强烈建议您选择一个成熟的库,并有一个活跃的社区支持它,并支持您需要的相同功能集而不包含太多其他功能。

库拥有的功能越多,你就会发现越多的方法来误配置它,并浪费时间进行调试。在处理集群解决方案时,简单性 通常 是正确的目标。以下是一些常用的集群解决方案:

  • ZeroMQ 是一个低级别且高效的消息传递库,可以在节点之间发送消息。它原生支持发布/订阅范式,并且可以通过多种传输方式进行通信(TCP、UDP、WebSocket 等)。它相当底层,不提供太多有用的抽象,这可能会使其使用有些困难。尽管如此,它在 Jupyter、Auth0、Spotify 等许多地方都有使用!

  • Celery(BSD 许可证)是一个广泛使用的异步任务队列,采用分布式消息架构,用 Python 编写。它支持 Python、PyPy 和 Jython。通常情况下,它使用 RabbitMQ 作为消息代理,但也支持 Redis、MongoDB 和其他存储系统。它经常用于 Web 开发项目中。Andrew Godwin 在 “Lanyrd.com 上的任务队列 (2014)” 中讨论了 Celery。

  • AirflowLuigi 使用有向无环图将依赖任务链接成可靠运行的序列,配备监控和报告服务。它们在数据科学任务中被广泛应用于工业界,我们建议在自定义解决方案之前先进行审查。

  • [ 亚马逊简单队列服务 (SQS) 是集成到 AWS 中的作业处理系统。作业消费者和生产者可以位于 AWS 内部,也可以是外部的,因此 SQS 容易上手,并支持轻松迁移到云端。许多语言都有对应的库支持。

Docker

Docker 是 Python 生态系统中的一个重要工具。然而,它解决的问题在处理大型团队或集群时尤为重要。特别是 Docker 有助于创建可复制的环境来运行代码,在其中共享/控制运行时环境,轻松地在团队成员之间共享可运行代码,并根据资源需求将代码部署到节点集群中。

Docker 的性能

有一个关于 Docker 的常见误解,即它会大幅降低其运行应用程序的性能。虽然在某些情况下这可能是正确的,但通常并非如此。此外,大多数性能降低几乎总是可以通过一些简单的配置更改来消除。

就 CPU 和内存访问而言,Docker(以及所有其他基于容器的解决方案)不会导致任何性能降级。这是因为 Docker 简单地在主机操作系统中创建一个特殊的命名空间,代码可以在其中正常运行,尽管受到与其他运行程序不同的约束。基本上,Docker 代码以与计算机上的每个其他程序相同的方式访问 CPU 和内存;然而,它可以有一组单独的配置值来微调资源限制。⁶

这是因为 Docker 是操作系统级虚拟化的一个实例,而不是像 VMware 或 VirtualBox 这样的硬件虚拟化。在硬件虚拟化中,软件运行在“虚拟”硬件上,访问所有资源都会引入开销。另一方面,操作系统虚拟化使用本地硬件,但在“虚拟”操作系统上运行。由于 cgroups Linux 功能,这种“虚拟”操作系统可以紧密耦合到正在运行的操作系统中,这使得几乎没有开销地运行成为可能。

警告

cgroups 是 Linux 内核中的一个特定功能。因此,这里讨论的性能影响仅限于 Linux 系统。事实上,要在 macOS 或 Windows 上运行 Docker,我们首先必须在硬件虚拟化环境中运行 Linux 内核。Docker Machine 是一个帮助简化此过程的应用程序,它使用 VirtualBox 来完成这一过程。因此,在 Linux 系统上运行时,由硬件虚拟化部分引起的性能开销将大大减少。

例如,我们可以创建一个简单的 Docker 容器来运行来自示例 6-17 的二维扩散代码。作为基准,我们可以在主机系统的 Python 上运行代码以获取基准:

$ python diffusion_numpy_memory2.py
Runtime for 100 iterations with grid size (256, 256): 1.4418s

要创建我们的 Docker 容器,我们必须创建一个包含 Python 文件 diffusion_numpy_memory2.py、一个用于依赖关系的 pip 要求文件和一个 Dockerfile 的目录,如示例 10-10 所示。

示例 10-10. 简单的 Docker 容器
$ ls
diffusion_numpy_memory2.py
Dockerfile
requirements.txt

$ cat requirements.txt
numpy>=1.18.0

$ cat Dockerfile
FROM python:3.7

WORKDIR /usr/src/app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
CMD python ./diffusion_numpy_memory2.py

Dockerfile 从指定我们希望用作基础的容器开始。这些基础容器可以是各种基于 Linux 的操作系统或更高级别的服务。Python 基金会为所有主要 Python 版本提供官方容器,这使得选择要使用的 Python 版本非常简单。接下来,我们定义我们的工作目录的位置(选择 /usr/src/app 是任意的),将我们的要求文件复制到其中,并开始设置我们的环境,就像我们在本地机器上使用 RUN 命令一样。

在正常设置开发环境和在 Docker 上设置环境之间的一个主要区别是COPY命令。它们将文件从本地目录复制到容器中。例如,requirements.txt 文件被复制到容器中,以便在pip install命令时使用。最后,在Dockerfile的末尾,我们将当前目录中的所有文件复制到容器中,并告诉 Docker 在容器启动时运行python ./diffusion_numpy_memory2.py

注意

在 示例 10-10 的Dockerfile中,初学者经常会想知道为什么我们首先只复制需求文件,然后再将整个目录复制到容器中。在构建容器时,Docker 会尝试缓存构建过程的每一步。为了确定缓存是否仍然有效,检查复制来回的文件的内容。通过首先只复制需求文件,然后再移动其余目录,如果需求文件未发生变化,则只需运行一次pip install。如果仅 Python 源代码发生了更改,新构建将使用缓存的构建步骤,并直接跳过第二个COPY命令。

现在我们已经准备好构建和运行容器了,可以为其命名和打标签。容器名称通常采用*<username>*/*<project-name>*的格式,⁷而可选的标签通常是描述当前代码版本的描述性标签,或者简单地是标签latest(这是默认的,如果未指定标签将自动应用)。为了帮助版本管理,通常的约定是始终将最新构建标记为latest(当进行新构建时将被覆盖),以及一个描述性标签,以便将来可以轻松找到这个版本:

$ docker build -t high_performance/diffusion2d:numpy-memory2 \
               -t high_performance/diffusion2d:latest .
Sending build context to Docker daemon  5.632kB
Step 1/6 : FROM python:3.7
 ---> 3624d01978a1
Step 2/6 : WORKDIR /usr/src/app
 ---> Running in 04efc02f2ddf
Removing intermediate container 04efc02f2ddf
 ---> 9110a0496749
Step 3/6 : COPY requirements.txt ./
 ---> 45f9ecf91f74
Step 4/6 : RUN pip install --no-cache-dir -r requirements.txt
 ---> Running in 8505623a9fa6
Collecting numpy>=1.18.0 (from -r requirements.txt (line 1))
  Downloading https://.../numpy-1.18.0-cp37-cp37m-manylinux1_x86_64.whl (20.1MB)
Installing collected packages: numpy
Successfully installed numpy-1.18.0
You are using pip version 18.1, however version 19.3.1 is available.
You should consider upgrading via the 'pip install --upgrade pip' command.
Removing intermediate container 8505623a9fa6
 ---> 5abc2df1116f
Step 5/6 : COPY . .
 ---> 52727a6e9715
Step 6/6 : CMD python ./diffusion_numpy_memory2.py
 ---> Running in c1e885b926b3
Removing intermediate container c1e885b926b3
 ---> 892a33754f1d
Successfully built 892a33754f1d
Successfully tagged high_performance/diffusion2d:numpy-memory2
Successfully tagged high_performance/diffusion2d:latest

$ docker run high_performance/diffusion2d:numpy-memory2
Runtime for 100 iterations with grid size (256, 256): 1.4493s

我们可以看到,在任务主要依赖于 CPU/内存时,Docker 的核心并不比在主机上运行慢。然而,与任何事物一样,没有免费的午餐,有时 Docker 的性能会受到影响。尽管优化 Docker 容器的全面讨论超出了本书的范围,但在为高性能代码创建 Docker 容器时,我们提供以下考虑事项清单:

  • 当你将过多数据复制到 Docker 容器中,甚至是在 Docker 构建过程中同一个目录下的数据过多时,都要小心。如果docker build命令的第一行所标识的build context过大,性能可能会受到影响(通过 .dockerignore 文件可以解决这个问题)。

  • Docker 使用各种文件系统技巧在彼此之上层叠文件系统。这有助于构建缓存,但与主机文件系统交互可能会比较慢。当需要快速访问数据时,请使用主机级挂载,并考虑使用设置为只读的volumes,选择适合你基础设施的卷驱动程序。

  • Docker 为所有容器创建了一个虚拟网络,使大多数服务保持在网关后面,这对于隐匿大部分服务非常有用,但也增加了轻微的网络开销。对于大多数用例,这种开销可以忽略不计,但可以通过更改网络驱动程序来减轻。

  • 使用特殊的 Docker 运行时驱动程序可以访问 GPU 和其他主机级设备。例如,nvidia-docker 允许 Docker 环境轻松使用连接的 NVIDIA GPU。通常,设备可以通过 --device 运行时标志提供。

像往常一样,重要的是对你的 Docker 容器进行性能分析,以了解存在的问题及其在效率方面的简单解决方案。docker stats 命令提供了一个良好的高层视图,帮助理解容器当前的运行时性能。

Docker 的优势

到目前为止,似乎 Docker 只是在性能方面增加了一系列新的问题。然而,运行时环境的可重现性和可靠性远远超过了任何额外复杂性。

在本地,可以访问我们之前运行的所有 Docker 容器,这使我们可以快速重新运行和重新测试我们代码的先前版本,而不必担心运行时环境的更改,比如依赖项和系统包(示例 10-11 显示了我们可以使用简单的 docker_run 命令运行的容器列表)。这使得持续测试性能回归变得非常容易,否则这将是难以复现的。

示例 10-11. Docker 标签以跟踪先前的运行时环境
$ docker images -a
REPOSITORY                        TAG                 IMAGE ID
highperformance/diffusion2d       latest              ceabe8b555ab
highperformance/diffusion2d       numpy-memory2       ceabe8b555ab
highperformance/diffusion2d       numpy-memory1       66523a1a107d
highperformance/diffusion2d       python-memory       46381a8db9bd
highperformance/diffusion2d       python              4cac9773ca5e

使用容器注册表带来了许多额外好处,它允许使用简单的 docker pulldocker push 命令存储和共享 Docker 镜像,类似于 git 的方式。这使我们可以将所有容器放在公共可用的位置,允许团队成员拉取变更或新版本,并立即运行代码。

这本书是使用 Docker 容器共享的一个很好的例子,用于标准化运行时环境。为了将这本书从其编写的标记语言 asciidoc 转换为 PDF,我们之间共享了一个 Docker 容器,这样我们可以可靠且可重复地构建书籍工件。这种标准化节省了我们无数小时,在第一版中我们会遇到一个构建问题,而另一个人却无法复制或帮助调试。

运行 docker pull highperformance/diffusion2d:latest 要比克隆存储库并执行可能必需的所有相关设置容易得多。对于研究代码来说,这一点尤为真实,因为可能存在一些非常脆弱的系统依赖性。将所有内容放入一个可以轻松拉取的 Docker 容器中意味着可以跳过所有这些设置步骤,并且可以轻松运行代码。因此,代码可以更轻松地共享,编码团队可以更有效地协同工作。

最后,结合 kubernetes 和其他类似的技术,将您的代码 Docker 化有助于确保其能够使用所需的资源进行运行。Kubernetes 允许您创建一个节点集群,每个节点都标记有其可能具备的资源,并在节点上协调运行容器。它会确保正确数量的实例在运行,而由于 Docker 虚拟化的作用,代码将在您保存它时相同的环境中运行。在使用集群时,最大的问题之一是确保集群节点具有与您工作站相同的运行环境,使用 Docker 虚拟化完全解决了这个问题。⁸

总结

在本书中,我们已经了解了性能分析来理解代码中的缓慢部分,使用 numpy 进行编译并加快代码运行速度,以及多进程和多计算机的各种方法。此外,我们还调查了容器虚拟化来管理代码环境并帮助集群部署。在倒数第二章中,我们将探讨通过不同的数据结构和概率方法减少内存使用的方法。这些课程可以帮助您将所有数据保存在一台计算机上,避免运行集群的需要。

¹ 在 AWS 工作时,我们可以将我们的 nsqd 进程运行在预留实例上,而我们的消费者则在一组竞价实例上运行,这样做有很大的优势。

² 这种异步性来自于 NSQ 的发送消息给消费者的推送式协议。这使得我们的代码可以在后台进行异步读取 NSQ 连接,并在发现消息时唤醒。

³ 这种数据分析的链接被称为流水线处理,可以有效地对同一组数据执行多种类型的分析。

⁴ 您还可以通过 HTTP 调用手动发布消息;但是,这个 nsq.Writer 对象简化了大部分的错误处理。

⁵ 例如,我们可以将 NSQ 直接安装到系统上,通过将提供的二进制文件解压缩到我们的 PATH 环境变量中。或者,您可以使用 Docker,在“Docker”中讨论的方式来轻松运行最新版本。

⁶ 这种微调可以用来调整进程可以访问的内存量,或者可以使用的 CPU 核心数量,甚至可以控制 CPU 使用量的大小。

⁷ 当将构建的容器推送到存储库时,容器名称中的*username*部分非常有用。

⁸ 一个很棒的入门教程,可以在https://oreil.ly/l9jXD找到。

第十一章:使用更少的 RAM

我们很少考虑我们使用了多少 RAM,直到我们用尽它。如果在扩展代码时用尽了 RAM,它可能会成为一个突如其来的阻碍。将更多内容适应到机器的 RAM 中意味着需要管理的机器更少,而且它为你规划更大项目的容量提供了一条途径。知道 RAM 被耗尽的原因,并考虑更有效地使用这种稀缺资源的方法,将有助于你处理扩展问题。我们将使用 Memory Profiler 和 IPython 内存使用工具来测量实际的 RAM 使用量,以及一些内省对象的工具,试图猜测它们使用了多少 RAM。

节省 RAM 的另一种方法是使用利用数据特性进行压缩的容器。在这一章中,我们将研究一种 trie(有序树数据结构)和一个有向无环图(DAWG),它们可以将一个 1.2GB 的字符串集合压缩到只有 30MB,而性能几乎没有变化。第三种方法是为准确性交换存储空间。为此,我们将研究近似计数和近似集合成员资格,它们比其精确对应物使用的 RAM 少得多。

关于 RAM 使用的一个考虑是“数据具有质量”的概念。它越多,移动速度就越慢。如果你在使用 RAM 时节俭,你的数据可能会被更快地消耗掉,因为它会更快地在总线上移动,并且更多的数据将适应受限的缓存中。如果你需要将它存储在离线存储器中(例如,硬盘或远程数据集群),它将以更慢的速度传输到你的机器上。尽量选择适当的数据结构,以便所有数据都能适应一个机器。我们将使用 NumExpr 来使用比更直接的方法少得多的数据移动效率地进行 NumPy 和 Pandas 计算,这将节省我们时间,并使一些更大的计算在固定 RAM 量中变得可行。

计算 Python 对象使用的 RAM 量是令人惊讶地棘手。我们并不一定知道对象在幕后是如何表示的,如果我们向操作系统询问已使用的字节计数,它会告诉我们关于分配给进程的总量。在这两种情况下,我们都无法准确地看到每个单独的 Python 对象如何增加到总量中。

由于一些对象和库不会报告它们的完整内部分配字节数(或者它们包装的外部库根本不会报告它们的分配情况),这必须是一个最佳猜测的情况。本章探讨的方法可以帮助我们决定如何以更少的 RAM 总体上使用最佳的方式来表示我们的数据。

我们还将看几种在 scikit-learn 中存储字符串和数据结构中的计数的损失方法。这有点像 JPEG 压缩图像——我们会失去一些信息(并且无法撤消操作以恢复它),但作为结果我们获得了很大的压缩。通过对字符串使用哈希,我们在 scikit-learn 中为自然语言处理任务压缩了时间和内存使用,并且可以用很少的 RAM 计算大量事件。

原始数据类型的对象成本高昂

list 这样的容器通常用于存储数百或数千个项。一旦存储大量数据,RAM 的使用就成为一个问题。

一个包含 1 亿项的 list 大约消耗 760 MB RAM,如果这些项是相同对象。如果我们存储 1 亿个不同的项(例如唯一的整数),我们可以期望使用几 GB 的 RAM!每个唯一对象都有内存成本。

在 示例 11-1 中,我们将许多 0 整数存储在一个 list 中。如果您将 1 亿个对象的引用存储在列表中(无论这个对象的一个实例有多大),您仍然期望看到大约 760 MB 的内存成本,因为 list 存储的是对对象的引用(而不是副本)。参考 “使用 memory_profiler 诊断内存使用情况” 来了解如何使用 memory_profiler;在这里,我们通过 %load_ext memory_profiler 将其加载为 IPython 中的新魔术函数。

示例 11-1. 测量列表中 1 亿个相同整数的内存使用情况
In [1]: %load_ext memory_profiler  # load the %memit magic function
In [2]: %memit [0] * int(1e8)
peak memory: 806.33 MiB, increment: 762.77 MiB

对于我们的下一个示例,我们将从一个新的 shell 开始。正如在 示例 11-2 中第一次调用 memit 的结果显示的那样,一个新的 IPython shell 大约消耗 40 MB RAM。接下来,我们可以创建一个临时的包含 1 亿个唯一数字的列表。总共,这大约消耗了 3.8 GB。

警告

内存可以在运行进程中缓存,因此在使用 memit 进行分析时,退出并重新启动 Python shell 总是更安全的选择。

memit 命令完成后,临时列表被释放。最后一次调用 memit 显示内存使用量降至之前的水平。

示例 11-2. 测量列表中 1 亿个不同整数的内存使用情况
# we use a new IPython shell so we have a clean memory
In [1]: %load_ext memory_profiler
In [2]: %memit # show how much RAM this process is consuming right now
peak memory: 43.39 MiB, increment: 0.11 MiB
In [3]: %memit [n for n in range(int(1e8))]
peak memory: 3850.29 MiB, increment: 3806.59 MiB
In [4]: %memit
peak memory: 44.79 MiB, increment: 0.00 MiB

后续在 示例 11-3 中执行 memit 来创建第二个 1 亿项目列表,内存消耗约为 3.8 GB。

示例 11-3. 再次测量列表中 1 亿个不同整数的内存使用情况
In [5]: %memit [n for n in range(int(1e8))]
peak memory: 3855.78 MiB, increment: 3810.96 MiB

接下来,我们将看到可以使用 array 模块更便宜地存储 1 亿个整数。

array 模块以便宜的方式存储许多原始对象

array 模块高效地存储诸如整数、浮点数和字符等基本类型,但包括复数或类。它创建一个连续的 RAM 块来保存底层数据。

在示例 11-4 中,我们分配了 1 亿个整数(每个 8 字节)到一个连续的内存块中。总计,该过程消耗约 760 MB。这种方法与之前的唯一整数列表方法之间的差异为3100MB - 760MB == 2.3GB。这在 RAM 上是巨大的节省。

示例 11-4. 使用 760 MB RAM 构建一个包含 1 亿个整数的数组
In [1]: %load_ext memory_profiler
In [2]: import array
In [3]: %memit array.array('l', range(int(1e8)))
peak memory: 837.88 MiB, increment: 761.39 MiB
In [4]: arr = array.array('l')
In [5]: arr.itemsize
Out[5]: 8

注意array中的唯一数字不是 Python 对象;它们是array中的字节。如果我们对它们进行解引用,将构造一个新的 Python int对象。如果你要对它们进行计算,则不会有整体节省,但如果你要将数组传递给外部进程或仅使用部分数据,与使用整数列表相比,RAM 的节省将是显著的。

注意

如果你正在使用 Cython 处理大型数组或矩阵,并且不希望依赖于numpy,请注意,你可以将数据存储在一个array中,并将其传递给 Cython 进行处理,而无需额外的内存开销。

array模块与有限的数据类型一起工作,具有不同的精度(参见示例 11-5)。选择你所需的最小精度,这样你只分配所需的 RAM,而不是更多。请注意,字节大小是依赖于平台的——这里的大小是针对 32 位平台的(它指定了最小大小),而我们在 64 位笔记本电脑上运行示例。

示例 11-5. array模块提供的基本类型
In [5]: array.array? # IPython magic, similar to help(array)
Init signature: array.array(self, /, *args, **kwargs)
Docstring:
array(typecode [, initializer]) -> array

Return a new array whose items are restricted by typecode, and
initialized from the optional initializer value, which must be a list,
string, or iterable over elements of the appropriate type.

Arrays represent basic values and behave very much like lists, except
the type of objects stored in them is constrained. The type is specified
at object creation time by using a type code, which is a single character.
The following type codes are defined:

    Type code   C Type             Minimum size in bytes
    'b'         signed integer     1
    'B'         unsigned integer   1
    'u'         Unicode character  2 (see note)
    'h'         signed integer     2
    'H'         unsigned integer   2
    'i'         signed integer     2
    'I'         unsigned integer   2
    'l'         signed integer     4
    'L'         unsigned integer   4
    'q'         signed integer     8 (see note)
    'Q'         unsigned integer   8 (see note)
    'f'         floating point     4
    'd'         floating point     8

NumPy 拥有可以容纳更广泛数据类型的数组——你可以更好地控制每个项的字节数,可以使用复数和datetime对象。complex128对象每个项占用 16 字节:每个项是一对 8 字节浮点数。你无法在 Python 数组中存储complex对象,但在numpy中可以免费使用它们。如果你需要重新熟悉numpy,请回顾第六章。

在示例 11-6 中,你可以看到numpy数组的另一个特性;你可以查询项目数、每个原始的大小以及底层 RAM 块的总存储。请注意,这不包括 Python 对象的开销(通常来说,与存储在数组中的数据相比,这是微不足道的)。

提示

要小心使用零进行惰性分配。在以下示例中,对zeros的调用“零”成本的 RAM,而对ones的调用成本为 1.5 GB。这两个调用最终都会消耗 1.5 GB,但是对zeros的调用仅在使用后才分配 RAM,因此成本稍后才会显现。

示例 11-6. 在numpy数组中存储更复杂的类型
In [1]: %load_ext memory_profiler
In [2]: import numpy as np
# NOTE that zeros have lazy allocation so misreport the memory used!
In [3]: %memit arr=np.zeros(int(1e8), np.complex128)
peak memory: 58.37 MiB, increment: 0.00 MiB
In [4]: %memit arr=np.ones(int(1e8), np.complex128)
peak memory: 1584.41 MiB, increment: 1525.89 MiB
In [5]: f"{arr.size:,}"
Out[5]: '100,000,000'
In [6]: f"{arr.nbytes:,}"
Out[6]: '1,600,000,000'
In [7]: arr.nbytes/arr.size
Out[7]: 16.0
In [8]: arr.itemsize
Out[8]: 16

使用普通的list来存储许多数字在 RAM 中比使用array对象要低效得多。需要进行更多的内存分配,每次分配都需要时间;还会对较大的对象进行计算,这些对象将不太适合缓存,并且总体上使用的 RAM 更多,因此其他程序可用的 RAM 更少。

然而,如果你在 Python 中对array的内容进行任何操作,原语很可能会被转换为临时对象,从而抵消其效益。在与其他进程通信时,将它们用作数据存储的一个很好的用例是array

如果你在做大量数值计算,那么numpy数组几乎肯定是更好的选择,因为你可以获得更多的数据类型选项和许多专门的快速函数。如果你希望项目依赖较少,可能会选择避免使用numpy,尽管 Cython 与arraynumpy数组同样有效;Numba 仅与numpy数组配合使用。

Python 还提供了一些其他工具来理解内存使用,我们将在以下部分中看到。

使用 NumPy 和 NumExpr 节省 RAM

在 NumPy 中(这也会在 Pandas 背后发生)的大型向量化表达式可能会在复杂操作期间创建中间大型数组。这些操作通常是看不见的,只有在发生内存不足错误时才会引起注意。这些计算也可能很慢,因为大型向量不会很好地利用缓存——缓存可能是兆字节或更小,而数百兆字节或几十亿字节的大型向量数据将阻止缓存的有效使用。NumExpr 是一个既加速又减少中间操作大小的工具;我们在“numexpr:使原地操作更快更容易”中介绍了它。

我们之前在“使用 memory_profiler 诊断内存使用”中介绍过 Memory Profiler。在这里,我们通过IPython 内存使用工具进一步扩展,它会报告 IPython shell 或 Jupyter Notebook 中逐行的内存变化。让我们看看如何使用这些工具来检查 NumExpr 是否更有效地生成结果。

提示

使用 Pandas 时,请记得安装可选的 NumExpr。如果在 Pandas 中安装了 NumExpr,调用eval会更快,但请注意,Pandas 不会告诉您如果安装 NumExpr。

我们将使用交叉熵公式来计算机器学习分类挑战的误差。交叉熵(或对数损失)是分类挑战的常见度量标准;它对大误差的惩罚要比小误差显著。在训练和预测阶段,需要为机器学习问题中的每一行打分:

- l o g P ( y t | y p ) = - ( y t l o g ( y p ) + ( 1 - y t ) l o g ( 1 - y p ) )

我们将在这里使用范围为[0, 1]的随机数来模拟类似于 scikit-learn 或 TensorFlow 等包的机器学习系统的结果。图 11-1 展示了右侧范围为[0, 1]的自然对数,左侧展示了计算交叉熵的结果,当目标值为 0 或 1 时。

如果目标yt为 1,则公式的前半部分生效,而后半部分为零。如果目标为 0,则公式的后半部分生效,而第一部分为零。这个结果是针对需要评分的每一行数据计算的,并且通常需要进行多次机器学习算法的迭代。

hpp2 1101

图 11-1. yt(“真实值”)的交叉熵,其取值为 0 和 1

在示例 11-7 中,我们生成了 2 亿个位于[0, 1]范围内的随机数作为ypyt是期望的真值 —— 在这种情况下是由 1 组成的数组。在实际应用中,yp由机器学习算法生成,而yt则是由机器学习研究人员提供的混合了 0 和 1 的目标真值。

示例 11-7. 大型 NumPy 数组临时变量的隐藏成本
In [1]: import ipython_memory_usage.ipython_memory_usage as imu; import numpy as np
In [2]: %ipython_memory_usage_start
Out[2]: 'memory profile enabled'
In [3]: nbr_items = 200_000_000
In [4]: yp = np.random.uniform(low=0.0000001, size=nbr_items)
In [4] used 1526.0508 MiB RAM in 2.18s, peaked 0.00 MiB above current,
       total RAM usage 1610.05 MiB
In [5]: yt = np.ones(shape=nbr_items)
In [5] used 1525.8516 MiB RAM in 0.44s, peaked 0.00 MiB above current,
       total RAM usage 3135.90 MiB

In [6]: answer = -(yt * np.log(yp) + ((1-yt) * (np.log(1-yp))))
In [6] used 1525.8594 MiB RAM in 18.63s, peaked 4565.70 MiB above current,
       total RAM usage 4661.76 MiB

In [7]: del answer
In [7] used -1525.8242 MiB RAM in 0.11s, peaked 0.00 MiB above current,
       total RAM usage 3135.93 MiB

ypyt都各自占用了 1.5 GB 内存,使得总内存使用量略高于 3.1 GB。answer向量与输入具有相同的维度,因此额外增加了 1.5 GB。请注意,计算在当前 RAM 使用量上峰值达到了 4.5 GB,因此虽然我们最终得到了 4.6 GB 的结果,但在计算过程中分配了超过 9 GB 的内存。交叉熵计算创建了几个临时变量(特别是1 – ytnp.log(1 – yp)及其乘积)。如果你使用的是 8 GB 的机器,由于内存耗尽的原因,可能无法计算出这个结果。

在示例 11-8,我们看到相同的表达式作为字符串放置在numexpr.evaluate内部。它在当前使用量之上峰值为 0 GB —— 在这种情况下不需要任何额外的 RAM。值得注意的是,它还计算得更快:之前直接向量计算在In[6]中花费了 18 秒,而在这里使用 NumExpr 进行相同计算只需要 2.6 秒。

NumExpr 将长向量分解为更短、友好缓存的块,并依次处理每个块,因此可以以友好缓存的方式计算本地块的结果。这解释了不需要额外 RAM 的需求以及增加的速度。

示例 11-8. NumExpr 将向量化计算分解为高效利用缓存的块
In [8]: import numexpr
In [8] used 0.0430 MiB RAM in 0.12s, peaked 0.00 MiB above current,
       total RAM usage 3135.95 MiB
In [9]: answer = numexpr.evaluate("-(yt * log(yp) + ((1-yt) * (log(1-yp))))")
In [9] used 1525.8281 MiB RAM in 2.67s, peaked 0.00 MiB above current,
       total RAM usage 4661.78 MiB

我们可以在 Pandas 中看到类似的好处,示例 11-9 中。我们构造一个与前面示例相同的 DataFrame,并使用 df.eval 调用 NumExpr。Pandas 机制必须为 NumExpr 解包 DataFrame,并且总体使用更多 RAM;在幕后,NumExpr 仍以一种缓存友好的方式计算结果。请注意,在这里,NumExpr 除了 Pandas 外还安装了。

示例 11-9. Pandas 的 eval 如果可用将使用 NumExpr
In [2] df = pd.DataFrame({'yp': np.random.uniform(low=0.0000001, size=nbr_items),
       'yt': np.ones(nbr_items)})
In [3]: answer_eval = df.eval("-(yt * log(yp) + ((1-yt) * (log(1-yp))))")
In [3] used 3052.1953 MiB RAM in 5.26s, peaked 3045.77 MiB above current,
       total RAM usage 6185.45 MiB

与前面的示例对比,示例 11-10 中没有安装 NumExpr。调用 df.eval 回退到 Python 解释器——结果相同,但执行时间为 34 秒(之前为 5.2 秒),内存使用峰值更高。您可以使用 import numexpr 测试是否安装了 NumExpr——如果失败,您将需要安装它。

示例 11-10. 注意,没有安装 NumExpr 的 Pandas 将对 eval 进行缓慢和昂贵的调用!
In [2] df = pd.DataFrame({'yp': np.random.uniform(low=0.0000001, size=nbr_items),
       'yt': np.ones(nbr_items)})
In [3]: answer_eval = df.eval("-(yt * log(yp) + ((1-yt) * (log(1-yp))))")
In [3] used 3052.5625 MiB RAM in 34.88s, peaked 7620.15 MiB above current,
       total RAM usage 6185.24 MiB

在大数组上进行复杂的向量操作将更快地运行,如果可以使用 NumExpr 的话。Pandas 不会警告您未安装 NumExpr,因此建议在设置中添加它,如果您使用 eval。IPython Memory Usage 工具将帮助您诊断您的大数组消耗了多少 RAM;这可以帮助您在当前机器上更多地放入 RAM,以免您不得不开始分割数据并引入更大的工程工作。

理解集合中使用的 RAM

你可能想知道是否可以询问 Python 每个对象使用的 RAM。Python 的 sys.getsizeof(obj) 调用将告诉我们关于对象内存使用的一些信息(大多数但不是所有对象都提供此信息)。如果你以前没见过它,要注意它不会为容器给出你期望的答案!

让我们首先看一些基本类型。在 Python 中,int 是一个大小可变的对象,大小任意,当用 0 初始化时,基本对象在 Python 3.7 中的成本是 24 字节。随着数值变大,会添加更多字节:

In [1]: sys.getsizeof(0)
Out[1]: 24
In [2]: sys.getsizeof(1)
Out[2]: 28
In [3]: sys.getsizeof((2**30)-1)
Out[3]: 28
In [4]: sys.getsizeof((2**30))
Out[4]: 32

在幕后,每次数到前一个限制之上的数字大小时,会增加 4 字节的集合。这仅影响内存使用;外部看不到任何差异。

我们可以对字节字符串执行相同的检查。空字节序列占用 33 字节,每个额外字符增加 1 字节的成本:

In [5]: sys.getsizeof(b"")
Out[5]: 33
In [6]: sys.getsizeof(b"a")
Out[6]: 34
In [7]: sys.getsizeof(b"ab")
Out[7]: 35
In [8]: sys.getsizeof(b"abc")
Out[8]: 36

当我们使用列表时,我们会看到不同的行为。getsizeof 并不计算列表内容的成本,只计算列表本身的成本。空列表占用 64 字节,在 64 位笔记本上,列表中的每个项目另外占用 8 字节:

# goes up in 8-byte steps rather than the 24+ we might expect!
In [9]: sys.getsizeof([])
Out[9]: 64
In [10]: sys.getsizeof([1])
Out[10]: 72
In [11]: sys.getsizeof([1, 2])
Out[11]: 80

如果我们使用字节字符串,我们会看到比 getsizeof 报告的成本要大得多:

In [12]: sys.getsizeof([b""])
Out[12]: 72
In [13]: sys.getsizeof([b"abcdefghijklm"])
Out[13]: 72
In [14]: sys.getsizeof([b"a", b"b"])
Out[14]: 80

getsizeof仅报告了部分成本,通常只报告父对象的成本。正如前面所述,它也并非总是实现的,因此其实用性有限。

pympler中,更好的工具是asizeof。它将遍历容器的层次结构,并对找到的每个对象的大小进行最佳猜测,将大小添加到总数中。请注意,它运行速度较慢。

除了依赖猜测和假设外,asizeof也无法计算幕后分配的内存(例如,包装 C 库的模块可能不会报告 C 库中分配的字节)。最好将其用作指南。我们更喜欢使用memit,因为它能准确地计算所涉及机器的内存使用量。

我们可以检查它对大列表的估计——这里我们将使用 1000 万个整数:

In [1]: from pympler.asizeof import asizeof
In [2]: asizeof([x for x in range(int(1e7))])
Out[2]: 401528048
In [3]: %memit [x for x in range(int(1e7))]
peak memory: 401.91 MiB, increment: 326.77 MiB

我们可以通过使用memit来验证这一估算,看看这个过程是如何增长的。两个报告都是大致的——memit在执行语句时获取操作系统报告的 RAM 使用快照,而asizeof则询问对象的大小(可能报告不准确)。我们可以得出结论,一个包含 1000 万个整数的列表大约需要 320 到 400 MB 的 RAM。

通常情况下,asizeof过程比使用memit慢,但在分析小对象时,asizeof可能很有用。对于实际应用程序而言,memit可能更有用,因为它测量了进程的实际内存使用情况,而不是推测。

字节与 Unicode

Python 3.x 相对于 Python 2.x 的(许多!)优势之一是默认使用 Unicode。以前,我们有单字节字符串和多字节 Unicode 对象混合,这在数据导入和导出过程中可能会带来麻烦。在 Python 3.x中,所有字符串默认都是 Unicode,如果需要操作字节,则必须显式创建字节序列。

Python 3.7 中的 Unicode 对象比 Python 2.x中的 RAM 使用效率更高。在 Example 11-11 中,我们可以看到一个包含 1 亿字符序列,一部分作为字节集合,一部分作为 Unicode 对象。对于常见字符,Unicode 变体(假设系统的默认编码是 UTF 8)成本相同——这些常见字符使用单字节实现。

示例 11-11。在 Python 3.x 中,Unicode 对象可以和字节一样便宜。
In [1]: %load_ext memory_profiler
In [2]: type(b"b")
Out[2]: bytes
In [3]: %memit b"a" * int(1e8)
peak memory: 121.55 MiB, increment: 78.17 MiB
In [4]: type("u")
Out[4]: str
In [5]: %memit "u" * int(1e8)
peak memory: 122.43 MiB, increment: 78.49 MiB
In [6]: %memit "Σ" * int(1e8)
peak memory: 316.40 MiB, increment: 176.17 MiB

Sigma 字符(Σ)更昂贵——在 UTF 8 中表示为 2 字节。我们通过PEP 393从 Python 3.3 中获得了灵活的 Unicode 表示。它通过观察字符串中字符的范围,并在可能的情况下使用更少的字节来表示低序字符来工作。

Unicode 对象的 UTF-8 编码每个 ASCII 字符使用 1 个字节,对于不经常见到的字符使用更多字节。如果您对 Unicode 编码与 Unicode 对象不确定,请观看Net Batchelder 的“Pragmatic Unicode, or, How Do I Stop The Pain?”

高效地在 RAM 中存储大量文本

文本的一个常见问题是它占用大量的 RAM——但如果我们想要测试我们以前是否看到过字符串或计算它们的频率,将它们存储在 RAM 中比从磁盘中分页更方便。简单地存储字符串是昂贵的,但 tries 和有向无环单词图(DAWG)可以用于压缩它们的表示,并仍然允许快速操作。

这些更先进的算法可以节省大量 RAM,这意味着您可能不需要扩展到更多的服务器。对于生产系统来说,这样的节省是巨大的。在本节中,我们将探讨如何使用 trie 将占用 1.2 GB 的字符串set压缩到 30 MB,性能几乎没有变化。

例如,我们将使用从维基百科的部分转储构建的文本集。这个集合包含了来自英文维基百科的 1100 万个唯一令牌,占据了磁盘上的 120 MB。

令牌按照它们原始文章中的空白分隔开来;它们长度不固定,包含 Unicode 字符和数字。它们看起来像这样:

faddishness
'melanesians'
Kharálampos
PizzaInACup™
url="http://en.wikipedia.org/wiki?curid=363886"
VIIIa),
Superbagnères.

我们将使用这个文本样本来测试我们可以多快地构建一个包含每个唯一单词实例的数据结构,然后我们将看看我们可以多快地查询一个已知的单词(我们将使用不常见的“Zwiebel”,出自画家 Alfred Zwiebel)。这让我们可以问:“我们以前见过 Zwiebel 吗?”令牌查找是一个常见的问题,能够快速地执行这些操作非常重要。

注意

当您在自己的问题上尝试这些容器时,请注意您可能会看到不同的行为。每个容器以不同的方式构建其内部结构;传递不同类型的令牌可能会影响结构的构建时间,而令牌的不同长度会影响查询时间。请始终以一种系统的方式进行测试。

在 1100 万个令牌上尝试这些方法

Figure 11-2 展示了存储在一些容器中的 1100 万个令牌文本文件(120 MB 原始数据)。x 轴显示了每个容器的 RAM 使用情况,y 轴跟踪查询时间,并且每个点的大小与构建结构所花费的时间成比例(更大表示花费的时间更长)。

如图所示,setlist示例使用了大量 RAM;list示例既大又!对于这个数据集来说,Marisa trie 示例在内存使用效率上是最高的,而 DAWG 运行速度是 RAM 使用量相对较小的两倍。

DAWG 和 tries 与 1100 万令牌的内置容器的比较

图 11-2. DAWG 和 tries 与内置容器的比较

图中没有显示使用未排序方法的列表的查找时间,我们将很快介绍,因为它太耗时了。请注意,您必须使用各种容器测试您的问题——每种容器都提供不同的权衡,如构建时间和 API 灵活性。

接下来,我们将建立一个过程来测试每个容器的行为。

列表

让我们从最简单的方法开始。我们将我们的标记加载到一个列表中,然后使用O(n)线性搜索进行查询。你不能在我们已经提到的大例子上这样做——搜索时间太长了——所以我们将用一个小得多的例子(50 万标记)来演示这种技术。

在以下每个示例中,我们使用一个生成器text_example.readers,它从输入文件中一次提取一个 Unicode 标记。这意味着读取过程只使用了极少量的 RAM:

print("RAM at start {:0.1f}MiB".format(memory_profiler.memory_usage()[0]))
t1 = time.time()
words = [w for w in text_example.readers]
print("Loading {} words".format(len(words)))
t2 = time.time()
print("RAM after creating list {:0.1f}MiB, took {:0.1f}s" \
      .format(memory_profiler.memory_usage()[0], t2 - t1))

我们对能够多快地查询这个列表很感兴趣。理想情况下,我们希望找到一个可以存储我们的文本并允许我们进行查询和修改而没有惩罚的容器。为了查询它,我们使用timeit多次查找一个已知的单词:

assert 'Zwiebel' in words
time_cost = sum(timeit.repeat(stmt="'Zwiebel' in words",
                              setup="from __main__ import words",
                              number=1,
                              repeat=10000))
print("Summed time to look up word {:0.4f}s".format(time_cost))

我们的测试脚本报告显示,将原始的 5 MB 文件存储为一个列表大约使用了 34 MB,聚合查找时间为 53 秒:

$ python text_example_list.py
RAM at start 36.6MiB
Loading 499056 words
RAM after creating list 70.9MiB, took 1.0s
Summed time to look up word 53.5657s

显然,将文本存储在未排序的列表中是一个糟糕的主意;O(n)的查找时间代价高昂,内存使用也是如此。这是所有世界中最糟糕的情况!如果我们在以下更大的数据集上尝试这种方法,我们期望的聚合查找时间将是 25 分钟,而不是我们讨论的方法的一小部分秒数。

我们可以通过对列表进行排序并使用bisect模块进行二分查找来改善查找时间;这为未来的查询提供了一个合理的下限。在 示例 11-12 中,我们测量了对列表进行排序所需的时间。在这里,我们转向更大的 1100 万标记集。

示例 11-12. 对排序操作计时,为使用bisect做准备
    print("RAM at start {:0.1f}MiB".format(memory_profiler.memory_usage()[0]))
    t1 = time.time()
    words = [w for w in text_example.readers]
    print("Loading {} words".format(len(words)))
    t2 = time.time()
    print("RAM after creating list {:0.1f}MiB, took {:0.1f}s" \
          .format(memory_profiler.memory_usage()[0], t2 - t1))
    print("The list contains {} words".format(len(words)))
    words.sort()
    t3 = time.time()
    print("Sorting list took {:0.1f}s".format(t3 - t2))

接下来,我们进行与之前相同的查找,但增加了使用bisectindex方法:

import bisect
...
def index(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
...
    time_cost = sum(timeit.repeat(stmt="index(words, 'Zwiebel')",
                                  setup="from __main__ import words, index",
                                  number=1,
                                  repeat=10000))

在 示例 11-13 中,我们看到 RAM 使用量比以前大得多,因为我们加载了更多的数据。排序需要额外的 0.6 秒,累积查找时间为 0.01 秒。

示例 11-13. 在排序列表上使用bisect的时间
$ python text_example_list_bisect.py 
RAM at start 36.6MiB
Loading 11595290 words
RAM after creating list 871.9MiB, took 20.6s
The list contains 11595290 words
Sorting list took 0.6s
Summed time to look up word 0.0109s

现在,我们对字符串查找定时有了一个合理的基线:RAM 使用必须优于 871 MB,并且总查找时间应优于 0.01 秒。

集合

使用内置的set似乎是解决我们任务的最明显方式。在示例 11-14 中,set使用哈希结构存储每个字符串(如果需要复习,请参阅第四章)。检查成员资格很快,但每个字符串必须单独存储,这在 RAM 上很昂贵。

示例 11-14. 使用set存储数据
    words_set = set(text_example.readers)

如我们在示例 11-15 中所见,setlist使用更多的 RAM,额外多了 250 MB;然而,它提供了非常快的查找时间,而无需额外的index函数或中间排序操作。

示例 11-15. 运行set示例
$ python text_example_set.py
RAM at start 36.6MiB
RAM after creating set 1295.3MiB, took 24.0s
The set contains 11595290 words
Summed time to look up word 0.0023s

如果 RAM 不是问题,这可能是最明智的第一种方法。

现在我们已经丢失了原始数据的顺序。如果这对您很重要,请注意,您可以将字符串存储为字典中的键,每个值都是与原始读取顺序相关联的索引。这样,您可以询问字典是否存在该键及其索引。

更有效的树结构

让我们介绍一组算法,它们更有效地使用 RAM 来表示我们的字符串。

图 11-3 来自维基共享资源,展示了“tap”、“taps”、“top”和“tops”四个单词在 trie 和 DAWG 之间的表示差异。¹使用listset,每个单词都将存储为单独的字符串。DAWG 和 trie 都共享字符串的部分,因此使用的 RAM 更少。

这些之间的主要区别在于 trie 仅共享公共前缀,而 DAWG 共享公共前缀和后缀。在具有许多常见单词前缀和后缀的语言(如英语)中,这可以节省大量重复。

精确的内存行为将取决于您数据的结构。通常,由于字符串从开头到结尾有多条路径,DAWG 不能为键分配值,但这里显示的版本可以接受值映射。Trie 也可以接受值映射。某些结构必须在开始时进行构建,而其他结构可以随时更新。

这些结构的一个重要优势是它们提供了公共前缀搜索;也就是说,您可以请求所有具有您提供的前缀的单词。对于我们的四个单词列表,搜索“ta”将得到“tap”和“taps”的结果。此外,由于这些是通过图结构发现的,检索这些结果非常快速。例如,如果您处理的是 DNA,使用 trie 来压缩数百万个短字符串可以有效减少 RAM 使用量。

DAWG 和 Trie 数据结构

图 11-3. Trie 和 DAWG 结构(图片由Chkno [CC BY-SA 3.0]提供)

在接下来的章节中,我们将更详细地了解 DAWG、trie 及其用法。

有向无环字图

有向无环字图(MIT 许可证)尝试高效表示共享公共前缀和后缀的字符串。

请注意,在撰写本文时,GitHub 上的一个开放的 Pull Request需要应用才能使此 DAWG 与 Python 3.7 配合工作。

在示例 11-16 中,您可以看到一个非常简单的 DAWG 设置。对于此实现,构建后的 DAWG 无法修改;它读取一个迭代器来构建自身一次。缺乏构建后更新可能会成为您用例的破坏者。如果是这样,您可能需要考虑改用 trie。DAWG 支持丰富的查询,包括前缀查找;它还允许持久性,并支持存储整数索引作为值以及字节和记录值。

示例 11-16. 使用 DAWG 存储数据
import dawg
...
    words_dawg = dawg.DAWG(text_example.readers)

正如您在示例 11-17 中所见,对于相同的字符串集合,在构建阶段使用的 RAM 比之前的set示例少得多。更相似的输入文本将导致更强的压缩。

示例 11-17. 运行 DAWG 示例
$ python text_example_dawg.py
RAM at start 38.1MiB
RAM after creating dawg 200.8MiB, took 31.6s
Summed time to look up word 0.0044s

更重要的是,如果我们将 DAWG 持久化到磁盘,如示例 11-18 所示,然后将其加载回到一个新的 Python 实例中,我们将看到 RAM 使用量显著减少——磁盘文件和加载后的内存使用量都是 70 MB;与我们之前构建的 1.2 GB set 变体相比,这是显著的节省!

示例 11-18. 加载之前构建并保存的 DAWG 更节省内存
$ python text_example_dawg_load_only.py
RAM at start 38.4MiB
RAM after load 109.0MiB
Summed time to look up word 0.0051s

鉴于您通常只需创建一次 DAWG,然后多次加载,您在将结构持久化到磁盘后将受益于重复的构建成本。

Marisa trie

Marisa trie(双许可 LGPL 和 BSD)是一个静态的trie,使用 Cython 绑定到外部库。因为它是静态的,所以在构建后无法修改。与 DAWG 类似,它支持将整数索引作为值存储,以及字节值和记录值。

一个关键字可以用来查找一个值,反之亦然。可以高效地找到所有共享相同前缀的键。trie 的内容可以持久化。示例 11-19 说明了使用 Marisa trie 存储我们的示例数据。

示例 11-19. 使用 Marisa trie 存储数据
import marisa_trie
...
    words_trie = marisa_trie.Trie(text_example.readers)

在示例 11-20 中,我们可以看到查找时间比 DAWG 提供的要慢。

示例 11-20. 运行 Marisa trie 示例
$ python text_example_trie.py
RAM at start 38.3MiB
RAM after creating trie 419.9MiB, took 35.1s
The trie contains 11595290 words
Summed time to look up word 0.0148s

字典树在这个数据集上进一步节省了内存。虽然查找速度稍慢(在示例 11-21 中),但是在下面的代码片段中,如果我们将字典树保存到磁盘,然后重新加载到一个新进程中,磁盘和 RAM 使用量大约为 30 MB;这比 DAWG 实现的效果好一倍。

示例 11-21. 加载先前会话中构建和保存的字典树更节省 RAM
$ python text_example_trie_load_only.py
RAM at start 38.5MiB
RAM after loading trie from disk 76.7MiB, took 0.0s
The trie contains 11595290 words
Summed time to look up word 0.0092s

在构建后的存储大小和查找时间之间的权衡需要针对您的应用程序进行调查。您可能会发现其中一种“效果足够好”,因此可以避免基准测试其他选项,而是直接继续下一个挑战。在这种情况下,我们建议 Marisa 字典树是您的首选;它在 GitHub 上的星数比 DAWG 多。

在生产系统中使用字典树(和 DAWG)

字典树和 DAWG 数据结构提供了很多好处,但在采用它们之前,你仍然需要根据自己的问题进行基准测试,而不是盲目地采用。

字典树(trie)和 DAWG 虽然不那么出名,但它们在生产系统中能提供显著的好处。我们在“Smesh 的大规模社交媒体分析(2014 年)”中有一个令人印象深刻的成功案例。Jamie Matthews 在 DabApps(一家位于英国的 Python 软件公司)也有一个关于在客户系统中使用字典树来实现更高效、更便宜部署的案例:

在 DabApps,我们经常试图通过将复杂的技术架构问题分解为小型、自包含的组件来解决,通常使用 HTTP 在网络上进行通信。这种方法(称为面向服务微服务架构)有各种好处,包括可以在多个项目之间重用或共享单个组件的功能。

我们在面向消费者的客户项目中经常需要处理的任务之一是邮政编码地理编码。这个任务是将完整的英国邮政编码(例如:BN1 1AG)转换为经纬度坐标对,以便应用程序执行地理空间计算,比如距离测量。

在其最基本的形式下,地理编码数据库是字符串之间的简单映射,概念上可以表示为字典。字典的键是邮政编码,以规范化形式存储(例如 BN11AG),值是坐标的表示(我们使用了地理哈希编码,但为简单起见,可以想象为逗号分隔的对,如 50.822921,-0.142871)。

英国大约有 170 万个邮政编码。如前所述,将整个数据集朴素地加载到 Python 字典中需要几百兆字节的内存。使用 Python 的本机 Pickle 格式将这些数据结构持久化到磁盘需要大量的存储空间,这是不可接受的。我们知道我们可以做得更好。

我们尝试了几种不同的内存和磁盘存储及序列化格式,包括将数据存储在外部数据库如 Redis 和 LevelDB 中,并压缩键/值对。最终,我们想到了使用 trie 树的想法。Trie 树在内存中表示大量字符串非常高效,并且可用的开源库(我们选择了“marisa-trie”)使它们非常简单易用。

结果应用程序,包括使用 Flask 框架构建的微小 Web API,仅使用 30MB 内存来表示整个英国邮政编码数据库,并且可以轻松处理大量的邮政编码查询请求。代码简洁;服务非常轻量级且无痛地可以在免费托管平台如 Heroku 上部署和运行,不依赖外部数据库或其他依赖。我们的实现是开源的,可在https://github.com/j4mie/postcodeserver获取。

Jamie Matthews,DabApps.com(英国)的技术总监

DAWG 和 trie 是强大的数据结构,可以帮助您节省 RAM 和时间,但需要在准备阶段额外付出一些努力。这些数据结构对许多开发者来说可能不太熟悉,因此考虑将此代码分离到一个与其余代码相对隔离的模块中,以简化维护。

使用 Scikit-Learn 的 FeatureHasher 进行更多文本建模

Scikit-learn 是 Python 最知名的机器学习框架,对基于文本的自然语言处理(NLP)挑战有着出色的支持。在这里,我们将会看到如何将来自 Usenet 档案的公共帖子分类到 20 个预定类别中;这与清理电子邮件收件箱中的二类垃圾分类过程类似。

文本处理的一个困难在于分析中的词汇量迅速爆炸。英语使用许多名词(例如人名、地名、医学术语和宗教术语)和动词(“-ing”结尾的“doing words”,例如“running,” “taking,” “making,” 和 “talking”),以及它们的各种变形(将动词“talk”转变为“talked,” “talking,” “talks”)。此外,标点符号和大写字母为词语的表示增添了额外的细微差别。

一种强大而简单的文本分类技术是将原始文本分解为n-grams,通常是 unigrams(一元组)、bigrams(二元组)和 trigrams(三元组)(也称为 1-gram、2-gram 和 3-gram)。例如,“there is a cat and a dog”这样的句子可以转换为 unigrams(“there,” “is,” “a,” 等等)、bigrams(“there is,” “is a,” “a cat,” 等等)和 trigrams(“there is a,” “is a cat,” “a cat and,” …)。

这个句子有 7 个一元词项,6 个二元词项和 5 个三元词项;总计这个句子可以通过一个包含 6 个唯一一元词项(因为术语 “a” 被使用了两次)、6 个唯一二元词项和 5 个唯一三元词项的词汇表以这种形式表示,总共有 17 个描述性项。如你所见,用于表示句子的 n-gram 词汇表会快速增长;有些术语非常常见,而有些则非常罕见。

有控制词汇表爆炸的技术,例如消除停用词(删除最常见且通常无信息的词项,如 “a”、“the” 和 “of”)、将所有内容转换为小写,并忽略较少频繁的类型的词项(例如标点符号、数字和括号)。如果你从事自然语言处理,你很快就会接触到这些方法。

介绍 DictVectorizerFeatureHasher

在我们看 Usenet 分类任务之前,让我们看一下两个帮助处理 NLP 挑战的 scikit-learn 特征处理工具。首先是 DictVectorizer,它接受一个词汇表及其频率的字典,并将其转换为一个宽度可变的稀疏矩阵(我们将在 “SciPy's Sparse Matrices” 中讨论稀疏矩阵)。第二个是 FeatureHasher,它将相同的词汇表及其频率转换为固定宽度的稀疏矩阵。

示例 11-22 展示了两个句子:“there is a cat” 和 “there is a cat and a dog”,这两个句子之间共享了词项,“a” 在其中一个句子中使用了两次。在调用 fit 时,DictVectorizer 获得了这些句子;第一次遍历时,它构建了一个内部 vocabulary_ 单词列表,第二次遍历时,它构建了一个稀疏矩阵,包含每个词项及其计数的引用。

进行两次遍历比 FeatureHasher 的一次遍历需要更长时间,并且构建词汇表会额外消耗内存。构建词汇表通常是一个串行过程;通过避免这一阶段,特征哈希可以潜在地并行操作以获得额外的速度。

示例 11-22. 使用 DictVectorizer 进行无损文本表示
In [2]: from sklearn.feature_extraction import DictVectorizer
   ...:
   ...: dv = DictVectorizer()
   ...: # frequency counts for ["there is a cat", "there is a cat and a dog"]
   ...: token_dict = [{'there': 1, 'is': 1, 'a': 1, 'cat': 1},
   ...:               {'there': 1, 'is': 1, 'a': 2, 'cat': 1, 'and': 1, 'dog': 1}]

In [3]: dv.fit(token_dict)
   ...:
   ...: print("Vocabulary:")
   ...: pprint(dv.vocabulary_)

Vocabulary:
{'a': 0, 'and': 1, 'cat': 2, 'dog': 3, 'is': 4, 'there': 5}

In [4]: X = dv.transform(token_dict)

为了使输出更加清晰,请参阅矩阵 X 在 图 11-4 中的 Pandas DataFrame 视图,其中列被设置为词汇表。注意,这里我们已经制作了一个密集的矩阵表示——我们有 2 行和 6 列,每个 12 个单元格都包含一个数字。在稀疏形式中,我们仅存储存在的 10 个计数,对于缺失的 2 个项目我们不存储任何内容。对于较大的语料库,密集表示所需的存储空间,大部分是 0,很快变得难以承受。对于自然语言处理来说,稀疏表示是标准的。

DictVectorizer 转换输出

图 11-4. DictVectorizer 转换输出显示在 Pandas DataFrame 中

DictVectorizer 的一个特点是我们可以给它一个矩阵,并且反向进行处理。在 示例 11-23 中,我们使用词汇表来恢复原始的频率表示。请注意,这并恢复原始句子;在第一个示例中,有多种解释单词顺序的方式(“there is a cat” 和 “a cat is there” 都是有效的解释)。如果我们使用了二元组,我们会开始引入对单词顺序的约束。

示例 11-23. 将矩阵 X 的输出反向转换为原始字典表示
In [5]: print("Reversing the transform:")
   ...: pprint(dv.inverse_transform(X))

Reversing the transform:
[{'a': 1, 'cat': 1, 'is': 1, 'there': 1},
 {'a': 2, 'and': 1, 'cat': 1, 'dog': 1, 'is': 1, 'there': 1}]

FeatureHasher 接受相同的输入并生成类似的输出,但有一个关键的区别:它不存储词汇表,而是使用散列算法将标记频率分配到列中。

我们已经在 “字典和集合是如何工作的?” 中看过哈希函数。哈希将唯一项(在本例中是文本标记)转换为一个数字,其中多个唯一项可能映射到相同的哈希值,这时我们会发生冲突。良好的哈希函数会导致很少的冲突。如果我们将许多唯一项哈希到一个较小的表示中,冲突是不可避免的。哈希函数的一个特点是它不容易反向操作,因此我们无法将哈希值转换回原始标记。

在 示例 11-24 中,我们要求一个固定宽度为 10 列的矩阵——默认情况下是一个包含 100 万个元素的固定宽度矩阵,但我们将在这里使用一个小矩阵来展示一个冲突。对于许多应用程序来说,默认的 100 万元素宽度是一个合理的默认值。

哈希过程使用了快速的 MurmurHash3 算法,它将每个标记转换为一个数字;然后将其转换为我们指定的范围内。较大的范围有较少的冲突;像我们的 10 的范围将会有许多冲突。由于每个标记必须映射到仅有的 10 列之一,如果我们添加了许多句子,我们将会得到许多冲突。

输出 X 具有 2 行和 10 列;每个标记映射到一列,并且我们不能立即知道哪一列代表每个单词,因为哈希函数是单向的,所以我们无法将输出映射回输入。在这种情况下,我们可以推断,使用 extra_token_dict,标记 thereis 都映射到列 8,因此在列 8 中我们得到九个 0 和一个计数为 2。

示例 11-24. 使用一个包含 10 列的 FeatureHasher 来展示哈希冲突
In [6]: from sklearn.feature_extraction import FeatureHasher
   ...:
   ...: fh = FeatureHasher(n_features=10, alternate_sign=False)
   ...: fh.fit(token_dict)
   ...: X = fh.transform(token_dict)
   ...: pprint(X.toarray().astype(np.int_))
   ...:
array([[1, 0, 0, 0, 0, 0, 0, 2, 0, 1],
       [2, 0, 0, 1, 0, 1, 0, 2, 0, 1]])

In [7]: extra_token_dict = [{'there': 1, 'is': 1}, ]
   ...: X = fh.transform(extra_token_dict)
   ...: print(X.toarray().astype(np.int_))
   ...:
[[0 0 0 0 0 0 0 2 0 0]]

尽管存在冲突的发生,通常在这种表示中保留了足够的信号(假设使用了默认列数),以便与 DictVectorizer 相比,FeatureHasher 能够获得类似的优质机器学习结果。

在一个真实问题上比较 DictVectorizer 和 FeatureHasher

如果我们使用完整的 20 个新闻组数据集,我们有 20 个类别,大约 18,000 封电子邮件分布在这些类别中。虽然某些类别如“sci.med”相对独特,但其他类别如“comp.os.ms-windows.misc”和“comp.windows.x”将包含共享相似术语的电子邮件。机器学习任务是为测试集中的每个项目正确识别出 20 个选项中的正确新闻组。测试集大约有 4,000 封电子邮件;用于学习术语到匹配类别映射的训练集大约有 14,000 封电子邮件。

请注意,本示例涉及一些现实训练挑战的必要性。我们未剥离新闻组元数据,这些元数据可能会用于这一挑战的过度拟合;与仅从电子邮件文本泛化不同,某些外部元数据人为地提高了分数。我们已随机打乱了电子邮件。在这里,我们不试图获得单一优秀的机器学习结果;而是演示了一个损失哈希表示可以等效于一个非损失且更占内存的变体。

在示例 11-25 中,我们采用 18,846 份文档,并使用DictVectorizerFeatureHasher分别基于单字、双字和三字组建立训练和测试集表示。对于训练集,DictVectorizer的稀疏数组形状为(14,134, 4,335,793),我们的 14,134 封电子邮件使用了 4 百万个标记。构建词汇表和转换训练数据耗时 42 秒。

与此形成对比的是FeatureHasher,它具有固定的 100 万元素宽度的哈希表示,转换耗时 21 秒。请注意,在这两种情况下,稀疏矩阵存储了大约 980 万个非零项,因此它们存储了类似数量的信息。由于冲突,哈希版本存储了大约 10,000 个较少的项。

如果我们使用了一个密集矩阵,我们将有 14 万行和 1 千万列,每个单元格为 8 字节——远超过当前任何一台机器的典型可用内存。只有这个矩阵的微小部分是非零的。稀疏矩阵避免了这种内存消耗。

示例 11-25. 在一个真实的机器学习问题上比较DictVectorizerFeatureHasher
Loading 20 newsgroups training data
18846 documents - 35.855MB

DictVectorizer on frequency dicts
DictVectorizer has shape (14134, 4335793) with 78,872,376 bytes
 and 9,859,047 non-zero items in 42.15 seconds
Vocabulary has 4,335,793 tokens
LogisticRegression score 0.89 in 1179.33 seconds

FeatureHasher on frequency dicts
FeatureHasher has shape (14134, 1048576) with 78,787,936 bytes
 and 9,848,492 non-zero items in 21.59 seconds
LogisticRegression score 0.89 in 903.35 seconds

关键是,在DictVectorizer上使用的LogisticRegression分类器,与使用FeatureHasher的 100 万列相比,训练时间长了 30%。两者都显示了 0.89 的得分,因此对于这一挑战,结果基本上是等效的。

使用 FeatureHasher,我们在测试集上达到了相同的分数,更快地构建了训练矩阵,避免了构建和存储词汇表,训练速度也比常见的 DictVectorizer 方法快。但作为交换,我们失去了将散列表示转换回原始特征以进行调试和解释的能力,而且由于我们经常希望能够诊断为什么做出决策,这可能是一个过于昂贵的交易。

SciPy 的稀疏矩阵

在 “介绍 DictVectorizer 和 FeatureHasher” 中,我们使用 DictVectorizer 创建了一个大特征表示,它在后台使用稀疏矩阵。这些稀疏矩阵也可以用于一般计算,并且在处理稀疏数据时非常有用。

稀疏矩阵 是大多数矩阵元素为 0 的矩阵。对于这类矩阵,有许多方法可以编码非零值,然后简单地说“所有其他值都是零”。除了这些内存节省外,许多算法还有处理稀疏矩阵的特殊方法,提供额外的计算优势:

>>> from scipy import sparse
>>> A_sparse = sparse.random(2048, 2048, 0.05).tocsr()
>>> A_sparse
<2048x2048 sparse matrix of type '<class 'numpy.float64'>'
        with 209715 stored elements in Compressed Sparse Row format>
>>> %timeit A_sparse * A_sparse
150 ms ± 1.71 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
>>> A_dense = A_sparse.todense()
>>> type(A_dense)
numpy.matrix
>>> %timeit A_dense * A_dense
571 ms ± 14.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

这种最简单的实现是在 SciPy 的 COO 矩阵中,对于每个非零元素,我们存储该值以及该值的位置。这意味着对于每个非零值,我们总共存储三个数字。只要我们的矩阵至少有 66% 的零条目,我们就可以减少用于表示数据的内存量,这与标准的 numpy 数组相比是一种节省。然而,COO 矩阵通常仅用于构造稀疏矩阵而不是进行实际计算(对于这一点,更喜欢使用 CSR/CSC)。

我们可以看到在 图 11-5 中,对于低密度,稀疏矩阵比其密集对应物速度快得多。此外,它们还使用更少的内存。

测试稀疏矩阵与密集矩阵在各种矩阵密度值下的运行时速度。稀疏矩阵通过  创建,密集矩阵通过  创建

图 11-5. 稀疏与密集矩阵乘法

在 图 11-6 中,密集矩阵始终使用 32.7 MB 内存(2048 × 2048 × 64 位)。然而,20% 密度的稀疏矩阵仅使用 10 MB,节省了 70%!随着稀疏矩阵密度的增加,由于向量化和更好的缓存性能带来的好处,numpy 的速度迅速超越了。

2048 x 2048 稠密与稀疏矩阵在不同密度下的足迹

图 11-6. 稀疏与密集内存足迹

这种极端的内存使用减少部分是速度提升如此明显的原因之一。除了仅对非零元素执行乘法运算(从而减少所需的操作次数)外,我们还不需要为保存结果分配如此大量的空间。这就是稀疏数组加速的推拉之道——在失去高效缓存和向量化的同时,还不必做与矩阵零值相关的大量计算。

稀疏矩阵特别擅长的一种操作是余弦相似度。事实上,在创建DictVectorizer时,如我们在“介绍 DictVectorizer 和 FeatureHasher”中所做的那样,通常使用余弦相似度来查看两段文本的相似度。总体而言,对于这些项目对项目的比较(其中特定矩阵元素的值与另一个矩阵元素进行比较),稀疏矩阵表现非常出色。由于调用numpy的方式无论我们使用普通矩阵还是稀疏矩阵都是相同的,我们可以在不改变算法代码的情况下对使用稀疏矩阵的好处进行基准测试。

虽然这很令人印象深刻,但也存在严重的限制。稀疏矩阵的支持程度相当有限,除非你运行特殊的稀疏算法或者仅进行基本操作,否则在支持方面可能会遇到障碍。此外,SciPy 的sparse模块提供了多种稀疏矩阵的实现,每种都有不同的优缺点。理解哪种是最佳选择,并在何时使用它需要一些专业知识,并经常会导致冲突的需求。因此,稀疏矩阵可能不是你经常使用的工具,但当它们是正确的工具时,它们是无价的。

使用较少内存的技巧

一般而言,如果可以避免将其放入 RAM 中,请避免。加载的每一项都会消耗你的 RAM。例如,你可能可以只加载部分数据,例如使用内存映射文件;或者,你可以使用生成器仅加载需要进行部分计算的数据部分,而不是一次性加载所有数据。

如果你正在处理数值数据,几乎可以肯定地说你会想要切换到使用numpy数组——该包提供了许多直接在底层基本对象上运行的快速算法。与使用数字列表相比,RAM 的节省可能非常巨大,时间的节省同样令人惊讶。此外,如果处理的是非常稀疏的数组,使用 SciPy 的稀疏数组功能可以节省大量内存,尽管与普通的numpy数组相比功能集合有所减少。

如果您正在处理字符串,请使用str而不是bytes,除非您有充分理由在字节级别工作。手动处理各种文本编码非常麻烦,而 UTF-8(或其他 Unicode 格式)倾向于解决这些问题。如果您在静态结构中存储许多 Unicode 对象,您可能想研究我们刚讨论过的 DAWG 和 trie 结构。

如果您正在处理大量的位串,请研究numpybitarray包;两者都具有有效的位打包表示。您还可以通过查看 Redis 来受益于有效存储位模式。

PyPy 项目正在尝试更高效的同质数据结构表示,因此在 PyPy 中,长列表的同一种原始类型(例如整数)可能比 CPython 中的等效结构成本低得多。MicroPython项目对于任何与嵌入式系统一起工作的人都很有趣:这个内存占用极小的 Python 实现正在争取实现 Python 3 兼容性。

几乎可以肯定,当您试图优化 RAM 使用时,您知道您必须进行基准测试,并且在进行算法更改之前建立一个单元测试套件将会非常有益。

在回顾了压缩字符串和高效存储数字的方法之后,我们现在将探讨为存储空间而牺牲精度的方法。

概率数据结构

概率数据结构允许您在精度上做出牺牲以极大减少内存使用。此外,您可以对它们执行的操作数量远远少于set或 trie。例如,使用单个 2.56 KB 的 HyperLogLog++结构,您可以计算约 7,900,000,000 个项目的唯一项数,误差为 1.625%。

这意味着,如果我们试图统计汽车的唯一车牌号码数量,而我们的 HyperLogLog++ 计数器显示有 654,192,028 个,我们可以确信实际数字在 643,561,407 和 664,822,648 之间。此外,如果这种精度不够,您可以简单地向结构添加更多内存,它将表现得更好。给它 40.96 KB 的资源将把误差从 1.625%降低到 0.4%。然而,将这些数据存储在一个set中将需要 3.925 GB,即使假设没有任何额外开销!

另一方面,HyperLogLog++ 结构只能计数一组set的车牌并与另一个set合并。例如,我们可以为每个州创建一个结构,找出每个州中有多少个独特的车牌,然后将它们全部合并以获得整个国家的计数。如果我们拿到一个车牌号,我们无法非常准确地告诉您我们之前是否见过它,也无法给您一些我们已经见过的车牌的样本。

当你花时间理解问题并需要将某些内容投入生产以回答大量数据中的一小部分问题时,概率性数据结构非常棒。每种结构可以以不同的精度回答不同的问题,因此找到适合的结构只是理解你需求的问题。

警告

这一部分大部分内容深入探讨了许多流行的概率性数据结构的动力机制。这是有用的,因为一旦你理解了这些机制,你可以在设计算法时使用它们的部分。如果你刚开始接触概率性数据结构,可能先看一下实际例子(“实际例子”)再深入了解内部机制会更有帮助。

几乎在所有情况下,概率性数据结构的工作方式是找到数据的替代表示,这种表示更紧凑并包含回答特定问题所需的相关信息。这可以被看作是一种有损压缩,我们可能会丢失一些数据的特定方面,但保留必要的组成部分。由于我们允许丢失对于特定问题集并不必要的数据,这种有损压缩比我们之前看到的基于 tries 的无损压缩效率要高得多。正是因为这个原因,选择将要使用的概率性数据结构非常重要——你希望选择一个能够保留适合你使用情况的正确信息的结构!

在我们深入讨论之前,需要明确的是这里所有的“误差率”都是以标准差为定义。这个术语来自于描述高斯分布,并且说明了函数围绕中心值的分布有多广。当标准差增大时,远离中心点的数值也会增多。概率性数据结构的误差率被这样框定,因为它们周围的所有分析都是概率性的。因此,例如,当我们说 HyperLogLog++算法的误差率为e r r = 1.04 m时,我们的意思是有 68%的时间误差小于err,95%的时间误差小于 2 × err,99.7%的时间误差小于 3 × err。²

使用一个字节的莫里斯计数器进行非常粗略的计数

我们将介绍概率计数的主题,其中包括最早的概率计数器之一,即 Morris 计数器(由 NSA 和贝尔实验室的 Robert Morris 设计)。应用场景包括在受限 RAM 环境中计数数百万个对象(例如嵌入式计算机)、理解大数据流以及解决人工智能中的问题,如图像和语音识别。

Morris 计数器跟踪一个指数,并将被计数的状态建模为 2 exponent(而不是正确的计数)—它提供一个 数量级 的估计。此估计是使用概率规则更新的。

我们从指数设置为 0 开始。如果我们请求计数器的 ,我们将得到 pow(2,*exponent*)=1(敏锐的读者会注意到这与实际值相差一步,我们确实说过这是一个 近似 计数器!)。如果我们要求计数器自增,它将生成一个随机数(使用均匀分布),并且会测试 random.uniform(0, 1) <= 1/pow(2,*exponent*),这个测试总是成立(pow(2,0) == 1)。计数器自增,指数设为 1。

第二次请求计数器自增时,它会测试 random.uniform(0, 1) <= 1/pow(2,1) 是否成立。这个测试有 50%的概率会通过。如果测试通过,则指数会增加。否则,在这次自增请求中指数不会增加。

表 11-1 展示了每个初始指数下增加发生的可能性。

表 11-1. Morris 计数器详细信息

指数 pow(2,exponent) P(increment)
0 1 1
1 2 0.5
2 4 0.25
3 8 0.125
4 16 0.0625
254 2.894802e+76 3.454467e-77

我们大约能计数的最大值,当我们使用单个无符号字节作为指数时,是 math.pow(2,255) == 5e76。随着计数增加,与实际计数的相对误差将会很大,但是相对于需要使用的 32 个无符号字节,内存节省是巨大的。示例 11-26 展示了 Morris 计数器的简单实现。

示例 11-26. 简单的 Morris 计数器实现
"""Approximate Morris counter supporting many counters"""
import math
import random
import array

SMALLEST_UNSIGNED_INTEGER = 'B' # unsigned char, typically 1 byte

class MorrisCounter(object):
    """Approximate counter, stores exponent and counts approximately 2^exponent

 https://en.wikipedia.org/wiki/Approximate_counting_algorithm"""
    def __init__(self, type_code=SMALLEST_UNSIGNED_INTEGER, nbr_counters=1):
        self.exponents = array.array(type_code, [0] * nbr_counters)

    def __len__(self):
        return len(self.exponents)

    def add_counter(self):
        """Add a new zeroed counter"""
        self.exponents.append(0)

    def get(self, counter=0):
        """Calculate approximate value represented by counter"""
        return math.pow(2, self.exponents[counter])

    def add(self, counter=0):
        """Probabilistically add 1 to counter"""
        value = self.get(counter)
        probability = 1.0 / value
        if random.uniform(0, 1) < probability:
            self.exponents[counter] += 1

if __name__ == "__main__":
    mc = MorrisCounter()
    print("MorrisCounter has {} counters".format(len(mc)))
    for n in range(10):
        print("Iteration %d, MorrisCounter has: %d" % (n, mc.get()))
        mc.add()

    for n in range(990):
        mc.add()
    print("Iteration 1000, MorrisCounter has: %d" % (mc.get()))

使用这个实现,我们可以在 示例 11-27 中看到,第一次请求增加计数器成功,第二次也成功,但第三次失败了。³

示例 11-27. Morris 计数器库示例
>>> mc = MorrisCounter()
>>> mc.get()
1.0

>>> mc.add()
>>> mc.get()
2.0

>>> mc.add()
>>> mc.get()
4.0

>>> mc.add()
>>> mc.get()
4.0

在图 11-7 中,粗黑线显示了每次迭代中正常整数的增加。在 64 位计算机上,这是一个 8 字节的整数。三个 1 字节的 Morris 计数器的演变显示为虚线;y 轴显示它们的值,这大致代表了每次迭代的真实计数。展示三个计数器是为了让你了解它们不同的轨迹和整体趋势;这三个计数器完全独立于彼此。

三个 1 字节的 Morris 计数器

图 11-7. 三个 1 字节的 Morris 计数器与一个 8 字节的整数对比

这个图表可以让你对使用 Morris 计数器时可能遇到的误差有所了解。关于误差行为的更多详细信息可以在在线获取。

K-最小值

在 Morris 计数器中,我们失去了关于插入的任何信息。也就是说,计数器的内部状态无论我们是执行.add("micha")还是.add("ian")都是相同的。这些额外的信息是有用的,如果正确使用,可以帮助我们的计数器仅计算唯一的项。这样,调用.add("micha")数千次只会增加计数器一次。

要实现这种行为,我们将利用哈希函数的特性(参见“哈希函数和熵”以获取哈希函数的更深入讨论)。我们希望利用的主要属性是哈希函数接受输入并均匀地分布它。例如,假设我们有一个哈希函数,它接受一个字符串并输出一个在 0 到 1 之间的数字。对于函数是均匀的意味着当我们输入一个字符串时,得到 0.5 的值和得到 0.2 或任何其他值的概率是相等的。这也意味着,如果我们输入许多字符串值,我们期望这些值相对均匀地分布。请记住,这是一个概率论的论点:这些值不会总是均匀分布,但如果我们有许多字符串并多次尝试这个实验,它们会趋向于均匀分布。

假设我们取了 100 个项并存储了这些值的哈希(哈希值为 0 到 1 的数字)。知道间隔是均匀的意味着,不是说,“我们有 100 个项”,而是说,“每个项之间的距离是 0.01”。这就是 K-Minimum Values 算法最终发挥作用的地方⁴——如果我们保留了我们见过的 k 个最小的唯一哈希值,我们可以近似推断出哈希值之间的总体间隔,并推断出总项数。在 图 11-8 中,我们可以看到 K-Minimum Values 结构(也称为 KMV)在添加更多项时的状态。起初,由于我们没有很多哈希值,我们保留的最大哈希值相当大。随着我们添加更多的哈希值,我们保留的 k 个哈希值中的最大值变得越来越小。使用这种方法,我们可以获得 O ( 2 π(k-2) ) 的错误率。

k 越大时,我们能更好地考虑我们使用的哈希函数对于特定输入和不幸的哈希值并非完全均匀的影响。一个不幸的哈希值的例子是对 ['A', 'B', 'C'] 进行哈希后得到 [0.01, 0.02, 0.03]。如果我们开始哈希更多的值,它们聚集在一起的可能性就越来越小。

此外,由于我们只保留了最小的唯一哈希值,数据结构仅考虑唯一的输入。我们可以轻松看到这一点,因为如果我们处于仅存储最小的三个哈希值的状态,并且当前 [0.1, 0.2, 0.3] 是最小的哈希值,那么如果我们添加一个哈希值为 0.4 的项,我们的状态也不会改变。同样地,如果我们添加更多哈希值为 0.3 的项,我们的状态也不会改变。这是一种称为幂等性的属性;这意味着如果我们在这个结构上多次进行相同的操作,使用相同的输入,状态不会改变。这与例如在 list 上的 append 不同,后者总是会改变其值。这种幂等性的概念延续到本节中所有的数据结构,除了 Morris 计数器。

示例 11-28 展示了一个非常基本的 K-Minimum Values 实现。值得注意的是我们使用了 sortedset,它像集合一样只能包含唯一的项。这种唯一性使得我们的 KMinValues 结构自动具有幂等性。要看到这一点,请跟随代码:当同一项被多次添加时,data 属性不会改变。

K-Min Value 结构的哈希空间密度

图 11-8. 随着更多元素添加到 KMV 结构中存储的值
示例 11-28. 简单的KMinValues实现
import mmh3
from blist import sortedset

class KMinValues:
    def __init__(self, num_hashes):
        self.num_hashes = num_hashes
        self.data = sortedset()

    def add(self, item):
        item_hash = mmh3.hash(item)
        self.data.add(item_hash)
        if len(self.data) > self.num_hashes:
            self.data.pop()

    def __len__(self):
        if len(self.data) <= 2:
            return 0
        length = (self.num_hashes - 1) * (2 ** 32 - 1) /
                 (self.data[-2] + 2 ** 31 - 1)
        return int(length)

使用 Python 包countmemaybe中的KMinValues实现(示例 11-29),我们可以开始看到这种数据结构的实用性。这个实现与示例 11-28 中的实现非常相似,但它完全实现了其他集合操作,如并集和交集。还要注意,“size”和“cardinality”可以互换使用(“cardinality”这个词来自集合理论,在概率数据结构的分析中更常用)。在这里,我们可以看到,即使对于一个相对较小的k值,我们也可以存储 50,000 个项目,并计算许多集合操作的基数,误差相对较低。

示例 11-29. countmemaybe KMinValues实现
>>> from countmemaybe import KMinValues

>>> kmv1 = KMinValues(k=1024)

>>> kmv2 = KMinValues(k=1024)

>>> for i in range(0,50000): ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
    kmv1.add(str(i))
   ...:

>>> for i in range(25000, 75000): ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
    kmv2.add(str(i))
   ...:

>>> print(len(kmv1))
50416

>>> print(len(kmv2))
52439

>>> print(kmv1.cardinality_intersection(kmv2))
25900.2862992

>>> print(kmv1.cardinality_union(kmv2))
75346.2874158

1

我们将 50,000 个元素放入kmv1中。

2

kmv2也获得了 50,000 个元素,其中 25,000 个也在kmv1中。

注意

对于这种类型的算法,哈希函数的选择可能会对估计的质量产生重大影响。这两种实现都使用了mmh3,这是murmurhash3的 Python 实现,具有良好的哈希字符串属性。但是,如果对于您的特定数据集更方便,也可以使用不同的哈希函数。

布隆过滤器

有时,我们需要能够执行其他类型的集合操作,为此我们需要引入新类型的概率数据结构。布隆过滤器就是为了回答我们是否以前见过某个项目的问题而创建的。⁵

布隆过滤器通过具有多个哈希值来表示一个值的方式工作,如果以后看到具有相同整数集合的东西,我们可以合理地认为它是相同的值。

为了以一种有效利用可用资源的方式来实现这一点,我们将整数隐式编码为列表的索引。这可以看作是一个初始设置为Falsebool值列表。如果我们被要求添加具有哈希值[10, 4, 7]的对象,我们将列表的第十、第四和第七个索引设置为True。将来,如果我们被问及之前是否见过特定项,我们只需找到其哈希值并检查bool列表中对应的位置是否都设置为True

这种方法既不会产生误报,又可以控制误判率。如果布隆过滤器表示我们以前没有看到某个项目,我们可以百分之百确定我们确实没有看到过这个项目。另一方面,如果布隆过滤器表示我们曾经看到过某个项目,实际上有可能我们并没有,我们只是看到了一个错误的结果。这种错误结果来自哈希冲突的事实,有时两个对象的哈希值可能相同,即使对象本身并不相同。然而,在实践中,布隆过滤器的误差率通常低于 0.5%,因此这种误差是可以接受的。

注意

我们可以简单地通过两个相互独立的哈希函数来模拟任意多个哈希函数。这种方法称为双重哈希

def multi_hash(key, num_hashes):
    hash1, hash2 = hashfunction(key)
    for i in range(num_hashes):
        yield (hash1 + i * hash2) % (2³² - 1)

取模操作确保生成的哈希值为 32 位(对于 64 位哈希函数,我们会取模2⁶⁴ - 1)。

布尔列表的确切长度和每个项目所需的哈希值数量将根据我们需要的容量和误差率进行固定。通过一些相当简单的统计论证,⁶ 我们得出理想值如下:

n u m b i t s = c a p a c i t y × log(error) log(2) 2n u m h a s h e s = n u m _ b i t s × log(2) capacity

如果我们希望以 0.05%的误报率(即我们声称曾经见过一个对象,实际上却没有的情况)存储 50,000 个对象(无论这些对象本身有多大),则需要 791,015 位的存储空间和 11 个哈希函数。

为了进一步提高我们在内存使用方面的效率,我们可以使用单个位来表示bool值(本地bool实际上占据 4 位)。我们可以通过使用bitarray模块轻松实现这一点。示例 11-30 展示了一个简单的 Bloom 过滤器实现。

示例 11-30。简单的 Bloom 过滤器实现
import math

import bitarray
import mmh3

class BloomFilter:
    def __init__(self, capacity, error=0.005):
        """
 Initialize a Bloom filter with given capacity and false positive rate
 """
        self.capacity = capacity
        self.error = error
        self.num_bits = int((-capacity * math.log(error)) // math.log(2) ** 2 + 1)
        self.num_hashes = int((self.num_bits * math.log(2)) // capacity + 1)
        self.data = bitarray.bitarray(self.num_bits)

    def _indexes(self, key):
        h1, h2 = mmh3.hash64(key)
        for i in range(self.num_hashes):
            yield (h1 + i * h2) % self.num_bits

    def add(self, key):
        for index in self._indexes(key):
            self.data[index] = True

    def __contains__(self, key):
        return all(self.data[index] for index in self._indexes(key))

    def __len__(self):
        bit_off_num = self.data.count(True)
        bit_off_percent = 1.0 - bit_off_num / self.num_bits
        length = -1.0 * self.num_bits * math.log(bit_off_percent) / self.num_hashes
        return int(length)

    @staticmethod
    def union(bloom_a, bloom_b):
        assert bloom_a.capacity == bloom_b.capacity, "Capacities must be equal"
        assert bloom_a.error == bloom_b.error, "Error rates must be equal"

        bloom_union = BloomFilter(bloom_a.capacity, bloom_a.error)
        bloom_union.data = bloom_a.data | bloom_b.data
        return bloom_union

如果我们插入的项目比 Bloom 过滤器的容量指定的要多会发生什么?在极端情况下,bool列表中的所有项目都将设置为True,在这种情况下,我们说我们已经见过每个项目。这意味着 Bloom 过滤器对其初始容量设置非常敏感,如果我们正在处理一个大小未知的数据集(例如数据流),这可能会非常令人恼火。

处理这个问题的一种方法是使用一种称为可伸缩的 Bloom 过滤器的变体。⁷ 它们通过将多个 Bloom 过滤器链接在一起,这些过滤器的错误率以特定的方式变化。⁸ 通过这样做,我们可以保证整体的错误率,并在需要更多容量时添加一个新的 Bloom 过滤器。要检查我们以前是否见过一个项目,我们需要遍历所有的子 Bloom,直到我们找到该对象或者我们用尽了列表。这种结构的一个示例实现可以在示例 11-31 中看到,我们在基础功能中使用了之前的 Bloom 过滤器实现,并有一个计数器来简化知道何时添加新的 Bloom。

示例 11-31。简单的 Bloom 过滤器实现
from bloomfilter import BloomFilter

class ScalingBloomFilter:
    def __init__(self, capacity, error=0.005, max_fill=0.8,
                 error_tightening_ratio=0.5):
        self.capacity = capacity
        self.base_error = error
        self.max_fill = max_fill
        self.items_until_scale = int(capacity * max_fill)
        self.error_tightening_ratio = error_tightening_ratio
        self.bloom_filters = []
        self.current_bloom = None
        self._add_bloom()

    def _add_bloom(self):
        new_error = self.base_error * self.error_tightening_ratio ** len(
            self.bloom_filters
        )
        new_bloom = BloomFilter(self.capacity, new_error)
        self.bloom_filters.append(new_bloom)
        self.current_bloom = new_bloom
        return new_bloom

    def add(self, key):
        if key in self:
            return True
        self.current_bloom.add(key)
        self.items_until_scale -= 1
        if self.items_until_scale == 0:
            bloom_size = len(self.current_bloom)
            bloom_max_capacity = int(self.current_bloom.capacity * self.max_fill)

            # We may have been adding many duplicate values into the Bloom, so
            # we need to check if we actually need to scale or if we still have
            # space
            if bloom_size >= bloom_max_capacity:
                self._add_bloom()
                self.items_until_scale = bloom_max_capacity
            else:
                self.items_until_scale = int(bloom_max_capacity - bloom_size)
        return False

    def __contains__(self, key):
        return any(key in bloom for bloom in self.bloom_filters)

    def __len__(self):
        return int(sum(len(bloom) for bloom in self.bloom_filters))

处理这个问题的另一种方法是使用一种称为定时 Bloom 过滤器的方法。这种变体允许元素从数据结构中过期,从而为更多元素释放空间。这对处理流非常方便,因为我们可以让元素在一个小时后过期,并且将容量设置得足够大,以处理每小时所见到的数据量。这样使用 Bloom 过滤器会给我们一个对过去一小时发生了什么的良好视图。

使用这种数据结构会感觉很像使用set对象。在以下交互中,我们使用可伸缩的 Bloom 过滤器添加了几个对象,测试我们以前是否见过它们,然后尝试实验性地找出误报率:

>>> bloom = BloomFilter(100)

>>> for i in range(50):
   ....:     bloom.add(str(i))
   ....:

>>> "20" in bloom
True

>>> "25" in bloom
True

>>> "51" in bloom
False

>>> num_false_positives = 0

>>> num_true_negatives = 0

>>> # None of the following numbers should be in the Bloom.
>>> # If one is found in the Bloom, it is a false positive.
>>> for i in range(51,10000):
   ....:     if str(i) in bloom:
   ....:         num_false_positives += 1
   ....:     else:
   ....:         num_true_negatives += 1
   ....:

>>> num_false_positives
54

>>> num_true_negatives
9895

>>> false_positive_rate = num_false_positives / float(10000 - 51)

>>> false_positive_rate
0.005427681173987335

>>> bloom.error
0.005

我们还可以使用 Bloom 过滤器对多个项目集进行并集操作:

>>> bloom_a = BloomFilter(200)

>>> bloom_b = BloomFilter(200)

>>> for i in range(50):
   ...:     bloom_a.add(str(i))
   ...:

>>> for i in range(25,75):
   ...:     bloom_b.add(str(i))
   ...:

>>> bloom = BloomFilter.union(bloom_a, bloom_b)

>>> "51" in bloom_a ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png)
Out[9]: False

>>> "24" in bloom_b ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png)
Out[10]: False

>>> "55" in bloom ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png)
Out[11]: True

>>> "25" in bloom
Out[12]: True

1

51不在bloom_a中。

2

类似地,值24不在bloom_b中。

3

然而,bloom对象包含了bloom_abloom_b中的所有对象!

一个注意事项是,只能对两个容量和误差率相同的布隆过滤器执行并集。此外,最终布隆过滤器的使用容量可以高达将两个并集成它的布隆过滤器的使用容量之和。这意味着你可以从两个稍微超过一半满的布隆过滤器开始,并将它们联合起来,得到一个超过容量且不可靠的新布隆过滤器!

注意

布谷鸟过滤器是一种现代布隆过滤器类似的数据结构,提供了与布隆过滤器类似的功能,并且具有更好的对象删除功能。此外,大多数情况下,布谷鸟过滤器的开销更低,导致比布隆过滤器更好的空间效率。当需要跟踪固定数量的对象时,它通常是一个更好的选择。然而,当其负载限制达到并且没有数据结构自动缩放选项时(就像我们看到的缩放布隆过滤器),其性能会急剧下降。

在内存高效方式中进行快速集合包含的工作是数据库研究中非常重要和活跃的部分。布谷鸟过滤器、布隆曼过滤器XOR 过滤器等不断被发布。然而,对于大多数应用程序,最好还是坚持使用众所周知、得到良好支持的布隆过滤器。

LogLog 计数器

LogLog 类型计数器基于以下认识:哈希函数的每个比特也可以被视为随机的。也就是说,哈希的第一个比特为1的概率为 50%,前两个比特为01的概率为 25%,前三个比特为001的概率为 12.5%。通过了解这些概率,并保留具有最多0的哈希(即最不可能的哈希值),我们可以估算出到目前为止我们见过多少项。

这种方法的一个很好的类比是抛硬币。想象一下我们想抛硬币 32 次并每次都得到正面。数字 32 来自于我们使用 32 位哈希函数的事实。如果我们抛一次硬币得到反面,我们会记录数字0,因为我们最好的尝试没有一次得到正面。由于我们知道这个硬币翻转背后的概率,我们也可以告诉你我们最长的系列是0,你可以估算我们已经尝试了这个实验2⁰ = 1次。如果我们继续抛硬币并在得到反面之前得到 10 次正面,那么我们会记录数字10。使用相同的逻辑,你可以估算我们尝试了2¹⁰ = 1024次实验。使用这个系统,我们能够计数的最高数字将是我们考虑的最大抛硬币次数(32 次抛硬币的情况下是2³² = 4,294,967,296)。

要用 LogLog 类型计数器来编码这个逻辑,我们取输入的哈希值的二进制表示,看看第一个 1 之前有多少个 0。可以将哈希值视为一系列 32 次硬币翻转,其中 0 表示正面,1 表示反面(即 000010101101 意味着在第一个反面前我们翻了四次正面,而 010101101 意味着在第一个反面前我们翻了一次正面)。这给我们一个概念,即在达到这个哈希值之前发生了多少次尝试。这个系统背后的数学几乎等同于 Morris 计数器,但有一个主要的例外:我们通过查看实际输入来获取“随机”值,而不是使用随机数生成器。这意味着如果我们持续向 LogLog 计数器添加相同的值,其内部状态不会改变。Example 11-32 展示了 LogLog 计数器的简单实现。

示例 11-32. LogLog 寄存器的简单实现
import mmh3

def trailing_zeros(number):
    """
 Returns the index of the first bit set to 1 from the right side of a 32-bit
 integer
 >>> trailing_zeros(0)
 32
 >>> trailing_zeros(0b1000)
 3
 >>> trailing_zeros(0b10000000)
 7
 """
    if not number:
        return 32
    index = 0
    while (number >> index) & 1 == 0:
        index += 1
    return index

class LogLogRegister:
    counter = 0
    def add(self, item):
        item_hash = mmh3.hash(str(item))
        return self._add(item_hash)

    def _add(self, item_hash):
        bit_index = trailing_zeros(item_hash)
        if bit_index > self.counter:
            self.counter = bit_index

    def __len__(self):
        return 2**self.counter

这种方法的最大缺点是,我们可能会得到一个在一开始就增加计数器的哈希值,从而扭曲我们的估计。这类似于第一次尝试就翻了 32 次反面。为了解决这个问题,我们应该让多人同时翻硬币并结合他们的结果。大数定律告诉我们,随着我们增加越来越多的翻转器,总体统计数据受单个翻转器的异常样本影响越少。我们组合结果的确切方式是 LogLog 类型方法(经典 LogLog、SuperLogLog、HyperLogLog、HyperLogLog++ 等)之间差异的根源。

我们可以通过取哈希值的前几位来实现这种“多次翻转”方法,并使用它来指定哪一个翻转器具有特定的结果。如果我们取哈希的前 4 位,这意味着我们有 2⁴ = 16 个翻转器。由于我们用前 4 位进行选择,剩下的 28 位(每个翻转器对应 28 次独立的硬币翻转)意味着每个计数器只能计数到 2²⁸ = 268,435,456。此外,还有一个常数(alpha),它取决于翻转器的数量,用于归一化估计。⁹ 所有这些组合在一起给了我们一个具有 1 . 05 / m 精度的算法,其中 m 是使用的寄存器(或翻转器)的数量。Example 11-33 展示了 LogLog 算法的简单实现。

示例 11-33. LogLog 的简单实现
import mmh3

from llregister import LLRegister

class LL:
    def __init__(self, p):
        self.p = p
        self.num_registers = 2 ** p
        self.registers = [LLRegister() for i in range(int(2 ** p))]
        self.alpha = 0.7213 / (1.0 + 1.079 / self.num_registers)

    def add(self, item):
        item_hash = mmh3.hash(str(item))
        register_index = item_hash & (self.num_registers - 1)
        register_hash = item_hash >> self.p
        self.registers[register_index]._add(register_hash)

    def __len__(self):
        register_sum = sum(h.counter for h in self.registers)
        length = (self.num_registers * self.alpha *
                  2 ** (register_sum / self.num_registers))
        return int(length)

除了使用哈希值作为指示符来去重类似项之外,这个算法还有一个可调参数,用于调节您愿意做的精度与存储折衷之间的平衡。

__len__ 方法中,我们对所有单独的 LogLog 寄存器的估计进行了平均。然而,这并不是组合数据的最有效方式!这是因为我们可能会得到一些不幸的哈希值,使得一个特定的寄存器数值急剧上升,而其他寄存器仍然保持较低数值。因此,我们只能实现 O ( 1.30 m ) 的错误率,其中 m 是使用的寄存器数量。

SuperLogLog 被设计为解决这个问题。¹⁰ 使用这种算法时,仅使用寄存器中最低的 70% 进行尺寸估算,并且它们的值受限于一个限制规则给出的最大值。这种添加将错误率降低到 O ( 1.05 m ) 。这是反直觉的,因为通过忽略信息,我们得到了更好的估计!

最后,HyperLogLog 在 2007 年问世,并为我们带来了进一步的准确性提升。¹¹ 它通过改变个体寄存器的平均方法来实现这一点:不再仅仅是平均,而是使用了一个球形平均方案,还考虑了结构可能处于的不同边缘情况。这使得我们达到了当前最佳的 O ( 1.04 m ) 错误率。此外,这种公式还移除了超级 LogLog 中必需的排序操作。当你尝试高速插入项目时,这可以极大地提升数据结构的性能。示例 11-34 展示了 HyperLogLog 的基本实现。

示例 11-34. HyperLogLog 的简单实现
import math

from ll import LL

class HyperLogLog(LL):
    def __len__(self):
        indicator = sum(2 ** -m.counter for m in self.registers)
        E = self.alpha * (self.num_registers ** 2) / indicator

        if E <= 5.0 / 2.0 * self.num_registers:
            V = sum(1 for m in self.registers if m.counter == 0)
            if V != 0:
                Estar = (self.num_registers *
                         math.log(self.num_registers / (1.0 * V), 2))

            else:
                Estar = E
        else:
            if E <= 2 ** 32 / 30.0:
                Estar = E
            else:
                Estar = -2 ** 32 * math.log(1 - E / 2 ** 32, 2)
        return int(Estar)

if __name__ == "__main__":
    import mmh3

    hll = HyperLogLog(8)
    for i in range(100000):
        hll.add(mmh3.hash(str(i)))
    print(len(hll))

唯一进一步提高准确性的方法是使用 HyperLogLog++ 算法,它在数据结构相对空时增加了准确性。当插入更多项目时,此方案会恢复到标准 HyperLogLog。这实际上非常有用,因为 LogLog 类型计数器的统计需要大量数据才能准确——使用一种允许在较少项目时提高准确性的方案极大地改进了这种方法的可用性。通过具有更小但更精确的 HyperLogLog 结构来实现额外的准确性,稍后可以将其转换为最初请求的较大结构。此外,大小估计中使用了一些经验推导的常数以消除偏差。

现实世界示例

为了更好地理解数据结构,我们首先创建了一个具有许多唯一键的数据集,然后创建了一个带有重复条目的数据集。图表 11-9 和 11-10 展示了当我们将这些键输入到刚刚查看的数据结构中并定期查询“有多少个唯一条目?”时的结果。我们可以看到,包含更多状态变量的数据结构(如 HyperLogLog 和 KMinValues)表现更好,因为它们更稳健地处理了不良统计。另一方面,如果出现一个不幸的随机数或哈希值,Morris 计数器和单个 LogLog 寄存器很快就会有非常高的误差率。然而,对于大多数算法来说,我们知道状态变量的数量与误差保证直接相关,这是有道理的。

hpp2 1109

图表 11-9. 使用多个概率数据结构近似计算重复数据的数量。为此,我们生成了 60,000 个具有许多重复项的项目,并将它们插入到各种概率数据结构中。图表展示了这些数据结构在处理过程中唯一项目数量的预测。

仅看性能最佳的概率数据结构(这些可能是你真正会用到的),我们可以总结它们的实用性及其大致的内存使用情况(参见表 11-2)。我们可以看到内存使用量因关心的问题而有巨大变化。这简单地突显了使用概率数据结构时,你必须首先考虑对数据集真正需要回答的问题。同时要注意,只有布隆过滤器的大小依赖于元素数量。HyperLogLog 和 KMinValues 结构的大小仅依赖于误差率。

作为另一个更为现实的测试,我们选择使用从维基百科文本派生的数据集。我们运行了一个非常简单的脚本,以提取所有文章中长度为五个或更多字符的单词令牌,并将它们存储在一个以换行符分隔的文件中。然后的问题是,“有多少个唯一的令牌?”结果可以在表 11-3 中看到。

表 11-2。主要概率数据结构及其上可用的集合操作对比

大小 联合^(a) 交集 包含 大小^(b)
HyperLogLog 是( O ( 1.04 m ) 否^(c) 2.704 MB
KMinValues 是( O ( 2 π(m-2) ) 20.372 MB
布隆过滤器 是( O ( 0.78 m ) 否^(c) 197.8 MB
^(a) 联合操作在不增加错误率的情况下进行。^(b) 数据结构的大小,具有 0.05%的错误率,1 亿个唯一元素,并使用 64 位哈希函数。^(c) 这些操作可以完成,但在精度上会受到相当大的惩罚。

这个实验的主要收获是,如果您能够专门优化您的代码,您可以获得惊人的速度和内存增益。这在整本书中都是真实的:当我们在“选择性优化:找到需要修复的问题”中专门优化我们的代码时,我们同样能够获得速度提升。

hpp2 1110

图 11-10。使用多种概率数据结构的唯一数据近似计数。为此,我们将数字 1 到 100,000 插入到数据结构中。图中显示了在过程中唯一项目数量的结构预测。

概率数据结构是一种算法方式,可以专门化您的代码。我们仅存储需要的数据,以便以给定的误差界限回答特定问题。通过只处理给定信息的子集,我们不仅可以将内存占用量大大减小,而且还可以更快地执行大多数操作。

表 11-3. Wikipedia 中唯一单词数量的大小估计

元素 相对误差 处理时间^(a) 结构大小^(b)
莫里斯计数器^(c) 1,073,741,824 6.52% 751s 5 位
LogLog 寄存器 1,048,576 78.84% 1,690s 5 位
LogLog 4,522,232 8.76% 2,112s 5 位
HyperLogLog 4,983,171 –0.54% 2,907s 40 KB
KMinValues 4,912,818 0.88% 3,503s 256 KB
Scaling Bloom 4,949,358 0.14% 10,392s 11,509 KB
真实值 4,956,262 0.00% ----- 49,558 KB^(d)
^(a) 处理时间已经调整,以排除从磁盘读取数据集的时间。我们还使用了早期提供的简单实现进行测试。^(b) 结构大小是基于数据量的理论值,因为使用的实现并没有经过优化。^(c) 由于莫里斯计数器不会对输入进行去重,因此给出的大小和相对误差是针对总值的。^(d) 数据集仅考虑唯一令牌时为 49,558 KB,或者包括所有令牌时为 8.742 GB。

因此,无论您是否使用概率数据结构,您都应该始终牢记您将要对数据进行何种问题,以及如何最有效地存储该数据以便提出这些专门的问题。这可能涉及使用一种特定类型的列表而不是另一种,使用一种特定类型的数据库索引而不是另一种,或者甚至使用概率数据结构来丢弃除相关数据之外的所有数据!

¹ 这个例子摘自维基百科上关于确定性无环有限状态自动机(DAFSA)的文章。DAFSA 是 DAWG 的另一个名称。附带的图片来自维基媒体共享资源。

² 这些数字来自于高斯分布的 68-95-99.7 法则。更多信息可以在 Wikipedia 条目 中找到。

³ 一个更完整的实现使用一个 array 字节来创建多个计数器,可以在 https://github.com/ianozsvald/morris_counter 上找到。

⁴ Kevin Beyer 等人在《Proceedings of the 2007 ACM SIGMOD International Conference on Management of Data》中提到,“On Synopses for Distinct-Value Estimation under Multiset Operations”(纽约:ACM,2007),199–210,https://doi.org/10.1145/1247480.1247504

⁵ Burton H. Bloom 在《Communications of the ACM》13 卷 7 期(1970 年)的文章中提到,“Space/Time Trade-Offs in Hash Coding with Allowable Errors”,422–26,http://doi.org/10.1145/362686.362692

Bloom filters 的 Wikipedia 页面中对 Bloom 过滤器的性质有一个非常简单的证明。

⁷ Paolo Sérgio Almeida 等人在《Information Processing Letters》101 卷 6 期(2007 年)的文章中提到,“Scalable Bloom Filters”,255–61,https://doi.org/10.1016/j.ipl.2006.10.007

⁸ 错误值实际上像几何级数一样递减。这样,当你取所有错误率的乘积时,它接近所需的错误率。

⁹ 关于基本的 LogLog 和 SuperLogLog 算法的完整描述可参考http://bit.ly/algorithm_desc

¹⁰ Marianne Durand 和 Philippe Flajolet 在《Algorithms—ESA 2003》中提到,“LogLog Counting of Large Cardinalities”,由 Giuseppe Di Battista 和 Uri Zwick 编辑,卷 2832(柏林,海德堡:施普林格,2003),605–17,https://doi.org/10.1007/978-3-540-39658-1_55

¹¹ Philippe Flajolet 等人在《AOFA ’07: Proceedings of the 2007 International Conference on Analysis of Algorithms》中提到,“HyperLogLog: The Analysis of a Near-Optimal Cardinality Estimation Algorithm”(AOFA,2007),127–46。

第十二章:现场经验教训

在本章中,我们收集了一些成功公司的故事,这些公司在高数据量和速度关键的情况下使用 Python。这些故事由每个组织中具有多年经验的关键人员撰写;他们不仅分享了他们的技术选择,还分享了一些宝贵的经验。我们为您带来了来自领域内其他专家的四个新故事。我们还保留了本书第一版的“现场经验教训”,其标题标有“(2014)”。

用 Feature-engine 简化特征工程流程

Soledad Galli (trainindata.com)

Train in Data 是一个由经验丰富的数据科学家和人工智能软件工程师领导的教育项目。我们帮助专业人士提高编码和数据科学技能,并采用机器学习最佳实践。我们开设先进的机器学习和人工智能软件工程的在线课程,并开发开源库,如 Feature-engine,以简化机器学习解决方案的交付过程。

机器学习的特征工程

机器学习模型接收大量输入变量并输出预测结果。在金融和保险领域,我们建立模型来预测诸如贷款偿还的可能性、申请欺诈的概率,以及事故后车辆应当修理还是更换的可能性。我们收集的数据几乎从不适合直接用于训练机器学习模型或返回预测结果。相反,我们在将数据馈送给机器学习算法之前会对变量进行广泛的转换。我们将变量转换的集合称为特征工程

特征工程包括缺失数据的填充、分类变量的编码、数值变量的转换或离散化、将特征放置在相同的尺度上、将特征组合成新变量、从日期中提取信息、汇总交易数据,以及从时间序列、文本甚至图像中提取特征。每个特征工程步骤都有许多技术,您的选择将取决于变量的特征和您打算使用的算法。因此,当特征工程师在组织中构建和使用机器学习时,我们不再谈论单一的机器学习模型,而是谈论机器学习流水线,其中流水线的一大部分专注于特征工程和数据转换。

部署特征工程流水线的艰难任务

许多特征工程转换从数据中学习参数。我曾见过一些组织使用硬编码参数的配置文件。这些文件限制了灵活性,且难以维护(每次重新训练模型时,都需要用新的参数重新编写配置文件)。要创建性能高效的特征工程流水线,最好开发算法来自动学习和存储这些参数,而且还可以保存和加载,理想情况下作为一个对象。

在 Train in Data,我们在研究环境中开发机器学习流水线,并将其部署到生产环境中。这些流水线应该是可重复的。可重复性是准确复制一个机器学习模型的能力,这样,给定相同的数据作为输入,两个模型都会返回相同的输出。在研究和生产环境中利用相同的代码可以通过最小化需要重新编写的代码量,最大化可重复性来平滑地部署机器学习流水线。

特征工程转换需要进行测试。对每个特征工程过程进行单元测试,以确保算法返回所需的结果。在生产中进行广泛的代码重构以添加单元测试和集成测试非常耗时,并提供了引入错误的新机会,或者在研究阶段由于缺乏测试而引入的错误。为了最小化生产中的代码重构,最好在研究阶段开发工程算法时引入单元测试。

各项目中使用相同的特征工程转换。为了避免在拥有许多数据科学家的团队中经常发生的同一技术的不同代码实现,并增强团队绩效,加快模型开发速度,以及平滑模型操作化,我们希望重用先前构建和测试过的代码。最好的方法是创建内部包。创建包可能看起来很耗时,因为它涉及构建测试和文档。但从长远来看,这是更有效的,因为它允许我们逐步增强代码并添加新功能,同时重用已经开发和测试过的代码和功能。包开发可以通过版本控制来跟踪,甚至可以作为开源与社区共享,提升开发人员和组织的知名度。

利用开源 Python 库的力量

使用已建立的开源项目或彻底开发的内部库非常重要,原因如下:

  • 经过良好开发的项目往往有详尽的文档,因此清楚每段代码的目的是什么。

  • 已建立的开源包经过测试,以防止引入错误,确保转换达到预期的结果,并最大程度地实现可重复性。

  • 成熟的项目已被社区广泛采纳和认可,这使您放心代码质量。

  • 您可以在研究和生产环境中使用相同的包,最小化部署过程中的代码重构。

  • 包被明确版本化,因此您可以部署您在开发管道时使用的版本,以确保可重现性,而新版本则持续添加功能。

  • 开源包可以共享,因此不同的组织可以共同构建工具。

  • 虽然开源包由一组经验丰富的开发者维护,但社区也可以贡献,提供新的想法和功能,从而提升包和代码的质量。

  • 使用一个成熟的开源库减轻了我们编码的负担,提高了团队的表现、可重现性和协作能力,同时缩短了模型研究和部署的时间表。

scikit-learnCategory encoders,和Featuretools这样的开源 Python 库提供了特征工程的功能。为了扩展现有的功能并平滑创建和部署机器学习管道,我创建了开源 Python 包Feature-engine,提供了一系列详尽的特征工程过程,并支持不同转换实施到不同的特征空间。

特征工程平滑了特征工程管道的构建和部署。

特征工程算法需要自动从数据中学习参数,返回一个有利于在研究和生产环境中使用的数据格式,并包含详尽的变换,以促进在项目中的采纳。Feature-engine 的构思和设计就是为了满足所有这些要求。Feature-engine 的转换器—即实施特征工程转换的类—从数据中学习并存储参数。Feature-engine 的转换器返回适合于数据分析和可视化的 Pandas 数据框架,这在研究阶段非常有用。Feature-engine 支持在单个对象中创建和存储整个端到端的工程管道,使部署更加容易。为了促进在项目中的采纳,它包含了详尽的特征转换列表。

Feature-engine 包括许多处理缺失数据、编码分类变量、转换和离散化数值变量以及移除或屏蔽异常值的过程。每个转换器可以自动学习,或者明确指定它应该修改的变量组。因此,转换器可以接收整个数据框架,但仅会改变所选变量组,无需额外的转换器或手动操作来切片数据框架然后重新连接它们。

Feature-engine 的转换器使用 scikit-learn 的 fit/transform 方法,并扩展其功能以包括其他工程技术。 Fit/transform 功能使 Feature-engine 的转换器可以在 scikit-learn 管道中使用。 因此,使用 Feature-engine,我们可以将整个机器学习管道存储为一个单一对象,该对象可以保存和检索或放置在内存中进行实时评分。

协助新开源包的采纳

无论一个开源包有多好,如果没有人知道它的存在或者社区不能轻松理解如何使用它,它都不会成功。 制作一个成功的开源包意味着制作性能良好,经过充分测试,良好文档和有用的代码—然后让社区知道它的存在,鼓励用户采用并建议新功能,并吸引开发者社区增加更多功能,改进文档和提高代码质量来提高其性能。 对于包开发者来说,这意味着我们需要考虑开发代码的时间,设计和执行共享策略。 以下是我和其他包开发者的一些有效策略。

我们可以利用成熟的开源功能的力量来促进社区的采用。 Scikit-learn 是 Python 中机器学习的参考库。 因此,在新包中采用 scikit-learn 的 fit/transform 功能可以使社区轻松快速地采用。 使用该实现的用户对使用该包的学习曲线较短。 一些利用 fit/transform 的包包括Keras,Category encoders(也许是最著名的),当然还有 Feature-engine。

用户希望了解如何使用和分享该包,因此在代码存储库中包含声明这些条件的许可证。 用户还需要代码功能的说明和示例。 在代码文件的文档字符串中包含关于功能的信息和使用示例是一个良好的开始,但这还不足够。 广泛使用的包包括附加文档(可以使用 ReStructuredText 文件生成),其中包括代码功能的描述,使用示例和返回的输出,安装指南,包可用的渠道(PyPI,conda),如何入门,变更日志等。 良好的文档应该使用户能够在不阅读源代码的情况下使用库。 机器学习可视化库Yellowbrick的文档是一个很好的例子。 我也已经在 Feature-engine 中采用了这一做法。

如何提高软件包的可见性?我们如何接触潜在用户?开设在线课程可以帮助您接触更多人群,特别是在知名的在线学习平台上。此外,在Read the Docs上发布文档,创建 YouTube 教程,参加 meetup 和会议等活动都能增加可见度。在像 Stack Overflow、Stack Exchange 和 Quora 这样的成熟用户网络中,介绍软件包的功能并回答相关问题也很有帮助。Featuretools 和 Yellowbrick 的开发者已经利用了这些网络的力量。创建专用的 Stack Overflow 问题列表可以让用户提问,并显示软件包正在积极维护中。

开发、维护和鼓励对开源库的贡献

要使软件包成功且具有相关性,需要一个活跃的开发者社区。开发者社区由一个或者最好是一组专注的开发者或维护者组成,他们将关注整体功能、文档和发展方向。一个活跃的社区允许并欢迎额外的临时贡献者。

在开发软件包时需要考虑的一件事是代码的可维护性。代码越简单和短小,维护起来就越容易,这样吸引贡献者和维护者的可能性就越大。为了简化开发和维护工作,Feature-engine 利用了 scikit-learn 基础转换器的强大功能。Scikit-learn 提供了一个 API 和一堆基础类,开发者可以在此基础上构建新的转换器。此外,scikit-learn 的 API 提供了许多测试,以确保包与包之间的兼容性,以及转换器提供了预期功能。通过使用这些功能,Feature-engine 的开发者和维护者可以专注于特征工程功能,而基础代码的维护则由更大的 scikit-learn 社区负责。当然,这也有一个权衡。如果 scikit-learn 改变了其基础功能,我们需要更新我们的库以确保与最新版本兼容。其他使用 scikit-learn API 的开源软件包包括 Yellowbrick 和 Category encoders。

为了鼓励开发者合作,NumFOCUS 建议制定行为准则,并鼓励包容和多样性。项目需要开放,通常意味着代码应该是公开托管的,并且有指导新贡献者了解项目开发和讨论的指南,如邮件列表或 Slack 频道。虽然一些开源 Python 库有自己的行为准则,其他如 Yellowbrick 和 Feature-engine 则遵循 Python Community Code of Conduct。许多开源项目,包括 Feature-engine,在 GitHub 上公开托管。贡献指南列出了新贡献者可以帮助的方式,例如修复错误、添加新功能或增强文档。贡献指南还告知新开发者贡献周期、如何分叉存储库、如何在贡献分支上工作、代码审查周期如何工作以及如何发起 Pull Request。

合作可以通过提升代码质量或功能,添加新功能以及改进文档,提升库的质量和性能。贡献可以简单到报告文档中的拼写错误,报告未返回预期结果的代码,或请求新功能。在开源库上合作也有助于提升合作者的知名度,同时让他们接触新的工程和编码实践,提高他们的技能。

许多开发者和数据科学家认为他们需要成为顶尖开发者才能为开源项目做贡献。我曾经也这样认为,这让我不敢贡献或请求功能——尽管作为用户,我清楚地知道哪些功能是可用的,哪些是缺失的。这远非事实。任何用户都可以为库做贡献。而包维护者喜欢贡献。

对于 Feature-engine 的有用贡献包括简单的事情,比如在 .gitignore 中添加一行,通过 LinkedIn 发送消息提醒我文档中的拼写错误,提交 PR 以纠正拼写错误本身,突出显示由较新版本的 scikit-learn 引起的警告问题,请求新功能或扩展单元测试的电池。

如果你想贡献但没有经验,查看包仓库中的问题是很有用的。问题是一系列对代码有优先级的修改列表。它们被标记为“代码增强”,“新功能”,“错误修复”或“文档”。首先,最好处理标记为“good first issue”或“good for new contributors”的问题;这些问题往往是较小的代码更改,可以让你熟悉贡献周期。然后你可以着手更复杂的代码修改。通过解决一个简单的问题,你将学到很多关于软件开发,Git 和代码审查周期的知识。

Feature-engine 目前是一个功能简单的小型包,代码实现直接。它易于导航,并且依赖较少,因此是贡献到开源项目的良好起点。如果您想开始,请与我联系。我将非常乐意听到您的消息。祝您好运!

高性能数据科学团队

Linda Uruchurtu(Fiit)

数据科学团队与其他技术团队不同,因为他们的工作范围取决于他们所处的位置和他们解决的问题类型。然而,无论团队负责回答“为什么”和“如何”的问题,还是仅仅交付完全操作的机器学习服务,为了成功交付,他们需要确保利益相关者满意。

这可能是具有挑战性的。大多数数据科学项目都存在一定程度的不确定性,而且由于利益相关者的类型不同,“满意”可能意味着不同的事情。有些利益相关者可能只关心最终交付物,而其他人可能关心副作用或通用接口。此外,有些人可能不具备技术背景,对项目的具体细节理解有限。在这里,我将分享一些我学到的在项目执行和交付方式上产生差异的经验教训。

需要多长时间?

这可能是数据科学团队领导经常被问到的问题。想象一下:管理层要求项目经理(PM)或负责交付的人解决一个特定的问题。项目经理去找团队,向他们展示这些信息,并要求他们计划一个解决方案。接着来自项目经理或其他利益相关者的问题:需要多长时间?

首先,团队应该提出问题,以更好地定义他们解决方案的范围。这些问题可能包括以下内容:

  • 为什么这是一个问题?

  • 解决此问题的影响是什么?

  • “完成”的定义是什么?

  • 什么是满足定义的最小版本解决方案?

  • 是否有办法在早期验证解决方案?

注意,“需要多长时间?”不在这个列表上。

策略应该是双管齐下的。首先,设定一个限时的期间来提出这些问题并提出一个或多个解决方案。一旦达成一致的解决方案,项目经理应该向利益相关者解释,团队可以在计划了解决方案工作后提供一个时间表。

发现与规划

团队有一定的时间来提出解决方案。接下来呢?他们需要提出假设,进行探索性工作和快速原型设计,依次保留或放弃潜在的解决方案。

根据所选择的解决方案,其他团队可能成为利益相关者。开发团队可能会有来自其持有的 API 的需求,或者它们可能成为服务的消费者;产品、运营、客户服务和其他团队可能会使用可视化和报告。项目经理的团队应该与这些团队讨论他们的想法。

一旦此过程完成,团队通常能够确定每个选项所带来的不确定性和/或风险有多少。项目经理现在可以评估哪个选项更可取。

一旦选择了一个选项,项目经理可以为里程碑和交付定义一个时间表。这里提出的有用点是:

  • 交付物是否可以合理地审查和测试?

  • 如果工作依赖于其他团队,能否安排工作以避免引入延迟?

  • 团队能否从中间里程碑中提供价值?

  • 是否有办法减少项目中存在显著不确定性部分的风险?

根据计划导出的任务可以进行规模化和时间分配,以提供时间估计。留出额外的时间是个好主意:有些人喜欢将他们认为需要的时间翻倍或翻三倍!

一些任务经常被低估和简化,包括数据收集和数据集构建,测试和验证。为模型构建获取良好的数据通常比起初看起来更复杂和昂贵。一种选择可能是从小数据集开始进行原型设计,并推迟进一步的收集工作。测试也是基础,既用于正确性又用于可重复性。输入是否符合预期?处理管道是否引入错误?输出是否正确?单元测试和集成测试应成为每个努力的一部分。最后,验证很重要,特别是在现实世界中。确保为所有这些任务考虑现实的估计。

一旦完成了这些步骤,团队不仅对“时间”问题有了答案,还有了一个每个人都能用来理解正在进行的工作的里程碑计划。

管理期望和交付

在交付之前可能需要的时间会受到许多问题的影响。注意以下几点以确保管理团队的期望:

范围蔓延

范围蔓延是工作范围微妙的移动,以便期望比最初计划的工作更多。配对和审查可以帮助减轻这种情况。

低估非技术任务

讨论、用户研究、文档编写和许多其他任务往往会被那些不熟悉它们的人低估。

可用性

团队成员的安排和可用性也可能会引入延迟。

数据质量问题

确保工作数据集可用并发现偏见来源,数据质量可能会引入复杂性,甚至使工作无效。

可选方案

当出现意外困难时,考虑其他方法可能是有道理的。然而,沉没成本可能会阻止团队想要提出这一点,这可能会延迟工作,并且有可能给人一种团队不知所措的印象。

缺乏测试

数据输入的突然变化或数据管道中的错误可能会使假设无效。从一开始就有良好的测试覆盖率将提高团队的速度,并在最后产生回报。

测试或验证困难

如果没有足够的时间进行测试和验证假设,计划可能会延迟。假设变更也可能导致测试计划的更改。

使用每周的完善和计划会议来发现问题,并讨论是否需要添加或移除任务。这将为项目经理提供足够的信息来更新最终的利益相关者。优先级也应该以相同的频率进行。如果有机会提前完成某些任务,应该抓住这些机会。

中间交付成果,特别是如果它们在项目范围之外提供价值,将不断证明工作的正当性。这对团队和利益相关者都是有利的,可以保持关注和士气,并让利益相关者感受到进展。持续重新绘制游戏计划、审查和调整迭代过程将确保团队有清晰的方向感和工作自由,同时提供足够的信息和价值,以保持利益相关者继续支持项目。

要在处理新项目时表现出色,你的数据科学团队主要应集中精力降低数据不确定性和业务需求不确定性的风险,通过交付轻量化的最小可行产品(MVP)解决方案(比如脚本和 Python 笔记本)。最初构想的 MVP 可能实际上比首个概念更精简或不同,根据随后的发现或业务需求变化。只有经过验证后,你才应继续推进到可投入生产的版本。

发现和规划过程至关重要,迭代思维同样重要。记住发现阶段始终在进行中,外部事件将始终影响计划。

Numba

Valentin Haenel (http://haenel.co)

Numba 是一个针对数值计算的 Python 的开源 JIT(即时编译)函数编译器。最初在 2012 年由 Continuum Analytics(现在的 Anaconda Inc)创建,它已经发展成为一个成熟的开源项目,在 GitHub 上拥有大量和多样化的贡献者。其主要用例是加速数值和/或科学 Python 代码。主要的入口点是装饰器——@jit装饰器,用于注释特定的函数,理想情况下是应用程序的瓶颈,这些函数将被即时编译,这意味着函数将在第一次或初始执行时编译。所有后续具有相同参数类型的执行将使用函数的编译变体,这应该比原始函数更快。

Numba 不仅可以编译 Python,还能识别 NumPy,并处理使用 NumPy 的代码。在底层,Numba 依赖于著名的LLVM 项目,这是一组模块化和可重用的编译器及工具链技术。此外,Numba 并非完整的 Python 编译器。它只能编译 Python 和 NumPy 的子集,尽管这个子集足够大,可以在广泛的应用中发挥作用。更多信息,请参阅文档

一个简单的例子

作为一个简单的例子,让我们使用 Numba 来加速一个 Python 实现的古老算法,用于找出所有不超过给定最大值(N)的素数:厄拉托斯特尼筛法。它的工作原理如下:

  • 首先,初始化一个长度为N的布尔数组,所有值都为 true。

  • 然后从第一个素数 2 开始,划掉(将布尔列表中相应位置设置为 false)所有不超过N的数字的倍数。

  • 继续处理下一个尚未划掉的数字,即 3,在这种情况下再次划掉所有它的倍数。

  • 继续处理数字并划掉它们的倍数,直到达到N

  • 当你达到N时,所有未被划掉的数字就是N以内的素数集合。

一个相对高效的 Python 实现可能看起来像这样:

import numpy as np
from numba import jit

@jit(nopython=True)  # simply add the jit decorator
def primes(N=100000):
    numbers = np.ones(N, dtype=np.uint8)  # initialize the boolean array
    for i in range(2, N):
        if numbers[i] == 0:  # has previously been crossed off
            continue
        else:  # it is a prime, cross off all multiples
            x = i + i
            while x < N:
                numbers[x] = 0
                x += i
    # return all primes, as indicated by all boolean positions that are one
    return np.nonzero(numbers)[0][2:]

将此放入名为sieve.py的文件中后,您可以使用%timeit魔法来对代码进行微基准测试:

In [1]: from sieve import primes

In [2]: primes()  # run it once to make sure it is compiled
Out[2]: array([    2,     3,     5, ..., 99971, 99989, 99991])

In [3]: %timeit primes.py_func()  # 'py_func' contains
                                  # the original Python implementation
145 ms ± 1.86 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [4]: %timeit primes()  # this benchmarks the Numba compiled version
340 µs ± 3.98 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

这使速度提高了大约四百倍;实际效果可能有所不同。尽管如此,这里有一些值得注意的事项:

  • 编译发生在函数级别。

  • 简单地添加装饰器@jit就足以指示 Numba 编译函数。不需要对函数源代码进行其他修改,例如变量的类型注解。

  • Numba 识别 NumPy,因此此实现中的所有 NumPy 调用都得到支持,并且可以成功编译。

  • 原始的、纯 Python 函数在编译函数中作为py_func属性可用。

这里有一个更快但不够教育的算法版本,其实现留给感兴趣的读者。

最佳实践和建议

对于 Numba 最重要的建议之一是尽可能使用 nopython 模式。要激活此模式,只需在@jit装饰器中使用nopython=True选项,就像素数示例中所示的那样。或者,您可以使用@njit装饰器别名,通过from numba import njit访问。在 nopython 模式下,Numba 尝试进行大量优化,可以显著提高性能。但是,此模式非常严格;为了成功编译,Numba 需要能够推断函数内所有变量的类型。

你也可以通过 @jit(forceobj=True) 使用对象模式。在这种模式下,Numba 变得非常宽容,可以编译的内容非常有限,这将显著地对性能产生负面影响。为了充分利用 Numba 的潜力,你应该真正使用 nopython 模式。

如果你不能决定是否要使用对象模式,可以选择使用对象模式块。当只有你的代码中的一小部分需要在对象模式下执行时,这将非常方便:例如,如果你有一个长时间运行的循环,并且希望使用字符串格式化来打印程序的当前进度。例如:

from numba import njit, objmode

@njit()
def foo():
    for i in range(1000000):
        # do compute
        if i % 100000 == 0:
            with objmode:  # to escape to object-mode
                           # using 'format' is permissible here
                print("epoch: {}".format(i))

foo()

注意你使用的变量类型。Numba 非常适用于 NumPy 数组和其他数据类型的 NumPy 视图。因此,如果可以的话,应该将 NumPy 数组作为首选数据结构。元组、字符串、枚举和简单的标量类型如整数、浮点数和布尔值也得到了合理的支持。全局变量对于常量是可以的,但是将其余数据作为函数参数传递。Python 列表和字典不幸地支持得不太好。这在很大程度上源于它们可能是类型异构的:特定的 Python 列表可能包含不同类型的项;例如,整数、浮点数和字符串。这对 Numba 来说是个问题,因为它需要容器只包含单一类型的项才能编译它。然而,这两种数据结构可能是 Python 语言中最常用的特性之一,甚至是程序员最早学习的内容之一。

要弥补这个缺点,Numba 支持所谓的类型化容器:typed-listtyped-dict。这些是 Python 列表和字典的同类型变体。这意味着它们只能包含单一类型的项:例如,只包含整数值的 typed-list。除了这个限制,它们的行为与其 Python 对应物基本相同,并支持大部分相同的 API。此外,它们可以在常规 Python 代码中或在 Numba 编译的函数中使用,并且可以传递给和从 Numba 编译的函数中返回。这些功能来自于 numba.typed 子模块。这里是一个 typed-list 的简单示例:

from numba import njit
from numba.typed import List

@njit
def foo(x):
    """ Copy x, append 11 to the result. """
    result = x.copy()
    result.append(11)
    return result

a = List() # Create a new typed-list
for i in (2, 3, 5, 7):
    # Add the content to the typed-list,
    # the type is inferred from the first item added.
    a.append(i)
b = foo(a) # make the call, append 11; this list will go to eleven

尽管 Python 有其局限性,但你可以重新思考它们,并理解在使用 Numba 时哪些可以安全地忽略。有两个具体的例子值得一提:调用函数和 for 循环。Numba 在底层 LLVM 库中启用了称为 内联 的技术,以优化调用函数的开销。这意味着在编译过程中,任何可进行内联的函数调用都将被替换为等效于被调用函数的代码块。因此,将一个大函数分解为几个或多个小函数以增强可读性和可理解性,几乎不会对性能产生影响。

Python 的一个主要批评是其for循环速度较慢。许多人建议在试图改善 Python 程序性能时使用替代构造:列表推导或甚至 NumPy 数组。Numba 不受此限制,并且在 Numba 编译函数中使用for循环是可以的。观察:

from numba import njit

@njit
def numpy_func(a):
    # uses Numba's implementation of NumPy's sum, will also be fast in
    # Python
    return a.sum()

@njit
def for_loop(a):
    # uses a simple for-loop over the array
    acc = 0
    for i in a:
        acc += i
    return acc

现在我们可以对上述代码进行基准测试了:

In [1]: ... # import the above functions

In [2]: import numpy as np

In [3]: a = np.arange(1000000, dtype=np.int64)

In [4]: numpy_func(a)  # sanity check and compile
Out[4]: 499999500000

In [5]: for_loop(a)  # sanity check and compile
Out[5]: 499999500000

In [6]: %timeit numpy_func(a)  # Compiled version of the NumPy func
174 µs ± 3.05 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

In [7]: %timeit for_loop(a)    # Compiled version of the for-loop
186 µs ± 7.59 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

In [8]: %timeit numpy_func.py_func(a)  # Pure NumPy func
336 µs ± 6.72 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

In [9]: %timeit for_loop.py_func(a)    # Pure Python for-loop
156 ms ± 3.07 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

正如您所见,两个 Numba 编译的变体具有非常相似的性能特征,而纯 Python 的for循环实现比其编译版本慢得多(慢了 800 倍)。

如果您现在正在考虑将您的 NumPy 数组表达式重写为for循环,请不要这样做!正如前面的例子所示,Numba 完全支持 NumPy 数组及其相关函数。事实上,Numba 还有一个额外的优化技术,称为循环融合。Numba 主要在数组表达式操作上执行这种技术。例如:

from numba import njit

@njit
def loop_fused(a, b):
    return a * b - 4.1 * a > 2.5 * b

In [1]: ... # import the example

In [2]: import numpy as np

In [3]: a, b = np.arange(1e6), np.arange(1e6)

In [4]: loop_fused(a, b)  # compile the function
Out[4]: array([False, False, False, ...,  True,  True,  True])

In [5]: %timeit loop_fused(a, b)
643 µs ± 18 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

In [6]: %timeit loop_fused.py_func(a, b)
5.2 ms ± 205 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

正如您所见,Numba 编译版本比纯 NumPy 版本快 8 倍。到底发生了什么?没有 Numba,数组表达式将导致多个for循环和所谓的临时内存。泛泛地说,对于表达式中的每个算术操作,都必须执行一次数组的for循环,并且每次的结果必须存储在内存中的临时数组中。循环融合的作用是将各种算术操作的循环合并成一个单独的循环,从而减少总体内存查找次数和计算结果所需的总内存。实际上,循环融合的变体可能看起来像这样:

import numpy as np
from numba import njit

@njit
def manual_loop_fused(a, b):
    N = len(a)
    result = np.empty(N, dtype=np.bool_)
    for i in range(N):
        a_i, b_i = a[i], b[i]
        result[i] = a_i * b_i - 4.1 * a_i > 2.5 * b_i
    return result

运行此代码将显示类似于循环融合示例的性能特征:

In [1]: %timeit manual_loop_fused(a, b)
636 µs ± 49.1 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

最后,我建议最初专注于串行执行,但要牢记并行执行的可能性。不要从一开始就假设只有并行版本才能达到目标性能特征。相反,专注于首先开发清晰的串行实现。并行化使得所有事情更难推理,并且在调试问题时可能会成为困难的源头。如果您对结果满意,仍然想调查并行化您的代码,Numba 确实带有parallel=True选项用于@jit装饰器以及相应的并行范围,prange结构,使得创建并行循环更容易。

获取帮助

截至 2020 年初,Numba 的两个主要推荐沟通渠道是GitHub 问题跟踪器Gitter 聊天室;这是活动发生的地方。还有一个邮件列表和一个 Twitter 账户,但这些活动较少,主要用于宣布新发布和其他重要项目新闻。

优化与思考

Vincent D. Warmerdam,GoDataDriven 的高级人员(http://koaning.io

这是一个团队解决错误问题的故事。我们在优化效率的同时忽略了效果。我希望这个故事能成为其他人的警示。这个故事实际上发生了,但我已经改变了部分内容,并且为了保持匿名性保留了细节。

我曾为一家面临常见物流问题的客户提供咨询:他们想预测将会抵达他们仓库的卡车数量。这是一个很好的商业案例。如果我们知道车辆的数量,我们就能知道需要多大的工作人员来处理当天的工作量。

规划部门多年来一直在尝试解决这个问题(使用 Excel)。他们对算法能否改善事物持怀疑态度。我们的工作是探索机器学习是否能在这里提供帮助。

从一开始就显而易见这是一个困难的时间序列问题:

  • 由于仓库是国际运营的,我们需要记住许多(真的很多!)假期。假期的影响可能取决于一周中的某一天,因为仓库周末不开放。某些假期意味着需求会增加,而其他假期意味着仓库关闭(有时会导致三天的周末)。

  • 季节性变化并不罕见。

  • 供应商经常进入和退出市场。

  • 由于市场不断变化,季节性模式始终在变化。

  • 有许多仓库,虽然它们位于不同的建筑物中,但有理由相信抵达不同仓库的卡车数量是相关的。

图 12-1 中的图示了算法计算季节效应和长期趋势的过程。只要没有假期,我们的方法就可以运行。规划部门警告我们这一点;假期是难点。在花费了大量时间收集相关特征之后,我们最终建立了一个主要专注于尝试应对假期的系统。

hpp2 1201

图 12-1. 季节效应和长期趋势

所以我们进行了迭代,进行了更多的特征工程,并设计了算法。我们到了需要为每个仓库计算一个时间序列模型的程度,这个模型将通过每个假期每天的启发式模型进行后处理。假期在周末前会引起不同的变化,而在周末后的假期则会引起另一种变化。可以想象,当你想要进行网格搜索时,这个计算变得非常昂贵,正如在 图 12-2 中所示。

hpp2 1202

图 12-2. 许多变体消耗了大量计算时间

我们必须准确估算许多影响因素,包括过去测量数据的衰减、季节效应的平滑程度、正则化参数以及如何处理不同仓库之间的相关性。

预测未来几个月的需要并不是一件容易的事情。另一个困难是成本函数:它是离散的。规划部门并不关心(甚至不欣赏)均方误差,他们只关心预测误差超过 100 辆卡车的天数。

除了统计问题,您可以想象模型还引发了性能问题。为了避免这种情况,我们限制使用更简单的机器学习模型。通过这样做,我们大大提高了迭代速度,这使我们能够专注于特征工程。几周后,我们得到了一个可以展示的版本。尽管如此,除了假期之外,我们仍然制作了一个表现良好的模型。

模型进入了概念验证阶段;它表现得相当不错,但并没有比当前规划团队的方法显著更好。这个模型很有用,因为它允许规划部门反思模型的意见,但没有人愿意让模型自动化规划。

然后发生了。这是我与客户的最后一周,就在同事接替我的前一刻。我和一位分析师在咖啡角闲聊,讨论我需要他提供数据的另一个项目。我们开始审查数据库中可用的表格。最后,他告诉我一个“卡车”表(如图 12-3 所示)。

我:“‘卡车’表?里面有什么?” 分析师:“哦,它包含所有卡车的订单。” 我:“供应商从仓库购买它们?” 分析师:“不,实际上是租赁的。他们通常在返回填充货物的几天前租赁它们三到五天。” 我:“所有的供应商都是这样操作的?” 分析师:“差不多。”

hpp2 1203

图 12-3. 卡车包含了这个挑战至关重要的主要信息!

我发现了所有问题中最显著的性能问题:我们解决的是错误的问题。这不是一个机器学习问题,而是一个 SQL 问题。租用的卡车数量是公司将派出多少辆卡车的一个强有力的代理。他们不需要一个机器学习模型。我们可以预测未来几天租用的卡车数量,除以一个卡车能容纳的卡车数量,从而得出一个合理的预测。如果我们早些意识到这一点,我们就不需要为一个巨大的网格搜索优化代码了,因为根本没有这种需要。

将业务案例转化为不反映现实的分析问题相对直接。任何可以防止这种情况发生的措施都将产生你无法想象的最显著的性能优势。

Adaptive Lab 的社交媒体分析(2014)

Ben Jackson (adaptivelab.com)

Adaptive Lab 是一家位于伦敦科技城区域 Shoreditch 的产品开发和创新公司。我们采用精益、用户中心的产品设计和交付方法,与从初创公司到大型企业的广泛合作伙伴共同合作。

YouGov 是一家全球市场研究公司,其宣称的目标是提供持续、准确的数据和洞察力,了解全球人们的思想和行为,而我们正是为其实现了这一目标。Adaptive Lab 设计了一种 passively 监听社交媒体上实时讨论并获取用户对各种主题感受的方法。我们构建了一个可扩展的系统,能够捕获大量流数据,实时处理、长期存储,并通过强大、可过滤的界面实时展示。该系统基于 Python 构建。

Python 在 Adaptive Lab 的应用

Python 是我们的核心技术之一。我们在性能关键的应用程序中使用它,并在与具备内部 Python 技能的客户合作时也同样如此,以便他们能够内部化我们为他们提供的工作。

Python 非常适合于小型、独立的长期运行守护进程,也同样适合灵活、功能丰富的 Web 框架,如 Django 和 Pyramid。Python 社区蓬勃发展,这意味着有大量的开源工具库可供使用,让我们能够快速、自信地构建项目,专注于创新和解决用户问题。

在所有项目中,Adaptive Lab 重复使用几个基于 Python 构建的工具,这些工具可以以与语言无关的方式使用。例如,我们使用 SaltStack 进行服务器配置和 Mozilla 的 Circus 管理长期运行的进程。当一个工具是开源的,并且使用我们熟悉的语言编写时,如果我们遇到任何问题,我们可以自行解决,并将解决方案推广,从而造福社区。

SoMA 的设计

我们的社交媒体分析(SoMA)工具需要处理大量社交媒体数据,并实时存储和检索大量信息。在研究了各种数据存储和搜索引擎后,我们选择了 Elasticsearch 作为我们的实时文档存储。正如其名,它具有高可扩展性,同时易于使用,能够提供统计响应和搜索功能,非常适合我们的应用场景。Elasticsearch 本身基于 Java 构建,但像现代系统中设计良好的任何组件一样,它具有良好的 API,并且有 Python 库和教程支持。

我们设计的系统使用 Celery 在 Redis 中的队列,快速将大量数据流交给任意数量的服务器进行独立处理和索引。整个复杂系统的每个组件都设计为小型、简单且能够独立工作。每个组件专注于一个任务,例如分析对话的情感或为索引到 Elasticsearch 准备文档。其中一些配置为使用 Mozilla 的 Circus 作为守护进程运行,它保持所有进程运行并允许根据单个服务器的需要进行扩展或缩减。

SaltStack 用于定义和配置复杂的集群,并处理所有库、语言、数据库和文档存储的设置。我们还使用了 Fabric,这是一个用于在命令行上运行任意任务的 Python 工具。在代码中定义服务器有很多好处:完全与生产环境一致;配置的版本控制;所有内容都在一个地方。它还作为集群设置和依赖项的文档。

我们的开发方法论

我们的目标是尽可能简化新加入项目的新人快速投入添加代码和自信部署的过程。我们使用 Vagrant 在本地构建系统的复杂性,放在一个完全与生产环境一致的虚拟机中。一个简单的 vagrant up 就是新人启动所需的所有依赖项的全部。

我们以敏捷方式工作,共同规划,讨论架构决策,并就任务估算达成共识。对于 SoMA,我们决定每个迭代都包括至少几个被视为技术债务修正的任务。还包括为系统编写文档的任务(我们最终建立了一个维基,用于存放这个不断扩展的项目的所有知识)。团队成员在每个任务之后互相审查代码,进行合理性检查、提供反馈,并理解即将添加到系统中的新代码。

一个良好的测试套件有助于增强信心,确保任何更改不会导致现有功能失败。在像 SoMA 这样由许多组件组成的系统中,集成测试至关重要。一个分级环境提供了测试新代码性能的方式;特别是在 SoMA 中,只有通过针对生产环境中看到的大数据集进行测试,才能发现问题并加以解决,因此通常需要在一个单独的环境中复制该数据量。亚马逊的弹性计算云(EC2)为我们提供了这种灵活性。

维护 SoMA

SoMA 系统持续运行,每天消耗的信息量不断增加。我们必须考虑数据流高峰、网络问题以及任何第三方服务提供商可能存在的问题。因此,为了简化操作,SoMA 被设计成可以自我修复。借助 Circus,崩溃的进程将重新启动并从上次停止的地方继续任务。任务将排队等待进程消耗,系统在恢复期间有足够的时间堆积任务。

我们使用 Server Density 来监控多台 SoMA 服务器。设置非常简单,但功能强大。一旦可能发生问题,指定工程师可以通过手机即时接收推送消息,以便及时反应,确保问题不会扩大。使用 Server Density,还可以非常轻松地用 Python 编写自定义插件,例如设置 Elasticsearch 行为的即时警报。

同行工程师的建议

最重要的是,您和您的团队需要确信和放心,即将部署到实时环境中的内容将完美无缺地运行。为了达到这一点,您必须向后推进,花时间处理系统的所有组件,这些组件将让您感到放心。简化并确保部署无误;使用分段环境测试具有真实数据的性能;确保您拥有一个覆盖率高的良好且稳固的测试套件;实施将新代码整合到系统中的流程;并确保尽早解决技术债务。您加固技术基础并改进流程的越多,您的团队在工程问题上找到正确解决方案的成功和满意程度就越高。

如果没有坚实的代码和生态系统基础,但业务要求您立即上线,这只会导致问题软件。您有责任推迟时间,逐步改进代码、测试和操作,以便顺利完成上线工作。

让深度学习飞起来(2014)

Radim Řehůřek(radimrehurek.com

当 Ian 要求我在这本书上写关于 Python 和优化的“现场经验”时,我立刻想到:“告诉他们如何比 Google 的 C 原版更快地编写 Python 移植版!”这是一个鼓舞人心的故事,讲述了如何使一个机器学习算法,Google 深度学习的招牌案例,比朴素的 Python 实现快 12,000 倍。任何人都可以写出糟糕的代码,然后大肆宣扬巨大的加速。但优化后的 Python 移植版本,令人惊讶地运行速度几乎是 Google 团队原始代码的四倍快!也就是说,比 Google 团队编写的不透明、严密优化的 C 代码快了四倍。

但在得出“机器级”优化经验之前,先谈一些关于“人类级”优化的通用建议。

黄金时机

我经营一家专注于机器学习的小型咨询公司,我和我的同事帮助公司理清数据分析的混乱世界,以赚钱或节省成本(或两者兼有)。我们帮助客户设计和构建用于数据处理的奇妙系统,特别是文本数据。

客户群体涵盖了从大型跨国公司到新兴初创企业,尽管每个项目都不同且需要不同的技术堆栈,但插入客户现有的数据流和管道时,Python 显然是首选。不是向信徒宣教,但 Python 的务实开发理念、其可塑性以及丰富的库生态使其成为理想的选择。

首先,关于有效的几点看法:“实地”经验告诉我们:

沟通,沟通,沟通

这一点显而易见,但值得重复。在决定方法之前,先在更高(商业)层面上了解客户的问题。坐下来讨论他们认为自己需要什么(基于他们对可能发生的事情部分了解和/或在联系你之前通过谷歌搜索到的信息),直到清楚他们真正需要什么,摆脱多余的东西和成见。同意在之前验证解决方案的方法。我喜欢将这个过程形象化为一条漫长曲折的道路:确保起点正确(问题定义、可用数据源),终点正确(评估、解决方案优先级),中间的路径就自然而然地展开。

保持对有前景的技术保持警惕

一种新兴技术,被合理理解且稳健,正在逐渐流行,但在行业中仍相对较为陌生,能为客户(或你自己)带来巨大价值。例如,几年前,Elasticsearch 是一个鲜为人知且有些粗糙的开源项目。但我认为它的方法可靠(建立在 Apache Lucene 之上,提供复制、集群分片等功能),并建议客户使用。我们随后构建了以 Elasticsearch 为核心的搜索系统,与考虑的替代方案(大型商业数据库)相比,为客户节省了大量的授权、开发和维护成本。更重要的是,使用这种新的、灵活且强大的技术为产品赋予了巨大的竞争优势。如今,Elasticsearch 已进入企业市场,不再具有竞争优势—每个人都知道它并使用它。抓住时机,达到我所说的“黄金时机”,最大化价值/成本比。

KISS(保持简单,愚蠢!)

这是另一个不需要考虑的问题。最好的代码是你不必编写和维护的代码。从简单开始,并在必要时改进和迭代。我更喜欢遵循 Unix 哲学的工具,“做一件事,并做好它”。大型编程框架可能很诱人,几乎包含了一切,并且整洁地组合在一起。但不可避免地,迟早会需要一些大型框架没有考虑到的东西,然后即使是看似简单的修改(在概念上)也会在程序上演变成噩梦。大型项目及其包罗万象的 API 往往会因自身臃肿而崩溃。使用模块化的、专注的工具,并尽可能使用小而简单的 API 之间的接口。除非性能要求不允许,否则请优先选择可以简单视觉检查的文本格式。

使用数据管道中的手动健全性检查

当优化数据处理系统时,很容易陷入“二进制思维”模式,使用紧凑的管道、高效的二进制数据格式和压缩的 I/O。随着数据在系统中通过,未经检查(除了可能的类型),它仍然是看不见的,直到出现明显问题。然后开始调试。我建议在代码中的各个内部处理点上撒些简单的日志消息,显示数据在不同内部点的样子,这是一种良好的实践——什么都不花哨,只是类似 Unix 的head命令,选择和可视化几个数据点。这不仅有助于前面提到的调试,而且在一切看似顺利的情况下,以人类可读的格式查看数据时,常常会有“啊哈!”的时刻。奇怪的标记化!他们承诺输入始终以 latin1 编码!图像文件泄漏到期望和解析文本文件的管道中!这些通常是超出自动类型检查或固定单元测试所能提供的洞察,暗示着超出组件边界的问题。现实世界的数据是混乱的。早点捕捉即使不会导致异常或显著错误的事情,也是个好主意。在冗长方面保持谨慎。

谨慎地应对时尚潮流

仅仅因为客户一直听说 X 并说他们也必须要 X,并不意味着他们真的需要。这可能是一个市场问题而不是技术问题,因此要小心分辨二者,并相应地交付。X 随着时间的推移会发生变化,随着炒作浪潮的来来去去;最近的价值会是 X = 大数据。

行了,足够商业化了——这是我如何让 Python 中的word2vec比 C 运行得更快的方法。

优化的教训

word2vec 是一种深度学习算法,允许检测相似的词语和短语。在文本分析和搜索引擎优化(SEO)中有着有趣的应用,并且附有 Google 光辉的品牌名称,吸引了初创公司和企业纷纷利用这一新工具。

不幸的是,唯一可用的代码是由 Google 自己生成的,这是一个用 C 语言编写的开源 Linux 命令行工具。这是一个经过优化但相当难以使用的实现。我决定将word2vec移植到 Python 的主要原因是为了能够将word2vec扩展到其他平台,使其更易于集成和扩展以供客户使用。

这里不涉及细节,但word2vec需要一个训练阶段,使用大量输入数据来生成一个有用的相似性模型。例如,谷歌的人员在他们的 GoogleNews 数据集上运行了word2vec,大约训练了 1000 亿字。显然,这种规模的数据集不适合 RAM,因此必须采取一种内存高效的方法。

我撰写了一个机器学习库,gensim,正好解决这种内存优化问题:数据集不再是微不足道的(“微不足道”是指完全适合 RAM 的任何东西),但也不大到需要 PB 级 MapReduce 计算机集群。这种“TB”级问题适用于令人惊讶地大部分真实案例,word2vec也包括在内。

细节在我的博客中有描述,但这里有一些优化的要点:

流式处理数据,注意内存使用

让您的输入逐个数据点访问和处理,以获得小而恒定的内存占用。流式数据点(在word2vec中为句子)可以在内部分组成更大的批次以提高性能(例如一次处理 100 个句子),但高级别的流式 API 证明是一个强大且灵活的抽象。Python 语言非常自然和优雅地支持这种模式,借助其内置的生成器——这是一个真正美丽的问题技术匹配。除非您知道数据始终保持较小,或者您不介意以后自己重新实现生产版本,否则应避免依赖于将所有内容加载到 RAM 的算法和工具。

充分利用 Python 丰富的生态系统

我从一个可读性强、干净的numpy端口开始。numpy在本书的第六章中有详细介绍,但简要提醒一下,它是一个了不起的库,是 Python 科学社区的基石,也是 Python 中数字计算的事实标准。利用numpy强大的数组接口、内存访问模式以及包装的 BLAS 例程进行超快速的常见向量操作,可以编写简洁、干净和快速的代码——这种代码比天真的 Python 代码快上数百倍。通常在这一点上我会收工,但是“数百倍快”仍然比谷歌优化的 C 版本慢 20 倍,因此我继续努力。

分析和编译热点代码

word2vec 是一个典型的高性能计算应用程序,其中一个内部循环中几行代码占整个训练运行时间的 90%。在这里,我用外部 Python 库 Cython 重写了一个单核心例程(大约 20 行代码),作为粘合剂。虽然从技术上讲它很出色,但我认为概念上 Cython 并不是一个特别方便的工具——它基本上就像学习另一种语言,一个不直观的 Python、numpy 和 C 的混合体,有它自己的注意事项和特殊性。但在 Python 的 JIT 技术成熟之前,Cython 可能是我们的最佳选择。通过 Cython 编译的热点,Python word2vec 的性能现在与原始 C 代码相当。从一个干净的 numpy 版本开始的额外优势是,通过与较慢但正确的版本进行比较,我们可以获得免费的正确性测试。

了解你的 BLAS

numpy 的一个巧妙特性是它在内部包装了基本线性代数子程序(BLAS),如果可用的话。这些是由处理器供应商(Intel、AMD 等)直接优化的低级例程,使用汇编、Fortran 或 C 设计,旨在从特定处理器架构中挤出最大性能。例如,调用 axpy BLAS 例程计算 vector_y += scalar * vector_x 比一个等效的显式 for 循环产生的代码快得多。将 word2vec 训练表达为 BLAS 操作导致另外 4 倍速度提升,超过了 C word2vec 的性能。胜利!公平地说,C 代码也可以链接到 BLAS,所以这并不是 Python 本质上的某种固有优势。numpy 只是让这类事情显现并且易于利用。

并行化和多核

gensim 包含了几种算法的分布式集群实现。对于 word2vec,我选择在单台机器上进行多线程处理,因为其训练算法的精细化特性。使用线程还允许我们避免 Python 的 multiprocessing 带来的 fork-without-exec POSIX 问题,特别是与某些 BLAS 库结合使用时。因为我们的核心例程已经在 Cython 中,我们可以释放 Python 的 GIL(全局解释器锁;参见“使用 OpenMP 在一台机器上并行化解决方案”),通常情况下对于 CPU 密集型任务来说,多线程是无用的。加速:在四核机器上再次提升 3 倍。

静态内存分配

在这一点上,我们每秒处理数以万计的句子。训练速度非常快,以至于甚至像创建一个新的 numpy 数组(为每个流式句子调用 malloc)这样的小事情也会拖慢我们的速度。解决方案:预先分配一个静态的“工作”内存并在代码中传递,像 Fortran 一样。让我眼泪汪汪。这里的教训是尽可能将尽可能多的簿记和应用逻辑保持在干净的 Python 代码中,并使优化的热点保持简洁高效。

问题特定优化

原始的 C 实现包含特定的微优化,比如将数组对齐到特定的内存边界或将某些函数预先计算到内存查找表中。这是对过去的怀旧,但是在今天复杂的 CPU 指令流水线、内存高速缓存层次结构和协处理器中,这样的优化已经不再是明显的赢家。仔细的分析表明,有几个百分点的改进,可能并不值得额外的代码复杂性。教训:使用注解和分析工具突出显示优化不佳的地方。利用您的领域知识引入算法近似,以在精度和性能之间进行权衡(或反之)。但千万不要凭信仰;尽量使用真实的生产数据进行分析。

结论

在适当的地方进行优化。根据我的经验,从来没有足够的沟通来完全确定问题范围、优先级和与客户业务目标的联系——即“人为级别”的优化。确保您解决的是一个重要问题,而不是为了“极客”的事情而迷失方向。当您卷起袖子时,确保它是值得的!

在 Lyst.com 上进行大规模生产机器学习(2014 年)

Sebastjan Trepca(lyst.com

自从网站创建以来,Python 和 Django 一直是 Lyst 的核心。随着内部项目的发展,一些 Python 组件已被其他工具和语言替换,以适应系统日益成熟的需求。

集群设计

集群在 Amazon EC2 上运行。总共有大约 100 台机器,其中包括最近的 C3 实例,其 CPU 性能良好。

Redis 用于使用 PyRes 进行排队和存储元数据。主要数据格式为 JSON,以便人类理解。

Elasticsearch 和 PyES 用于索引所有产品。Elasticsearch 集群在七台机器上存储了 6000 万个文档。已调查 Solr,但由于其缺乏实时更新功能而被排除在外。

在快速发展的初创公司中的代码演进

最好编写可以快速实现的代码,以便测试业务理念,而不是花费很长时间试图在第一次尝试中编写“完美的代码”。如果代码有用,它可以重构;如果代码背后的理念不好,删除它并移除一个功能是很廉价的。这可能会导致一个复杂的代码库,其中传递了许多对象,但只要团队有时间重构对业务有用的代码,这是可以接受的。

在 Lyst 中大量使用文档字符串——尝试使用外部 Sphinx 文档系统,但因为只需阅读代码而放弃了。维基用于记录流程和更大的系统。我们还开始创建非常小的服务,而不是将所有内容都放入一个代码库。

构建推荐引擎

最初推荐引擎是用 Python 编写的,使用 numpyscipy 进行计算。随后,推荐器的性能关键部分使用 Cython 加速。核心矩阵因子分解操作完全使用 Cython 编写,速度提升一个数量级。这主要是由于在 Python 中能够编写高性能的 numpy 数组循环,这在纯 Python 中非常慢,并且在向量化时性能表现不佳,因为它需要对 numpy 数组进行内存复制。罪魁祸首是 numpy 的花式索引,它总是会对被切片的数组进行数据复制:如果不需要或不打算进行数据复制,Cython 循环将会快得多。

随着时间的推移,系统的在线组件(负责在请求时计算推荐)已经集成到我们的搜索组件 Elasticsearch 中。在这个过程中,它们被转换为 Java,以便与 Elasticsearch 完全集成。这样做的主要原因不是性能,而是将推荐器与搜索引擎的全部功能结合起来,更容易地应用业务规则来提供推荐。Java 组件本身非常简单,主要实现了高效的稀疏向量内积。更复杂的离线组件仍然使用 Python 编写,使用了 Python 科学栈的标准组件(主要是 Python 和 Cython)。

根据我们的经验,Python 不仅仅是原型设计语言:诸如 numpy、Cython 和 weave(最近还有 Numba)等工具的可用性,使我们能够在代码的性能关键部分实现非常好的性能,同时保持 Python 的清晰和表达力,在低级别优化可能适得其反的情况下尤为如此。

报告和监控

Graphite 用于报告。目前,部署后可以通过肉眼看到性能回归。这使得可以轻松地深入详细的事件报告,或者放大以查看站点行为的高级报告,根据需要添加和删除事件。

在内部,正在设计一个更大的基础设施用于性能测试。它将包括代表性数据和用例,以适当地测试站点的新版本构建。

还将使用一个暂存站点,让少数真实访问者看到最新版本的部署——如果发现错误或性能回归,将只会影响少数访问者,并且可以快速撤销该版本。这将大大减少错误部署的成本和问题。

Sentry 用于记录和诊断 Python 的堆栈跟踪。

Jenkins 用于带有内存数据库配置的持续集成(CI)。这使得可以并行测试,以便快速发现开发者的任何错误。

一些建议

拥有好的工具跟踪你所构建内容的有效性非常重要,并且在开始阶段要非常务实。创业公司经常变化,工程也在演进:你从一个超级探索阶段开始,一直建立原型并删除代码,直到找到金矿,然后你开始深入,改进代码、性能等。在那之前,一切都是关于快速迭代和良好的监控/分析。我想这是被反复重复的标准建议,但我认为许多人并不真正理解它的重要性。

我认为现在技术并不那么重要,所以使用能解决问题的任何技术即可。不过,我会再三考虑迁移到像 App Engine 或 Heroku 这样的托管环境。

在 Smesh 进行的大规模社交媒体分析(2014)

Alex Kelly(sme.sh

在 Smesh,我们制作软件,从网络上各种 API 摄取数据;过滤、处理和聚合数据;然后使用这些数据为多样的客户构建定制的应用程序。例如,我们为 Beamly 的第二屏电视应用提供推文过滤和流服务技术,为移动网络 EE 运行品牌和活动监控平台,并为 Google 运行多个 Adwords 数据分析项目。

为此,我们运行各种流媒体和轮询服务,频繁地从 Twitter、Facebook、YouTube 等服务中获取内容,并每天处理数百万条推文。

Python 在 Smesh 的角色

我们广泛使用 Python——我们的平台和服务的大部分都是用它构建的。可用的各种库、工具和框架使我们能够在大多数工作中使用它。

这种多样性使我们能够(希望能够)为工作选择合适的工具。例如,我们使用 Django、Flask 和 Pyramid 创建了应用程序。每种工具都有其独特的优点,我们可以根据手头任务选择适合的工具。我们使用 Celery 处理任务;使用 Boto 与 AWS 交互;以及 PyMongo、MongoEngine、redis-py、Psycopg 等满足所有数据需求。清单还在继续。

平台

我们的主要平台由一个中心 Python 模块组成,提供数据输入、过滤、聚合和处理的钩子,以及各种其他核心功能。项目特定的代码从核心导入功能,然后实现更具体的数据处理和视图逻辑,因为每个应用程序都有不同的需求。

到目前为止,这种方式对我们运作得很好,并且允许我们构建相当复杂的应用程序,从各种来源摄取和处理数据,减少了大量的重复工作。不过,这并非没有缺点——每个应用程序都依赖于一个共同的核心模块,更新该模块的代码并保持使用它的所有应用程序的更新是一项重大任务。

我们目前正在进行一个项目,重新设计核心软件,并朝着更多的面向服务的架构 (SoA) 方法迈进。看起来,找到进行这种架构变更的合适时机是大多数软件团队在平台发展中面临的挑战之一。构建组件作为单独服务存在开销,并且通常只有通过开发的初步迭代获取构建每个服务所需的深层领域特定知识时,这种架构开销才会成为解决实际问题的障碍。希望我们选择了一个明智的时机来重新审视我们的架构选择,推动事物向前发展。时间将会告诉我们结果。

高性能实时字符串匹配

我们从 Twitter Streaming API 大量获取数据。当我们流式传入推文时,我们会将输入字符串与一组关键词进行匹配,以便知道每条推文与我们正在追踪的哪些术语相关联。当输入速率较低或关键词集较小时,并不是问题,但是每秒处理数百条推文,同时匹配数百甚至数千个可能的关键词,开始变得棘手起来。

更棘手的是,我们不仅对推文中的关键词字符串是否存在感兴趣,而是对单词边界、行的起始和结束,以及可选的在字符串前缀使用 # 和 @ 字符进行更复杂的模式匹配感兴趣。封装这些匹配知识的最有效方式是使用正则表达式。然而,每秒在数百条推文上运行成千上万个正则表达式模式是计算密集的。此前,我们必须在一组机器的集群上运行许多工作节点,以确保能够实时可靠地执行匹配。

了解这一点是系统中的一个主要性能瓶颈,我们尝试了多种方法来改善匹配系统的性能:简化正则表达式、运行足够的进程以确保我们充分利用服务器上的所有核心、确保所有的正则表达式模式都得到正确编译和缓存、在 PyPy 而不是 CPython 下运行匹配任务等。这些方法每种都带来了一点性能提升,但显然这种方法只能节约少量处理时间。我们希望获得数量级的加速,而不是小幅度改善。

很明显,我们需要在进行模式匹配之前,缩小问题空间,而不是试图提高每次匹配的性能。因此,我们需要减少要处理的推文数,或者减少需要对推文进行匹配的正则表达式模式数量。丢弃传入的推文并不是一个选择——那是我们感兴趣的数据。因此,我们着手寻找一种方法,以减少我们需要将传入推文与之比较的模式数量,以进行匹配。

我们开始研究各种字典树结构,以更高效地进行字符串集之间的模式匹配,并了解到了 Aho-Corasick 字符串匹配算法。对于我们的使用场景来说,它非常理想。用于构建字典树的词典必须是静态的——在确定自动机后不能向字典树添加新成员——但对我们来说,这不是问题,因为关键词集合在 Twitter 会话期间是静态的。当我们改变正在跟踪的术语时,我们必须断开并重新连接 API,这样我们可以同时重建 Aho-Corasick 字典树。

使用 Aho-Corasick 对输入字符串进行处理可以同时找到所有可能的匹配项,逐个字符地遍历输入字符串,并在字典树的下一级找到匹配的节点(或者没有找到)。因此,我们可以非常快速地找到推文中可能存在的关键词。尽管我们仍然不确定,因为 Aho-Corasick 的纯字符串匹配不能应用任何复杂逻辑,这些逻辑通常包含在正则表达式模式中,但我们可以将 Aho-Corasick 匹配视为预过滤器。不存在于字符串中的关键词不可能匹配,因此我们只需基于文本中出现的关键词处理少量正则表达式模式,而不是对每个输入都评估数百或数千个正则表达式模式。

通过减少我们尝试匹配每条传入推文的模式数量,仅保留少量模式,我们已经成功实现了所期望的加速效果。根据字典树的复杂性和输入推文的平均长度不同,我们的关键词匹配系统现在比原始的简单实现快了 10 到 100 倍。

如果你经常进行正则表达式处理或其他模式匹配工作,我强烈建议你详细了解不同变体的前缀和后缀字典树,这可能会帮助你找到解决问题的极快速度的解决方案。

报告、监控、调试和部署

我们管理多个运行我们的 Python 软件和支持其所有基础设施的系统。保持一切运行无中断可能会有些棘手。以下是我们在这个过程中学到的几个经验教训。

能够实时和历史上看到系统内部发生的情况真的非常强大,无论是在你自己的软件内部还是其运行的基础设施上。我们使用 Graphitecollectd 以及 statsd 来绘制系统状态的漂亮图表。这为我们提供了一种发现趋势和回顾性地分析问题以找出根本原因的方法。我们还没有开始实施,但 Etsy 的 Skyline 看起来也是一个很好的工具,用来在你有更多指标需要跟踪时发现意外情况。另一个有用的工具是 Sentry,一个非常棒的事件日志系统,用于跟踪一组机器上发生的异常。

无论你使用什么来进行部署,部署都可能是痛苦的。我们曾经使用过 PuppetAnsibleSalt。它们各有优缺点,但没有一个能够魔法般地解决复杂的部署问题。

为了保持某些系统的高可用性,我们在全球多个地理位置分布的基础设施集群上运行多个实例,将一个系统设为活动实例,其他系统作为热备份,并通过低 TTL(Time-to-Live)值的 DNS 更新来进行切换。显然,这并不总是简单的,特别是在数据一致性方面有严格的约束时。幸运的是,我们并没有受到太大的影响,使得这种方法相对来说还是相当简单的。这也为我们提供了一个相对安全的部署策略,更新一个备用集群并在晋升该集群为活动实例并更新其他集群之前进行测试。

与其他人一样,我们对使用 Docker 能够实现的可能性感到非常兴奋。也和几乎所有人一样,我们仍然处于摸索阶段,试图弄清楚如何将其融入我们的部署流程中。然而,能够以轻量且可重现的方式快速部署我们的软件,包括所有二进制依赖项和系统库,似乎就在不远的将来。

在服务器级别,有一大堆例行公事能够让生活更轻松。Monit 对于帮助你监控事物非常有用。Upstartsupervisord 则使得运行服务变得更加轻松。如果你没有使用完整的 Graphite/collectd 设置,Munin 对于一些快速简易的系统级图形显示也是非常有用的。而 Corosync/Pacemaker 则可以成为在集群节点上运行服务的良好解决方案(例如,当你有一堆需要在某处但不是每处运行的服务时)。

我尽量不只是列出流行词汇,而是指向我们每天都在使用的软件,这些软件确实在我们能有效部署和运行系统的效率上起到了巨大的作用。如果你已经听说过它们了,我相信你一定还有许多其他有用的建议要分享,所以请给我发封信指点一二。如果还没有,那就去了解一下吧——希望其中一些对你同样有用。

成功的 Web 和数据处理系统使用 PyPy(2014)

Marko Tasic(https://github.com/mtasic85

早期我在 PyPy 上有很好的经验,所以我选择在适用的地方都使用它。我使用它从小型玩具项目到中型项目。我第一次使用它的项目是一个协议实现;我们实现的协议是 Modbus 和 DNP3。后来,我用它来实现一个压缩算法,每个人都对它的速度感到惊讶。我记得,我在生产中使用的第一个版本是 PyPy 1.2,自带 JIT。到了 1.4 版本,我们确信它是我们所有项目的未来,因为修复了许多错误,速度也越来越快。我们惊讶地发现,仅仅升级 PyPy 到下一个版本,简单的案例就能快 2-3 倍。

我将在这里解释两个分开但深度相关的项目,它们共享 90%的相同代码,但为了使解释易于理解,我将把它们都称为“该项目”。

该项目是创建一个系统,收集报纸、杂志和博客,如有必要进行光学字符识别(OCR),对它们进行分类、翻译、应用情感分析、分析文档结构,并为以后的搜索对它们进行索引。用户可以在任何可用语言中搜索关键字,并检索有关索引文档的信息。搜索是跨语言的,所以用户可以用英语写作并获得法语的结果。此外,用户还将收到来自文档页面的被强调的文章和关键字,以及有关所占空间和刊登价格的信息。一个更高级的用例是报告生成,用户可以查看结果的表格视图,详细了解任何特定公司在受监控的报纸、杂志和博客上的广告支出情况。除了广告,它还可以“猜测”一篇文章是付费还是客观的,并确定其语调。

先决条件

显然,PyPy 是我们最喜欢的 Python 实现。对于数据库,我们使用了 Cassandra 和 Elasticsearch。缓存服务器使用了 Redis。我们使用 Celery 作为分布式任务队列(工作者),对于其代理,我们使用了 RabbitMQ。结果保存在 Redis 后端。后来,Celery 更多地使用 Redis 作为代理和后端。OCR 引擎使用的是 Tesseract。语言翻译引擎和服务器使用的是 Moses。我们使用 Scrapy 来爬取网站。对于整个系统的分布式锁定,我们使用了 ZooKeeper 服务器,但最初使用了 Redis。Web 应用程序基于出色的 Flask Web 框架及其许多扩展,例如 Flask-Login、Flask-Principal 等。Flask 应用程序由 Gunicorn 和 Tornado 在每个 Web 服务器上托管,nginx 被用作 Web 服务器的反向代理服务器。其余的代码由我们编写,是纯 Python 代码,运行在 PyPy 之上。

整个项目托管在内部 OpenStack 私有云上,并根据需求执行 100 到 1,000 个 ArchLinux 实例,这些需求可以动态地随时更改。整个系统每 6 到 12 个月消耗高达 200 TB 的存储空间,具体取决于上述需求。除了 OCR 和翻译外,所有处理都由我们的 Python 代码完成。

数据库

我们开发了一个 Python 包,统一了 Cassandra、Elasticsearch 和 Redis 的模型类。它是一个简单的对象关系映射器(ORM),将所有内容映射到字典或字典列表中,以便从数据库中检索多个记录时使用。

由于 Cassandra 1.2 不支持对索引进行复杂查询,我们通过类似于连接的查询来支持它们。但是,对小数据集(高达 4 GB)进行复杂查询是允许的,因为其中大部分必须在内存中处理。PyPy 运行在 CPython 无法加载数据到内存的情况下,这要归功于它对同质列表应用的策略,使它们在内存中更加紧凑。PyPy 的另一个好处是,它的即时编译在数据操作或分析发生的循环中起作用。我们编写代码的方式是,类型在循环内部保持静态,因为 JIT 编译的代码在这里特别有效。

Elasticsearch 用于文档的索引和快速搜索。在查询复杂性方面非常灵活,因此我们在使用过程中没有遇到任何重大问题。我们遇到的一个问题与更新文档有关;它并不适用于快速变化的文档,因此我们不得不将该部分迁移到 Cassandra。另一个限制与数据库实例上所需的 facets 和内存有关,但这可以通过使用更多的较小查询,然后在 Celery workers 中手动处理数据来解决。在 PyPy 和用于与 Elasticsearch 服务器池交互的 PyES 库之间没有出现重大问题。

Web 应用程序

正如我们之前提到的,我们使用 Flask 框架及其第三方扩展。最初我们在 Django 中启动了一切,但由于需求的迅速变化,我们转向了 Flask。这并不意味着 Flask 比 Django 更好;只是对于我们来说,在 Flask 中更容易遵循代码,因为它的项目布局非常灵活。Gunicorn 用作 Web 服务器网关接口(WSGI)HTTP 服务器,其 I/O 循环由 Tornado 执行。这使我们可以每个 Web 服务器拥有最多一百个并发连接。这比预期的要低,因为许多用户查询可能需要很长时间——在用户请求中进行了大量分析,并在用户交互中返回数据。

最初,Web 应用程序依赖于 Python Imaging Library(PIL)进行文章和单词高亮显示。我们在 PIL 库和 PyPy 之间遇到了问题,因为当时与 PIL 相关的许多内存泄漏。然后我们切换到了 Pillow,它的维护更加频繁。最终,我们编写了一个通过 subprocess 模块与 GraphicsMagick 交互的库。

PyPy 运行良好,结果与 CPython 可比。这是因为通常 Web 应用程序受 I/O 限制。但是,随着 PyPy 中 STM 的发展,我们希望不久的将来能在多核实例级别上实现可扩展的事件处理。

OCR 和翻译

我们为 Tesseract 和 Moses 编写了纯 Python 库,因为我们在 CPython API 依赖扩展方面遇到了问题。PyPy 使用 CPyExt 对 CPython API 有良好的支持,但我们希望更加控制底层发生的事情。因此,我们制定了一个与 PyPy 兼容的解决方案,其代码略快于 CPython。之所以没有更快,是因为大部分处理发生在 Tesseract 和 Moses 的 C/C++ 代码中。我们只能加速输出处理和构建文档的 Python 结构。在这个阶段,PyPy 的兼容性没有重大问题。

任务分配和工作人员

Celery 赋予了我们在后台运行多个任务的能力。典型任务包括 OCR、翻译、分析等。我们完全可以使用 Hadoop 进行 MapReduce,但我们选择了 Celery,因为我们知道项目需求可能经常变化。

我们大约有 20 名工作者,每名工作者都有 10 至 20 个函数。几乎所有函数都有循环或多重嵌套循环。我们关心类型保持静态,以便 JIT 编译器可以发挥其作用。最终结果是比 CPython 快 2 到 5 倍。我们没有获得更好的加速是因为我们的循环相对较小,介于 20,000 到 100,000 次迭代之间。在一些需要在单词级别进行分析的情况下,我们有超过 1 百万次迭代,这是我们获得超过 10 倍加速的地方。

结论

PyPy 是每个依赖于可读性和可维护性的大型源代码速度执行的纯 Python 项目的绝佳选择。我们发现 PyPy 也非常稳定。我们所有的程序都长时间运行,数据结构内部都是静态和/或同质类型,因此 JIT 可以发挥其作用。当我们在 CPython 上测试整个系统时,结果并不令我们惊讶:PyPy 比 CPython 快了大约 2 倍。在我们客户眼中,这意味着以同样价格获得了 2 倍的性能提升。除了 PyPy 到目前为止带给我们的所有好处外,我们希望它的软件事务内存(STM)实现能为我们带来 Python 代码的可扩展并行执行。

Lanyrd.com 的任务队列(2014 年)

Andrew Godwin

Lanyrd 是一个用于社交发现会议的网站——我们的用户登录,我们利用他们在社交网络中的朋友关系图,以及其他指标如他们的行业或地理位置,来推荐相关的会议。

站点的主要工作是将这些原始数据浓缩为我们可以向用户展示的内容——基本上是会议的排名列表。我们必须离线完成这项工作,因为我们每隔几天刷新推荐会议列表,并且因为我们正在访问通常很慢的外部 API。我们还使用 Celery 任务队列处理其他需要很长时间的任务,比如为用户提供的链接获取缩略图和发送电子邮件。每天队列中通常有超过 100,000 个任务,有时甚至更多。

Python 在 Lanyrd 的角色

Lanyrd 从一开始就使用 Python 和 Django 构建,几乎每个部分都是用 Python 编写的——包括网站本身、离线处理、统计分析工具、移动后端服务器以及部署系统。这是一种非常多才多艺且成熟的语言,非常适合快速编写,主要得益于其丰富的库和易于阅读简洁的语法,这使得更新和重构变得容易,初始编写也很轻松。

Celery 任务队列在我们早期(非常早期)需要任务队列时已经是一个成熟的项目,而且 Lanyrd 的其余部分已经是 Python,所以它非常合适。随着我们的增长,有必要改变它的后端队列(最终选择了 Redis),但它通常扩展得非常好。

作为初创企业,我们不得不在前进中留下一些已知的技术债务——这是你必须做的事情,只要你知道自己的问题在哪里,以及何时可能会出现,这并不一定是件坏事。Python 在这方面的灵活性非常棒;它通常鼓励组件的松耦合,这意味着通常可以轻松地发布一个“足够好”的实现,然后稍后轻松地重构为更好的实现。

所有关键部分,比如支付代码,都有完整的单元测试覆盖,但对于站点的其他部分和任务队列流程(尤其是与显示相关的代码),事情往往进展得太快,以至于单元测试不值得(它们会太脆弱)。因此,我们采用了非常敏捷的方法,并且拥有两分钟的部署时间和出色的错误跟踪;如果有 bug 进入生产环境,我们通常可以在五分钟内修复并部署。

提高任务队列的性能

任务队列的主要问题是吞吐量。如果积压,网站仍然可以工作,但开始出现神秘的过时问题——列表不更新,页面内容错误,电子邮件几个小时内未发送。

幸运的是,任务队列还鼓励设计非常可扩展的架构;只要我们的中央消息服务器(在我们的情况下是 Redis)能够处理作业请求和响应的消息开销,实际处理过程中可以启动任意数量的工作守护程序来处理负载。

报告、监控、调试和部署

我们有监控来跟踪我们的队列长度,如果队列开始变长,我们就会部署另一台带有更多工作守护程序的服务器。Celery 让这一切变得非常简单。我们的部署系统有钩子,可以根据需要增加单个服务器上的工作线程数(如果 CPU 利用率不理想),并且可以在 30 分钟内轻松将新服务器转换为 Celery 工作节点。这不像网站响应时间突然下降一样——如果你的任务队列突然出现负载高峰,你有时间实施修复措施,通常情况下问题会自行平稳过渡,只要你留有足够的备用容量。

向同行开发者的建议

我的主要建议是尽早将尽可能多的任务放入任务队列(或类似的松耦合架构)中。这需要一些初始的工程努力,但随着业务的增长,原本只需半秒钟的操作可能会增长到半分钟,你会庆幸它们没有阻塞你的主渲染线程。一旦达到这一点,确保密切关注平均队列延迟(任务从提交到完成所需的时间),并确保在负载增加时有一些备用容量。

最后,请注意为不同优先级的任务设置多个任务队列是有意义的。发送电子邮件并非很高优先级;人们习惯于等待几分钟才能收到电子邮件。然而,如果在后台渲染缩略图并显示加载指示器的同时,你希望该任务具有较高的优先级,否则会影响用户体验。你不希望你的 100,000 人邮件群发延迟整个站点的所有缩略图生成 20 分钟!


  1. 1 ↩︎

posted @ 2024-06-17 19:07  绝不原创的飞龙  阅读(36)  评论(0编辑  收藏  举报