Python 高性能编程

你将获得

通过阅读本书,你将能够:

  • 更好地掌握 numpy、Cython 和剖析器;
  • 了解 Python 如何抽象化底层的计算机架构;
  • 使用剖析手段来寻找 CPU 时间和内存使用的瓶颈;
  • 通过选择合适的数据结构来编写高效的程序;
  • 加速矩阵和矢量计算;
  • 使用工具把 Python 编译成机器代码;
  • 管理并发的多 I O 和计算操作;
  • 把多进程代码转换到在本地或者远程集群上运行;
  • 用更少的内存解决大型问题。

内容简介

Python 语言是一种脚本语言,其应用领域非常广泛,包括数据分析、自然语言处理、机器学习、科学计算、推荐系统构建等。

本书共有 12 章,围绕如何进行代码优化和加快实际应用的运行速度进行详细讲解。本书主要包含以下主题:计算机内部结构的背景知识、列表和元组、字典和集合、迭代器和生成器、矩阵和矢量计算、并发、集群和工作队列等。通过一系列真实案例展现了在应用场景中需要注意的问题。

本书适合初级和中级 Python 程序员、有一定 Python 语言基础想要得到进阶和提高的读者阅读。

作者简介

Micha Gorelick 在 bitly 公司从事与数据打交道的工作,并负责建立了快速前进实验室(Fast Forward Labs),研究从机器学习到高性能流算法领域的问题。

Ian Ozsvald 是 ModelInsight.io 的数据科学家和教师,有着超过十年的 Python 经验。他在 yCon 和 PyData 会议上教授 Python 编程,这几年一直在英国从事关于数据科学和高性能计算方面的咨询工作。

本书内容

内容提要

Python 语言是一种脚本语言,其应用领域非常广泛,包括数据分析、自然语言处理、机器学习、科学计算、推荐系统构建等。

本书共有 12 章,围绕如何进行代码优化和加快实际应用的运行速度进行详细讲解。本书主要包含以下主题:计算机内部结构的背景知识、列表和元组、字典和集合、迭代器和生成器、矩阵和矢量计算、并发、集群和工作队列等。最后,通过一系列真实案例展现了在应用场景中需要注意的问题。

本书适合初级和中级 Python 程序员、有一定 Python 语言基础想要得到进阶和提高的读者阅读。

前言

Python 很容易学。你之所以阅读本书可能是因为你的代码现在能够正确运行,而你希望它能跑得更快。你可以很轻松地修改代码,反复地实现你的想法,你对这一点很满意。但能够轻松实现代码跑得够快之间的取舍却是一个世人皆知且令人惋惜的现象。而这个问题其实是可以解决的。

有些人想要让顺序执行的过程跑得更快。有些人需要利用多核架构、集群,或者图形处理单元的优势来解决他们的问题。有些人需要可伸缩系统在保证可靠性的前提下酌情或根据资金多少处理更多或更少的工作。有些人意识到他们的编程技巧,通常是来自其他语言,可能不如别人的自然。

我们会在本书中覆盖所有这些主题,给出明智的指导去了解瓶颈并提出效率更高、伸缩性更好的解决方案。我们也会在本书中包含那些来自前人的战场故事,让你可以避免重蹈覆辙。

Python 很适合快速开发、生产环境部署,以及可伸缩系统。Python 的生态系统里到处都是帮你解决伸缩性的人,让你有更多时间处理那些更有挑战性的工作。

本书适合哪些人

你使用 Python 的时间已经足够长,了解为什么某些代码会跑得慢,也见过以本书讨论的 Cython、numpy 以及 PyPy 等技术作为解决方案。你可能还有其他语言的编程经验,因此知道解决性能问题的路不止一条。

本书主要的目标读者是那些需要解决 CPU 密集型问题的人,同时我们也会关注数据传输以及内存密集型问题。科学家、工程师、数据分析专家、学者通常会面临这些问题。

我们还会关注网页开发者可能面临的问题,包括数据的移动以及为了快速提升性能而使用 PyPy 这样的即时(JIT)编译器。

如果你有一个 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 2.7

Python 2.7 在科学和工程计算中是占主导地位的 Python 版本。在 *nix 环境(通常是 Linux 或 Mac)下,64 位的版本占了主导地位。64 位让你能够拥有更宽广的 RAM 寻址范围。*nix 让你构建出的应用程序的行为、部署和配置方法都可以很容易地被别人所理解。

如果你是一个 Windows 用户,那么你就要系好安全带了。我们展示的大多数代码都可以正常工作,但有些东西是针对特定操作系统的,你将不得不研究 Windows 下的解决方案。Windows 用户可能面临的最大的困难是模块的安装:搜索 StackOverflow 等站点应该可以帮助你找到你需要的答案。如果你正在使用 Windows,那么使用一台安装了 Linux 的虚拟机(比如 VirtualBox)可能可以帮助你更自由地进行实验。

Windows 用户绝对应该看看那些通过 Anaconda、Canopy、Python(x,y) 或 Sage 等 Python 发行版提供的打包的解决方案。这些发行版也会让 Linux 和 Mac 用户的生活简单许多。

迁移至 Python 3

Python 3 是 Python 的未来,每一个人都在迁移过去。尽管 Python 2.7 还将在接下来的很多年里面继续跟我们做伴(有些安装版仍在使用 2004 年的 Python 2.4),它的退役日期已经被定在 2020 年了。

升级到 Python 3.3+ 让 Python 库的开发者伤透了脑筋,人们移植代码的速度一直都很慢(这是有原因的),所以人们转用 Python 3 的速度也很慢。这主要是因为要把一个混用了 Python 2 的 string 和 Unicode 数据类型的应用程序切换成 Python 3 的 Unicode 和 byte 实在太过复杂了。

通常来说,当你需要重现基于一批值得信任的库的结果时,你不会想要站在危险的技术前沿。高性能 Python 的开发者更有可能在接下来的几年里使用和信任 Python 2.7。

本书的大多数代码只需要稍做修改就能运行于 Python 3.3+(最明显的修改是 print 从一个语句变成了一个函数)。在一些地方,我们将特地关注 Python 3.3+ 带来的性能提升。一个可能需要你关注的地方是在 Python 2.7 中 / 表示 integer 的除法,而在 Python 3 中它变成了 float 的除法。当然,作为一个好的开发者,你精心编写的单元测试应该已经在测试你的关键代码路径了,所以如果你的代码需要关注这点,那么你应该已经收到来自你单元测试的警告了。

scipynumpy 从 2010 年开始就已经兼容 Python 3 了。matplotlib 从 2012 年开始兼容,scikit-learn 是 2013,NLTK 是 2014,Django 是 2013。这些库的迁移备忘录可以在它们各自的代码库和新闻组里查看。如果你也有旧代码需要移植到 Python 3,那么就值得回顾一下这些库移植的过程。

我们鼓励你用 Python 3.3+ 进行新项目的开发,但你要当心那些最近刚刚移植还没有多少用户的库——追踪 bug 将会更困难。比较明智的做法是让你的代码可以兼容 Python 3.3+(学习一下 __future__ 模块的导入),这样未来的升级就会更简单。

有两本参考手册不错:《把 Python 2 的代码移植到 Python 3》和《移植到 Python 3:深度指南》。Anaconda 或 Canopy 这样的发行版让你可以同时运行 Python 2 和 Python 3——这会让你的移植变得简单一些。

版权声明

本书版权符合知识共享协议“署名-非商业性使用-禁止演绎 3.0”。

欢迎以非商业性目的使用本书,包括非商业性教育。本书许可完整转载,如果你需要部分转载,请联系 O’Reilly(见后面“联系我们”部分)。请根据下面的提示进行署名。

我们经过协商认为本书应该使用知识共享许可证,让其内容在世界上更广泛传播。如果这个决定帮到了你,我们将十分高兴收到你的啤酒。我们估计 O’Reilly 的员工对啤酒的看法跟我们相同。

如何引用

如果你需要使用本书,知识共享许可证要求你署名。署名意味着你需要写一些东西让其他人能够找到本书。下面是一个不错的例子:“High Performance Python by Micha Gorelick and Ian Ozsvald (O’Reilly). Copyright 2014 Micha Gorelick and Ian Ozsvald, 978-1-449-36159-4.”

勘误和反馈

我们鼓励你在 Amazon 这样的公开网站上评论本书——请帮助其他人了解他们是否能从本书中受益!你也可以发 E-mail 给我们:feedback@highperformancepython.com。

我们特别希望听到您指出本书的错误,本书帮到你的成功案例,以及我们应该在下一版本加上的高性能技术。你可以通过 O’Reilly 官网给我们留言。

至于抱怨,欢迎你使用即时抱怨传输服务 > /dev/null

排版约定

本书采用下列排版约定:

斜体

表示新词、E-mail 地址、文件名,以及文件扩展名。

等宽

用于程序列印,以及在文字中表示命令、模块和程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

等宽加粗

表示命令或其他需要用户原封不动输入的文字。

等宽斜体

表示需要被替换成用户指定的值或根据上下文决定的值。

问题 

这个记号表示一个问题或练习。

备忘 

这个记号表示一个备忘。

警告 

这个记号表示一个警告或注意。

使用示例代码

补充材料(示例代码、练习等)可以通过 GitHub 下载。

本书是为了帮你搞定你的问题。通常来说,只要是本书提供的示例代码,你就可以在你的程序和文档中使用。你不需要联系我们获得许可,除非你需要对很大一部分代码进行转载。比如,写一个使用了好几段本书代码的程序不需要许可。以 CD-ROM 的形式销售或分发 O’Reilly 图书中的示例需要许可。引用本书文字和示例代码回答问题不需要许可。在你的产品文档中合并大量本书示例代码需要许可。

如果你觉得你对示例代码的使用超出了上述的许可范围,请通过 permissions@oreilly.com 联系我们。

Safari@ 在线图书

enter image description here

Safari 在线图书是一个应需数字图书馆,它以书和视频的形式提供来自全球顶尖作者的技术和商业内容。

技术专家、软件开发者、网页设计者,以及商业和创新人员将 Safari 在线图书当成他们研究、解决问题、学习和资格认证训练的主要资源。

Safari 在线图书为企业,政府,教育和个人提供了各种收费标准。

其成员可以通过全文搜索数据库访问成千上万的图书,训练视频,以及还未正式出版的手稿。它们来自 O’Reilly Media、Prentice Hall Professional、Addison-Wesley Professional、Microsoft Press、Sams、Que、Peachpit Press、Focal Press、Cisco Press、John Wiley & Sons、Syngress、Morgan Kaufmann、IBM Redbooks、Packt、Adobe Press、FT Press、Apress、Manning、New Riders、McGraw-Hill、Jones & Bartlett、Course Technology,以及其他几百个出版社。更多信息请在线访问 https://www.safaribooksonline.com/

联系我们

请将关于本书的评论和问题发给本书出版社:

O’Reilly Media, Inc.

1005 Gravenstein Highway North

Sebastopol, CA 95472

800-998-9938 (in the United States or Canada)

707-829-0515 (international or local)

707-829-0104 (fax)

你也可以发送 E-mail 到 bookquestions@oreilly.com 对本书进行评论或询问技术问题。

关于我们的图书、课程、会议和新闻等更多信息请访问我们的网站。

我们的 Facebook:http://facebook.com/oreilly

Twitter:http://twitter.com/oreillymedia

YouTube:http://www.youtube.com/oreillymedia

致谢

感谢来自 Jake Vanderplas、Brian Granger、Dan Foreman-Mackey、Kyran Dale、John Montgomery、Jamie Matthews、Calvin Giles、William Winter、Christian Schou Oxvig、Balthazar Rouberol、Matt “snakes” Reiferson、Patrick Cooper 和 Michael Skirpan 的反馈和贡献。Ian 感谢他的妻子 Emily 让他消失 10 个月之久来完成本书(她真的太善解人意了)。Micha 感谢 Elaine 及其他朋友和他的家庭能够耐心等待他学习写书。O’Reilly 也是很好的合作伙伴。

第 12 章的提供者非常亲切地共享了他们的时间和宝贵的经验。我们感谢 Ben Jackson、Radim Řehůřek、Sebastjan Trebca、Alex Kelly、Marko Tasic 和 Andrew Godwin 花费的时间和精力。

作者简介

Micha Gorelick 在 bitly 公司从事与数据打交道的工作,并以负责建立了快速前进实验室(Fast Forward Labs),研究从机器学习到高性能流算法领域的问题。

Ian Ozsvald 是一个数据科学家,并且在 ModelInsight.io 担任 Python 老师,具有超过 10 年的 Python 经验。他已经在 PyCon 和 PyData 大会上讲课超过 10 年,并且在伦敦从事人工智能和高性能计算领域的咨询工作超过 10 年时间。Ian 的背景涉及 Python 和 C++,结合了 Linux 和 Windows 开发、存储系统、许多自然语言处理和文本处理,机器学习以及数据可视化。他在许多年前也共同创建了专注于 Python 的视频学习网站 ShowMeDo.com。

第 1 章 理解高性能 Python

读完本章之后你将能够回答下列问题

  • 计算机架构有哪些元素?
  • 常见的计算机架构有哪些?
  • 计算机架构在 Python 中的抽象表达是什么?
  • 实现高性能 Python 代码的障碍在哪里?
  • 性能问题有哪些种类?

计算机编程可以被认为是以特定的方式进行数据的移动和转换来得到某种结果。然而这些操作有时间上的开销。因此,高性能编程可以被认为是通过降低开销(比如撰写更高效的代码)或改变操作方式(比如寻找一种更合适的算法)来让这些操作的代价最小化。

数据的移动发生在实际的硬件上,我们可以通过降低代码开销的方式来了解更多硬件方面的细节。这样的练习看上去可能没什么用,因为 Python 做了很多工作将我们对硬件的直接操作抽象出来。然而,通过理解数据在硬件层面的移动方式以及 Python 在抽象层面移动数据的方式,你会学到一些编写高性能 Python 程序的知识。

1.1 基本的计算机系统

一台计算机的底层组件可被分为三大基本部分:计算单元,存储单元,以及两者之间的连接。除此之外,这些单元还具有多种属性帮助我们了解它们。计算单元有一个属性告诉我们它每秒能够进行多少次计算,存储单元有一个属性告诉我们它能保存多少数据,还有一个属性告诉我们能以多快的速度对它进行读写,而连接则有一个属性告诉我们它们能以多快的速度将数据从一个地方移动到另一个地方。

通过这些基本单元,我们就可以在各种不同的复杂度级别上描述一个标准工作站。例如,一个标准工作站可以被看作是具有一个中央处理单元(CPU)作为其计算单元,两个独立的存储单元,分别是随机访问内存(RAM)和硬盘(各自有不同的容量和读写速度),最后还有一个总线将所有这些连接在一起。然而,我们还可以深入 CPU 并发现其内部也有多个存储单元:L1、L2,有时甚至有 L3 和 L4 缓存,它们的容量较小(从几 KB 到十几 MB)但速度非常快。这些额外的存储单元通过一个被称为后端总线的特殊总线连接 CPU。另外,新的计算机架构通常会有新的配置(比如 Intel 的 Nehalem 架构的 CPU 用英特尔快速通道互联技术替换了前端总线并重新构建了很多连接)。最后,在上述案例的讨论中,我们还忽略了网络连接,这是一种慢速的连接,用于连接其他许多潜在的计算单元和存储单元。

为了帮助理清这些错综复杂的结构,让我们去浏览一下这些基本单元的简要描述。

1.1.1 计算单元

一台计算机的计算单元是其中央部件——它具有将接收到的任意输入转换成输出的能力以及改变当前处理状态的能力。CPU 是最常见的计算单元;然而,最初被设计用于加速计算机图像处理的图形处理单元(GPU)现在变得更加适用于数值计算了,这是因为其自身的并行模式使得大量计算能并发进行。无论哪种类型,一个计算单元都会接收一系列比特(比如代表数字的比特)并输出另外一堆比特(比如代表这些数字之和的比特)。除了实数的基本算数操作和二进制的比特操作以外,一些计算单元还提供了非常特殊的操作,比如“乘加混合计算”,接收三个数字 A、B、C 并返回 A * B + C 的值。

计算单元的主要属性是其每个周期能进行的操作数量以及每秒能完成多少个周期。第一个属性通过每周期完成的指令数(IPC)[1]来衡量,而第二个属性则是通过其时钟速度衡量。当新的计算单元被制造出来时,它们的这两个参数总是互相竞争。比如 Intel 的 Core 系列具有非常高的 IPC 但时钟速度较低,而 Pentium 4 的芯片则完全相反。不过话又说回来,GPU 的 IPC 和时钟速度都很高,但它们有别的问题,我们后面会提到。

另外,当时钟速度提高时,能够立即提高该计算单元上所有的程序运行速度(因为它们每秒能进行更多运算),而提高 IPC 则在矢量计算能力上有相当程度的影响。矢量计算是指一次提供多个数据给一个 CPU 并能同时被操作。这种类型的 CPU 指令被称为 SIMD(单指令多数据)。

总之,计算单元在过去十年的进展颇为有限(图 1-1)。时钟速度和 IPC 的提升都限于停滞,因为晶体管已经小到了物理的极限。结果就是芯片制造商开始依靠其他手段来获得更高的速度,包括超线程技术,更聪明的乱序执行和多核架构。

enter image description here

图 1-1 CPU 的时钟速度随时间的变化(数据来自 CPU DB)

超线程技术为主机的操作系统(OS)虚拟了第二个 CPU,而聪明的硬件逻辑则试图将两个指令线程交错地插入单个 CPU 的执行单元。如果成功插入,能比单线程提升 30%。一般来讲,当两个线程的工作分布在不同的执行单元上时,这样做效果不错——比如一个操作浮点而另一个操作整数时。

乱序执行允许编译器检测出一个线性程序中某部分可以不依赖于之前的工作,也就是说两个工作能够以各种顺序执行或同时执行。只要两个工作的成果都能够在正确的时间点上依次得到,哪怕它们的计算次序跟程序设计不同,程序也能继续正确运行。这使得当一些指令被阻塞时(比如等待一次内存访问),另一些指令得以执行,以此来提升资源的利用率。

最后也是对于高级程序员来说最重要的是多核架构的普及。这些架构在同一个计算单元中包含了多个 CPU,提高了总体计算能力,而且无须等待内存屏障,让单个核心可以跑得更快。这就是为什么现在已经很难找到少于双核的计算机了——双核意味着计算机有两个互连的物理计算单元。虽然这增加了每秒可以进行的操作总数,但是想要让两个计算单元都达到最大利用率的话还需要考虑很多错综复杂的因素。

给 CPU 增加更多的核心并不一定能提升程序运行的速度。这是由阿姆达尔定律决定的。简单地说,阿姆达尔定律认为如果一个可以运行在多核上的程序有某些执行路径必须运行在单核上,那么这些路径就会成为瓶颈导致最终速度无法通过增加更多核心来提高。

比如,假设我们有一个调查需要 100 个人参与,该调查需要花费 1 分钟,如果我们只有一位提问者(该提问者向第一位参与者提问,等待回答,然后移向下一位参与者),那么我们可以用 100 分钟完成这个任务。这个单人提问并等待回答的流程就是一个顺序执行的过程。对于一个顺序执行的过程,我们每次只能完成一个操作,后面的操作必须等待前面的操作完成。

然而,如果我们有两位提问者,他们就可以同时进行测试,用 50 分钟完成任务。这是由于两位提问者之间不需要互相了解,没有依赖关系,所以整个任务就很容易划分。

增加更多提问者可以进一步提速,直到我们有 100 位提问者。此时,整个流程仅需要 1 分钟就可以完成,仅取决于参与者回答问题所需要的时间。继续增加提问者将不会带来任何速度提升,因为这些多余的提问者无事可干——所有的参与者都已经在接受调查!此时,唯一能够降低整体时间的办法是降低单个参与者完成调查的时间,也就是降低顺序部分所需要的执行时间。同样,对于 CPU,我们可以增加更多的核心直到某个必须单核执行的任务成为瓶颈。也就是说,任何并行计算的瓶颈最终都会落在其顺序执行的那部分任务上。

另外,对于 Python 来说,充分利用多核性能的阻碍主要在于 Python 的全局解释器锁(GIL)。GIL 确保 Python 进程一次只能执行一条指令,无论当前有多少个核心。这意味着即使某些 Python 代码可以使用多个核心,在任意时间点仅有一个核心在执行 Python 的指令。以前面调查的例子来说,即使我们有 100 位提问者,然而一次仅有一位可以提问和接受回答,并没有什么用!这看上去是个严重的阻碍,特别是当现在计算机发展的趋势就是拥有更多而非更快的计算单元的时候。好在这个问题其实可以通过一些方法来避免,比如标准库的 multiprocessing,或 numexpr、Cython 等技术,或分布式计算模型等。

1.1.2 存储单元

计算机的存储单元被用于保存比特。这些比特可能代表程序中的变量,或一幅图片的像素。存储单元的概念包括了主板上的寄存器、RAM 以及硬盘。所有这些不同类型的存储单元的主要区别在于其读写数据的速度。更复杂的问题在于,其读写数据的速度还与数据的读写方式息息相关。

比如,大多数存储单元一次读一大块数据的性能远好于读多次小块数据(顺序读取 VS 随机数据)。将这些存储单元中的数据想象成一本书中的书页,大多数存储单元的读写速度在连续翻页时高于经常从一张随机页跳至另一张随机页。

所有的存储单元或多或少都受到这一影响,但不同类型存储单元受到的影响却大不相同。

除了读写速度以外,存储单元还有一个延时的属性,表示了设备为了查找到需要的数据所花费的时间。一个旋转硬盘的延时可能较高,因为磁盘必须物理旋转到一定速度且读取磁头必须移动到正确的位置。而从另一方面来说,RAM 的延时就比较小,因为一切都是固态的。下面是一个标准工作站内常见的各类存储单元的简短描述,以读写速度排序:

旋转硬盘

计算机关机也能保持的长期存储。读写速度通常较慢,因为磁盘必须物理旋转和等待磁头移动。随机访问性能下降但容量很高(TB 级别)。

固态硬盘

类似旋转硬盘,读写速度较快但容量较小(GB 级别)。

RAM

用于保存应用程序的代码和数据(比如用到的各种变量)。具有更快的读写速度且在随机访问时性能良好,但通常受限于容量(GB 级别)。

L1/L2 缓存

极快的读写速度。进入 CPU 的数据必须经过这里。很小的容量(KB 级别)。

图 1-2 展示了当今市面上可以见到的这几类存储单元的区别。

一个清晰可见的趋势是读写速度和容量成反比——当我们试图加快速度时,容量就下降了。因此,很多系统都实现了一个分层的存储:数据一开始都在硬盘里,部分进入 RAM,然后很小的一个子集进入 L1/L2 缓存。这种分层使得程序可以根据访问速度的需求将数据保存在不同的地方。在试图优化程序的存储访问模式时,我们只是简单优化了数据存放的位置、布局(为了增加顺序读取的次数),以及数据在不同位置之间移动的次数。另外,异步 I/O 和缓存预取等技术还提供了很多方法来确保数据在被需要时就已经存在于对应的地方而不需要浪费额外的计算时间——这些过程可以在进行其他计算时独立进行!

enter image description here

图 1-2 各类存储单元的特征(2014 年 2 月的数据)

1.1.3 通信层

最后,让我们看看这些基本单元如何互相通信。通信有很多模式,但它们都是同一样东西的变种:总线。

比如说,前端总线是 RAM 和 L1/L2 缓存之间的连接。它将已经准备好被处理器操作的数据移入一个集结场所以备计算所需,又将计算结果移出。除此之外还有其他总线,如外部总线就是硬件设备(如硬盘和网卡)通向 CPU 和系统内存的主干线。该总线通常比前端总线慢。

事实上,L1/L2 缓存的很多好处实际上是来自更快的总线。因为可以将需要计算的数据在慢速总线(连接 RAM 和缓存)上攒成大的数据块,然后以非常快的速度从后端总线(连接缓存和 CPU)传入 CPU,这样 CPU 就可以进行更多计算而无须等待这么长的时间。

同样,使用 GPU 的不利之处很多都来自它所连接的总线:因为 GPU 通常是一个外部设备,它通过 PCI 总线通信,速度远远慢于前端总线。结果,GPU 数据的输入输出就像是一种抽税操作。异质架构,一种在前端总线上同时具有 CPU 和 GPU 的计算机架构的兴起就是为了降低数据传输成本,使得 GPU 能够被使用在需要传输大量数据的计算上。

除了计算机内部的通信模块,网络可以被认为是另一种通信模块。不过这个模块就比之前讨论的更为灵活,一个网络设备可以直接连接至一个存储设备,如网络连接存储(NAS)设备或计算机集群中的另一台计算机节点。但是网络通信通常要比之前讨论的其他类型的通信慢很多。前端总线每秒可以传输数十 GB,而网络则仅有数十 MB。

现在我们清楚,一条总线的主要属性是它的速度:在给定时间内它能传输多少数据。该属性由两个因素决定:一次能传输多少数据(总线带宽)和每秒能传输几次(总线频率)。需要说明的是一次传输的数据总是有序的:一块数据先从内存中读出,然后被移动到另一个地方。这就是为什么总线的速度可以被拆分为两个因素,因为这两个因素分别独立影响计算的不同方面:高的总线带宽可以一次性移动所有相关数据,有助于矢量化的代码(或任何顺序读取内存的代码),而另一方面,低带宽高频率有助于那些经常随机读取内存的代码。有意思的是,这些属性是由计算机设计者在主板的物理布局上决定的:当芯片之间相距较近时,它们之间的物理链路就较短,就可以允许更高的传输速度。而物理链路的数量则决定了总线的带宽(带宽这个词真的具有物理上的意义!)。

由于物理接口可以针对某个特定应用优化,所以我们不会奇怪世上存在成百上千种不同类型的连接。图 1-3 显示了一些常见接口的比特率。注意这图上完全没提到连接的延时,延时决定了一个连接响应数据请求花费的时间(虽然延时跟具体的计算机系统息息相关,但是有来自物理接口的一些基本限制)。

enter image description here

图 1-3 各种常见界面的连接速度(图片来自 Leadbuffalo)

1.2 将基本的元素组装到一起

仅理解计算机的基本组成部分并不足以理解高性能编程的问题。所有这些组件的互动与合作还会引入新的复杂度。本段将研究一些样本问题,描述理想的解决方案以及 Python 如何实现它们。

警告:本段可能看上去让人绝望——大多数问题似乎都证明 Python 并不适合解决性能问题。这不是真的,原因有两点。首先,在所有这些“高性能计算要素”中,我们忽视了一个至关重要的要素:开发者。原生 Python 在性能上欠缺的功能会被迅速开发出来。另外,我们会在本书中介绍各种模块和原理来帮助减轻这里遇到的问题。当这两点结合起来,我们就能在快速开发 Python 的同时移除很多性能局限。

理想计算模型和 Python 虚拟机

为了更好地理解高性能编程的要素,让我们来看一段用于判断质数的简单代码样例:

import mathdef check_prime(number):    sqrt_number = math.sqrt(number)    number_float = float(number)    for i in xrange(2, int(sqrt_number)+1):        if (number_float / i).is_integer():            return False    return Trueprint "check_prime(10000000) = ", check_prime(10000000) # Falseprint "check_prime(10000019) = ", check_prime(10000019) # True

让我们用抽象的计算模型来分析这段代码对比 Python 运行这段代码时实际发生了什么。由于是抽象模型,我们将忽略很多理想化的计算机以及 Python 运行代码方式的细节。不过,这是一个在解决实际问题之前很好的练习:思考算法中的通用组件以及如何最优地使用这些组件来解决问题。只要明白在理想情况下以及实际在 Python 中发生了什么,我们就能让自己的 Python 代码一步步接近最优。

1.理想计算模型

在代码的开头,我们将 number 的值保存于 RAM 中。为了计算 sqrt_numbernumber_float,我们需要将该值传入 CPU。在理想情况下,我们只需要传一次,它将被保存在 CPU 的 L1/L2 缓存中,然后 CPU 会进行两次计算并将结果传回 RAM 保存。这是一个理想的情况,因为我们令从 RAM 中读取 number 的值的次数最少,转而以快很多的 L1/L2 缓存的读取代替。另外,我们还令前端总线传输数据的次数最少,以更快的后端总线(连接 CPU 和各类缓存)的传输代替之。将数据保持在需要的地方并尽量少移动这一场景对于优化来说至关重要。所谓“沉重数据”的概念指的是移动数据需要花费时间,而这就是我们需要避免的。

在代码的循环部分,与其一次次将 i 输入 CPU,我们更希望一次就将 number_float 和多个 i 的值输入 CPU 进行检查。这是可能的,因为 CPU 的矢量操作不需要额外的时间代价,意味着它可以一次进行多个独立计算。所以我们希望将 number_float 传入 CPU 缓存,以及在缓存放得下的情况下传入尽可能多的 i 的值。对于每一对 number_float/i,我们将进行除法计算并检查结果是否为整数,然后传回一个信号表明是否有任意一个结果确实为整数。如果是,则函数结束。如果否,我们继续下一批计算。这样,对于多个 i 的值,我们只需要传回一个结果,而不是依靠总线返回所有的值。这利用了 CPU 的矢量化计算的能力,或者说在一个时钟周期内以一条指令操作了多个数据。

这一矢量操作的概念可以用下列代码来表述:

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

这里,我们让程序一次对 5 个 i 的值进行除法和整数检查。当被正确地矢量化时,CPU 仅需一条指令完成这行代码而不是对每个 i 进行独立操作。理想情况下,any(result) 操作将只发生于 CPU 内部而无须将数据传回 RAM。我们将在第 6 章讨论更多关于矢量操作的细节,包括它具体如何工作以及在什么情况下有利于你的代码。

2.Python 虚拟机

Python 解释器为了抽离底层用到的计算元素做了很多工作。这让编程人员无须考虑如何为数组分配内存、如何组织内存以及用什么样的顺序将内存传入 CPU。这是 Python 的一个优势,让你能够集中在算法的实现上。然而它有一个巨大的性能代价。

首先我们要意识到 Python 核心运行于一组非常优化的指令上。而诀窍就是让 Python 以正确的次序执行它们来获得更好的性能。比如下例,我们可以轻松看出,虽然两个算法都有 O(n) 的运行时间,search_fast 会比 search_slow 快,因为它提前中止了循环,跳过了不必要的计算。

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

通过性能分析查找代码的慢速区域以及寻找更有效的算法其实就是寻找这些无用的操作并删除它们,最终的结果是一样的,但计算和传输数据的次数却大大减少了。

而 Python 虚拟机抽象层的影响之一就是矢量操作变得不是直接可用了。我们最初的质数函数会循环遍历 i 的每一个值而不是将多次遍历组合成一个矢量操作。而我们抽象以后的矢量化代码并不是合法的 Python 代码,因为我们不能用一个列表去除一个浮点。numpy 这样的外部库可以通过增加矢量化数学操作的方式帮助我们解决这个问题。

另外,Python 抽象还影响了任何需要为下一次计算保存 L1/L2 缓存中相关数据的优化。这有很多原因,首先是 Python 对象不再是内存中最优化的布局。这是因为 Python 是一种垃圾收集语言——内存会被自动分配并在需要时释放。这会导致内存碎片并影响向 CPU 缓存的传输。另外,我们也没有任何机会去直接改变数据结构在内存中的布局,这意味着总线上的一次传输可能并不包含一次计算的所有相关信息,即使这些信息少于总线带宽。

第二个问题更加基本,根源是 Python 的动态类型以及 Python 并不是一门编译性的语言。很多 C 语言开发者已经在多年开发过程中意识到,编译器总是比你聪明。当编译静态代码时,编译器可以做很多的事情来改变对象的内存布局以及让 CPU 运行某些指令来优化它们。然而,Python 并不是一种编译性的语言:更糟的是,它还具有动态类型,意味着任何算法上可能的优化机会都会更加难以实现,因为代码的功能有可能在运行时被改变。有很多方法可以缓解这一问题,其中最主要的一个方法就是使用 Cython,它可以将 Python 代码进行编译并允许用户“提示”编译器代码究竟有多“动态”。

最后,之前提到的 GIL 会影响并行代码的性能。比如,假设我们改变代码来使用多个 CPU 核心,每个核心收到一堆数字,取值范围是 2 到 sqrtN。每个核心可以对自身收到的数据进行计算,当它们都完成时可以互相进行比较。这看上去是一个好方案,虽然我们失去了提前中止循环的能力,但是随着我们使用的核心数的增加,每个核心需要进行的检查数降低了(例如,如果我们有 M 个核心,每个核心只需要进行 sqrtN/M 次检查)。然而,由于 GIL,一次仅有一个核心可以被使用。这意味着我们还是以非并行的方式运行这段代码,而且还不能提前中止。我们可以使用多进程(multiprocessing 模块)而不是多线程,或者使用 Cython 或外部函数来避免这个问题。

1.3 为什么使用 Python

Python 具有高度的表现力且容易上手——新开发者会很快发现他们可以在很短时间里做到很多。许多 Python 库包含了用其他语言编写的工具,使 Python 可以轻易调用其他系统。比如,scikit-learn 机器学习系统包含了 LIBLINEAR 和 LIBSVM(两者皆以 C 语言写成),numpy 库则包含了 BLAS 以及其他用 C 和 Fortran 语言写的库。因此,正确运用这些库的 Python 代码确实可以在速度上做到跟 C 媲美。

Python 被誉为“内含电池”,因为它内建了很多重要且稳定的库。包括:

unicodebytes

语言核心内建。

array

原始类型的高效数组。

math

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

sqlite3

包含了流行的基于文件的 SQL 引擎 SQLite3。

collections

多种对象,包括双向队列、计数器和字典的变种。

除了这些语言核心库,还有大量的外部库,包括:

numpy

一个 Python 数字库(矩阵运算的基石库)。

scipy

大量可信的科学库的集合,通常包含了广受尊重的 C 和 Fortran 库。

pandas

一个数据分析库,类似于 R 语言的数据框或 Excel 表格,基于 scipynumpy

scikit-learn

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

biopython

一个生物信息学库,类似于 bioperl

tornado

一个提供了并发机制的库。

各类数据库封装

为了跟基本上所有的数据库通信,包括 Redis、MongoDB、HDF5 以及 SQL。

各类网站开发框架

用于创建网站的各种高性能系统,如 djangopyramidflasktornado

OpenCV

计算机视觉的封装。

各类 API 封装

用于轻松访问各种时髦的 web API 如 Google、Twitter 和 LinkedIn。

为了适应各种部署环境,还有大量可选的管理环境和 shell,包括:

  • 标准发行版。
  • Enthought 公司的 EPD 和 Canopy,一个非常成熟且能干的环境。
  • Continuum 公司的 Anaconda,一个注重科学计算的环境。
  • Sage,一个类似于 Matlab 的环境,包括一个集成开发环境(IDE)。
  • Python(x,y)。
  • IPython,一个广泛被科学家和开发人员使用的 Python 互动 shell。
  • IPython Notebook,一个基于浏览器的 IPython 前端,广泛用于教学和演示。
  • BPython,另一个 Python 互动 shell。

Python 的一大优势在于它可以快速实现出一个新主意的原型。由于存在各种支持库,它能够轻易测试出一个主意是否可行,哪怕第一个实现可能是磕磕碰碰做出来的。

如果你想要让你的数学函数更快,看看 numpy。如果你想要实验一下机器学习,试试 scikit-learn。如果你在清理和操作数据,那么 pandas 是一个好选择。

总的来说,我们有理由提出这样一个问题,“为了让我们的系统跑得更快而进行的优化从长期来看会不会反而导致我们团队整体跑得更慢了?”只要花费足够的人力,系统总是可以被榨出更多的性能,但这可能导致系统脆弱的可维护性以及难以理解的优化并最终导致整个团队绊倒在地。

Cython 就是一个例子(7.6 节),它将 Python 代码注释成类似 C 语言的类型,被转化后的代码可以被一个 C 编译器编译。它在速度上的提升令人惊叹(相对较少的努力就能获得 C 语言的速度),但后续代码的维护成本也会上升。尤其是,对这个新模块可能更难,因为团队成员需要具备一定的编程能力来理解那些为了性能提升而绕开 Python 虚拟机的折衷。


[1] 不要跟进程间通信(也是 IPC)混淆——我们会在第 9 章讨论这个主题。

第 2 章 通过性能分析找到瓶颈(上)
第 2 章 通过性能分析找到瓶颈(下)
第 3 章 列表和元组
第 4 章 字典和集合
第 5 章 迭代器和生成器
第 6 章 矩阵和矢量计算(上)
第 6 章 矩阵和矢量计算(下)
第 7 章 编译成 C(上)
第 7 章 编译成 C(下)
第 8 章 并发
第 9 章 multiprocessing 模块(上)
第 9 章 multiprocessing 模块(下)
第 10 章 集群和工作队列
第 11 章 使用更少的 RAM
第 12 章 现场教训

阅读全文: http://gitbook.cn/gitchat/geekbook/5cab08a531cc5b7fc681227b

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页