精通-Python-高性能-全-

精通 Python 高性能(全)

原文:zh.annas-archive.org/md5/7289a66790cbfec1c8bb1a2cb27941dd

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书的想法来源于 Packt 出版社的友好人士。他们希望找到能够深入研究 Python 高性能及其相关主题的人,无论是配置、可用的工具(如剖析器和其他性能增强技术),甚至是标准 Python 实现的替代方案。

话虽如此,我欢迎你加入精通 Python 高性能的行列。在这本书中,我们将涵盖与性能改进相关的所有内容。对主题的了解并非严格必要(尽管这不会有害),但需要了解 Python 编程语言,尤其是在一些特定于 Python 的章节中。

我们将从介绍配置的基本概念开始,包括它在开发周期中的位置以及与之相关的益处。之后,我们将转向完成工作所需的核心工具(剖析器和可视化剖析器)。然后,我们将探讨一系列优化技术,最后到达一个完全实用的章节,将提供一个实际的优化示例。

本书涵盖内容

第一章,配置 101,为那些不了解配置艺术的人提供相关信息。

第二章,剖析器,告诉你如何使用本书中提到的核心工具。

第三章,可视化——使用 GUI 来理解剖析器输出,涵盖了如何使用 pyprof2calltree 和 RunSnakeRun 工具。它还帮助开发者通过不同的可视化技术理解 cProfile 的输出。

第四章,优化一切,讲述了优化的基本过程以及一套每个 Python 开发者考虑其他选项之前应该遵循的良好/推荐实践。

第五章,多线程与多进程,讨论了多线程和多进程,并解释了何时以及如何应用它们。

第六章,通用优化选项,描述并展示了如何安装和使用 Cython 和 PyPy 来提高代码性能。

第七章,使用 Numba、Parakeet 和 pandas 进行闪电般的数值计算,讨论了帮助优化处理数字的 Python 脚本的工具。这些特定的工具(Numba、Parakeet 和 pandas)有助于使数值计算更快。

第八章,“将一切付诸实践”,提供了一个关于分析器的实际示例,找出其瓶颈,并使用本书中提到的工具和技术将其移除。最后,我们将比较使用每种技术的结果。

您需要为这本书准备的内容

在执行本书中提到的代码之前,您的系统必须安装以下软件:

  • Python 2.7

  • 行分析器 1.0b2

  • Kcachegrind 0.7.4

  • RunSnakeRun 2.0.4

  • Numba 0.17

  • Parakeet 的最新版本

  • pandas 0.15.2

本书面向的对象

由于本书涉及与 Python 代码分析优化相关的所有主题,因此所有级别的 Python 开发者都将从本书中受益。

唯一的基本要求是您需要对 Python 编程语言有一些基本了解。

约定

在这本书中,您将找到许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"我们可以在PROFILER函数内部打印/收集我们认为相关的信息。"

代码块按照以下方式设置:

import sys

def profiler(frame, event, arg):
    print 'PROFILER: %r %r' % (event, arg)

sys.setprofile(profiler)

当我们希望将您的注意力引向代码块中的特定部分时,相关的行或项目将以粗体显示:

Traceback (most recent call last): 
  File "cprof-test1.py", line 7, in <module> 
    runRe() ...
  File "/usr/lib/python2.7/cProfile.py", line 140, in runctx 
    exec cmd in globals, locals 
 File "<string>", line 1, in <module> 
NameError: name 're' is not defined 

任何命令行输入或输出都按照以下方式编写:

$ sudo apt-get install python-dev libxml2-dev libxslt-dev

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:"再次,当第一个函数调用选择了调用者映射时,我们可以看到我们脚本的整个映射。"

注意

警告或重要注意事项以如下方式出现在框中。

小贴士

小贴士和技巧看起来是这样的。

读者反馈

我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com下载您购买的所有 Packt 出版物的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

下载本书的彩色图像

我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出中的变化。您可以从:www.packtpub.com/sites/default/files/downloads/9300OS_GraphicBundle.pdf下载此文件。

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分。

侵权

互联网上版权材料的侵权是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何形式的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供涉嫌侵权材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

询问

如果您本书的任何方面有问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章。分析 101

就像任何婴儿都需要学会爬行,然后再在 12 秒内跑 100 米并克服障碍一样,程序员在尝试掌握这门艺术之前,需要理解分析的基础知识。因此,在我们深入探讨 Python 程序的性能优化和分析的奥秘之前,我们需要对基础知识有一个清晰的理解。

一旦你了解了基础知识,你将能够了解工具和技术。因此,为了开始,本章将涵盖你关于分析所需了解的一切,但你可能因为害怕而未曾提问。在本章中,我们将做以下事情:

  • 我们将明确定义什么是分析,以及不同的分析技术。

  • 我们将解释分析在开发周期中的重要性,因为分析不是你只做一次然后忘记的事情。分析应该是开发过程的一个组成部分,就像编写测试一样。

  • 我们将涵盖我们可以分析的内容。我们将介绍我们将能够衡量的不同类型的资源以及它们如何帮助我们找到问题。

  • 我们将讨论过早优化的风险,即为什么在分析之前进行优化通常是一个糟糕的想法。

  • 你将了解运行时复杂度。理解分析技术是成功优化的第一步,但我们也需要了解如何衡量算法的复杂度,以便了解我们是否需要改进它。

  • 我们还将探讨良好的实践。最后,我们将回顾一些在开始项目分析过程时需要记住的良好实践。

什么是分析?

一个未经优化的程序通常会在某些特定的子例程中花费大部分的 CPU 周期。分析是分析代码相对于其使用的资源的行为。例如,分析会告诉你一条指令使用了多少 CPU 时间,或者整个程序消耗了多少内存。这通过修改程序的源代码或二进制可执行形式(如果可能)来实现,使用的东西被称为分析器。

通常,当开发者需要优化性能或程序出现某种奇怪的错误(这通常与内存泄漏有关)时,他们会分析程序。在这种情况下,分析可以帮助他们深入了解他们的代码是如何使用计算机资源的(即某个特定函数被调用的次数)。

开发者可以使用这些信息,结合对源代码的熟练掌握,来找出程序的瓶颈和内存泄漏。然后开发者可以修复代码中的任何错误。

软件分析主要有两种方法:基于事件的性能分析和基于统计的性能分析。当使用这些类型的软件时,你应该记住它们都有优点和缺点。

基于事件的性能分析

并非每种编程语言都支持这种类型的分析。以下是一些支持基于事件分析的编程语言:

基于事件的跟踪分析器(也称为跟踪分析器)通过在程序执行过程中收集特定事件的数据来工作。这些分析器会生成大量数据。基本上,它们监听的事件越多,收集的数据就越多。这使得它们在某种程度上不太实用,并且它们不是开始分析程序时的首选。然而,当其他分析方法不够或不够具体时,它们是一个很好的最后手段。考虑一下你想分析所有返回语句的情况。这种类型的分析器会为你提供完成任务所需的粒度,而其他分析器则根本不允许你执行此任务。

Python 中基于事件的跟踪分析器的一个简单例子可能是以下代码(一旦我们进入即将到来的章节,我们将更好地理解这个主题):

import sys

def profiler(frame, event, arg):
    print 'PROFILER: %r %r' % (event, arg)

sys.setprofile(profiler)

#simple (and very ineficient) example of how to calculate the Fibonacci sequence for a number.
def fib(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fib(n-1) + fib(n-2)

def fib_seq(n):
    seq = [ ]
    if n > 0:
        seq.extend(fib_seq(n-1))
    seq.append(fib(n))
    return seq

print fib_seq(2)

上述代码导致以下输出:

PROFILER: 'call' None
PROFILER: 'call' None
PROFILER: 'call' None
PROFILER: 'call' None
PROFILER: 'return' 0
PROFILER: 'c_call' <built-in method append of list object at 0x7f570ca215f0>
PROFILER: 'c_return' <built-in method append of list object at 0x7f570ca215f0>
PROFILER: 'return' [0]
PROFILER: 'c_call' <built-in method extend of list object at 0x7f570ca21bd8>
PROFILER: 'c_return' <built-in method extend of list object at 0x7f570ca21bd8>
PROFILER: 'call' None
PROFILER: 'return' 1
PROFILER: 'c_call' <built-in method append of list object at 0x7f570ca21bd8>
PROFILER: 'c_return' <built-in method append of list object at 0x7f570ca21bd8>
PROFILER: 'return' [0, 1]
PROFILER: 'c_call' <built-in method extend of list object at 0x7f570ca55bd8>
PROFILER: 'c_return' <built-in method extend of list object at 0x7f570ca55bd8>
PROFILER: 'call' None
PROFILER: 'call' None
PROFILER: 'return' 1
PROFILER: 'call' None
PROFILER: 'return' 0
PROFILER: 'return' 1
PROFILER: 'c_call' <built-in method append of list object at 0x7f570ca55bd8>
PROFILER: 'c_return' <built-in method append of list object at 0x7f570ca55bd8>
PROFILER: 'return' [0, 1, 1]
[0, 1, 1]
PROFILER: 'return' None
PROFILER: 'call' None
PROFILER: 'c_call' <built-in method discard of set object at 0x7f570ca8a960>
PROFILER: 'c_return' <built-in method discard of set object at 0x7f570ca8a960>
PROFILER: 'return' None
PROFILER: 'call' None
PROFILER: 'c_call' <built-in method discard of set object at 0x7f570ca8f3f0>
PROFILER: 'c_return' <built-in method discard of set object at 0x7f570ca8f3f0>
PROFILER: 'return' None

如您所见,PROFILER在每次事件上都会被调用。我们可以在PROFILER函数内部打印/收集我们认为相关的信息。示例代码的最后一条显示了简单的fib_seq(2)执行会产生大量的输出数据。如果我们处理的是一个现实世界的程序,这种输出将会大得多。这就是为什么基于事件的跟踪通常是在分析时最后的选项。还有其他替代方案(我们将在后面看到)会产生更少的输出,但当然,准确性较低。

统计分析

统计分析器通过在固定时间间隔采样程序计数器来工作。这反过来又允许开发者了解目标程序在各个函数上花费的时间。由于它是通过采样 PC 来工作的,因此得到的数字将是现实情况的统计近似值,而不是精确数字。尽管如此,这应该足以了解被分析程序正在做什么以及瓶颈在哪里。

这种类型分析的优势如下:

  • 分析数据更少:由于我们只采样程序的执行而不是保存每一小块数据,因此需要分析的信息量将显著减少。

  • 更小的剖析影响范围:由于采样方式(使用操作系统中断),目标程序在性能上受到的影响较小。尽管剖析器的存在并非完全不被察觉,但统计剖析对程序造成的损害比基于事件的剖析要小。

下面是OProfileoprofile.sourceforge.net/news/)的输出示例,OProfile 是一个 Linux 统计分析器:

Function name,File name,Times Encountered,Percentage
"func80000","statistical_profiling.c",30760,48.96%
"func40000","statistical_profiling.c",17515,27.88%
"func20000","static_functions.c",7141,11.37%
"func10000","static_functions.c",3572,5.69%
"func5000","static_functions.c",1787,2.84%
"func2000","static_functions.c",768,1.22%
"func1500","statistical_profiling.c",701,1.12%
"func1000","static_functions.c",385,0.61%
"func500","statistical_profiling.c",194,0.31%

下面是使用名为 statprof 的 Python 统计分析器对前面代码中的相同 Fibonacci 代码进行剖析的输出:

  %   cumulative      self          
 time    seconds   seconds  name    
100.00      0.01      0.01  B02088_01_03.py:11:fib
  0.00      0.01      0.00  B02088_01_03.py:17:fib_seq
  0.00      0.01      0.00  B02088_01_03.py:21:<module>
---
Sample count: 1
Total time: 0.010000 seconds

如您所见,对于相同的代码,两个剖析器的输出存在相当大的差异。

剖析的重要性

现在我们已经知道了剖析的含义,了解在应用程序的开发周期中实际进行剖析的重要性和相关性也同样重要。

剖析并不是每个人都习惯做的事情,尤其是对于非关键软件(与和平制造嵌入式软件或任何其他类型的执行关键示例不同)。剖析需要时间,并且通常只有在我们发现程序有问题时才有用。然而,即使在那之前,它仍然可以执行,以捕捉可能未被发现的问题,这反过来又可以帮助我们在稍后阶段减少调试应用程序所花费的时间。

随着硬件的不断进步,速度越来越快,价格越来越便宜,我们作为开发者,为何还要花费资源(主要是时间)来分析我们的作品变得越来越难以理解。毕竟,我们有诸如测试驱动开发、代码审查、结对编程等其他实践,这些实践确保我们的代码是可靠的,并且能够按照我们的预期工作。对吧?

然而,我们有时未能意识到的是,随着我们语言级别的提高(我们仅在几年内从汇编语言过渡到 JavaScript),我们对 CPU 循环、内存分配、CPU 寄存器等问题的思考越来越少。新一代程序员使用高级语言学习他们的技艺,因为这些语言更容易理解,并且能够提供开箱即用的更多功能。然而,它们也抽象化了硬件以及我们与它的交互。随着这种趋势的不断增长,新开发者甚至考虑将软件剖析作为其开发过程中的另一个步骤的可能性也在逐秒减弱。

让我们看看以下场景:

如我们所知,剖析测量了我们的程序使用的资源。正如我之前所述,这些资源正变得越来越便宜。因此,将我们的软件推向市场以及使其可供更多用户使用的成本也在降低。

这些天,创建和发布一个将被数千人访问的应用程序越来越容易。如果他们喜欢它,并通过社交媒体传播,这个数字可以呈指数级增长。一旦发生这种情况,非常常见的是软件会崩溃,或者它会变得无比缓慢,用户就会离开。

对于前面提到的情况,一个可能的解释当然是一个考虑不周且不可扩展的架构。毕竟,一个单服务器,有限的 RAM 和计算能力,只能让你走这么远,直到它成为你的瓶颈。然而,另一个可能的解释,一个被证明多次是正确的解释,是我们没有对我们的应用程序进行压力测试。我们没有考虑资源消耗;我们只是确保我们的测试通过,并且我们对这个结果感到满意。换句话说,我们没有走那额外的路,结果我们的项目失败了。

分析可以帮助避免那种崩溃和失败的结果,因为它提供了一个相当准确的观点,无论负载如何。所以,如果我们用很轻的负载来分析它,结果是我们在进行某种 I/O 操作上花费了 80%的时间,这可能会对我们提出警告。即使在我们的测试中,应用程序表现正确,它可能在重压下并不如此。想想内存泄漏类型的场景。在这些情况下,小测试可能不会产生足够大的问题让我们检测到它。然而,在重压下的生产部署会。分析可以提供足够的证据,让我们在问题变成问题之前就检测到它。

我们可以分析什么?

深入分析,了解我们实际上可以分析什么非常重要。测量是分析的核心,所以让我们详细看看在程序执行期间我们可以测量的东西。

执行时间

在分析时我们可以收集的最基本的数据是执行时间。整个过程的执行时间或只是代码特定部分的执行时间会对其本身有所启示。如果你在程序运行的领域有经验(也就是说,你是一个网络开发者,你正在工作在一个网络框架上),你可能已经知道对于你的系统来说,花费太多时间意味着什么。例如,一个简单的网络服务器在查询数据库、渲染响应并发送回客户端时可能需要高达 100 毫秒。然而,如果相同的代码开始变慢,现在它需要 60 秒来完成同样的任务,那么你应该开始考虑分析。你还得考虑这里的数字是相对的。让我们假设另一个过程:一个 MapReduce 作业,旨在处理存储在一系列文本文件上的 2TB 信息,需要 20 分钟。在这种情况下,你可能不会认为它是一个慢过程,即使它比前面提到的慢网络服务器花费的时间要多得多。

要获取这类信息,你并不真的需要大量的性能分析经验,甚至不需要复杂的工具来获取数据。只需将所需的行添加到你的代码中并运行程序即可。

例如,以下代码将计算数字 30 的斐波那契数列:

import datetime

tstart = None
tend = None

def start_time():
    global tstart
    tstart = datetime.datetime.now()
def get_delta():
    global tstart
    tend = datetime.datetime.now()
    return tend - tstart

 def fib(n):
     return n if n == 0 or n == 1 else fib(n-1) + fib(n-2)

def fib_seq(n):
    seq = [ ]
    if n > 0:
        seq.extend(fib_seq(n-1))
    seq.append(fib(n))
    return seq

start_time()
print "About to calculate the fibonacci sequence for the number 30"
delta1 = get_delta()

start_time()
seq = fib_seq(30) 
delta2 = get_delta()

print "Now we print the numbers: "
start_time()
for n in seq:
    print n
delta3 = get_delta()

print "====== Profiling results ======="
print "Time required to print a simple message: %(delta1)s" % locals()
print "Time required to calculate fibonacci: %(delta2)s" % locals()
print "Time required to iterate and print the numbers: %(delta3)s" % locals()
print "======  ======="

现在,代码将产生以下输出:

About to calculate the Fibonacci sequence for the number 30
Now we print the numbers: 
0
1
1
2
3
5
8
13
21
#...more numbers
4181
6765
10946
17711
28657
46368
75025
121393
196418
317811
514229
832040
====== Profiling results =======
Time required to print a simple message: 0:00:00.000030
Time required to calculate fibonacci: 0:00:00.642092
Time required to iterate and print the numbers: 0:00:00.000102

根据最后三行,我们可以看到明显的结果:代码中最昂贵的部分是斐波那契数列的实际计算。

小贴士

下载示例代码

您可以从www.packtpub.com下载您购买的所有 Packt 出版物的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

瓶颈在哪里?

一旦你测量了你的代码需要执行的时间,你可以通过特别关注慢速部分来对其进行分析。这些就是瓶颈,通常它们与以下一个或多个原因相关:

  • 重大的 I/O 操作,如读取和解析大文件、执行长时间运行的数据库查询、调用外部服务(如 HTTP 请求)等

  • 意外的内存泄漏开始累积,直到没有内存可供程序正确执行

  • 执行频率高的未优化代码

  • 本可以缓存但未缓存的高强度操作

I/O 受限的代码(文件读写、数据库查询等)通常更难优化,因为这会意味着改变程序处理这些 I/O 的方式(通常使用语言的核心函数)。相反,当优化计算受限的代码(如使用实现不佳的算法的函数)时,获得性能提升更容易(尽管不一定容易)。这是因为它仅仅意味着重写它。

一个普遍的指标表明你接近性能优化过程的尾声,那就是剩下的瓶颈大部分是由于 I/O 受限的代码造成的。

内存消耗和内存泄漏

在开发软件时,另一个需要考虑的重要资源是内存。常规软件开发者并不真正关心它,因为 640 KB RAM 的 PC 时代已经一去不复返了。然而,在长时间运行程序中的内存泄漏可以将任何服务器变成一个 640 KB 的电脑。内存消耗不仅仅是确保你的程序有足够的内存来运行;它还涉及到控制程序使用的内存。

有些发展,如嵌入式系统,实际上要求开发者特别注意他们使用的内存量,因为在那类系统中,内存是一种有限的资源。然而,一个普通的开发者可以预期他们的目标系统将拥有他们所需的 RAM 量。

对于 RAM 和带有自动内存管理(如垃圾回收)的高级语言,开发者不太可能过多关注内存利用率,相信平台会为他们处理。

跟踪内存消耗相对简单。至少对于基本方法,只需使用你的操作系统任务管理器。它将显示,包括其他事项在内,你的程序使用的内存量或至少是程序使用的总内存百分比。任务管理器也是一个检查你的 CPU 时间消耗的绝佳工具。正如你在下一张截图中所见,一个简单的 Python 程序(前面的那个)几乎占用了全部的 CPU 功率(99.8%),而总共只有 0.1%的可用内存:

内存消耗和内存泄漏

使用这样的工具(Linux 的top命令行工具),发现内存泄漏可以很容易,但这将取决于你正在监控的软件类型。如果你的程序不断加载数据,它的内存消耗率将不同于不需要处理太多外部资源的另一个程序。

例如,如果我们绘制一个处理大量外部数据的程序随时间变化的内存消耗图表,它看起来会像以下这样的图表:

内存消耗和内存泄漏

当这些资源完全加载到内存中时,会出现峰值,但当这些资源被释放时,也会出现一些下降。尽管内存消耗数值波动很大,但仍然可以估计程序在没有加载资源时将使用的平均内存量。一旦你定义了那个区域(前面图表中标记为绿色框的区域),你就可以发现内存泄漏。

让我们看看如果资源管理不当(没有完全释放分配的内存),同样的图表会是什么样子:

内存消耗和内存泄漏

在前面的图表中,你可以清楚地看到,当资源不再使用时,并非所有内存都会被释放,这导致线条移出了绿色框。这意味着程序每秒都在消耗越来越多的内存,即使加载的资源已经释放。

对于不是资源密集型的程序,例如执行特定处理任务相当长一段时间的脚本,也可以这样做。在这些情况下,内存消耗和泄漏应该更容易被发现。

让我们来看一个例子:

内存消耗和内存泄漏

当处理阶段开始时,内存消耗应该在明确定义的范围内稳定。如果我们发现超出该范围的数值,尤其是如果它出去后不再回来,我们就在看另一个内存泄漏的例子。

让我们来看一个这样的例子:

内存消耗和内存泄漏

提前优化的风险

优化通常被认为是一种良好的实践。然而,当优化行为最终驱动软件解决方案的设计决策时,这一点并不成立。

开发者在开始编写新软件时常常会遇到的一个常见陷阱是过早优化。

当这种情况发生时,最终结果往往与预期的优化代码相反。它可能包含所需解决方案的不完整版本,或者甚至包含来自优化驱动的设计决策的错误。

作为一条常规规则,如果你还没有测量(分析)你的代码,优化它可能不是最好的主意。首先,关注可读的代码。然后,分析它并找出真正的瓶颈,最后,进行实际的优化。

运行时间复杂度

在分析代码和优化时,真正重要的是要理解运行时间复杂度RTC)是什么,以及我们如何利用这些知识来正确优化我们的代码。

RTC 通过提供任何给定输入下代码执行时间的数学近似来量化给定算法的执行时间。这是一个近似值,因为这样我们能够使用该值将类似的算法分组。

RTC 使用称为 大 O 符号 的东西来表示。在数学中,大 O 符号用于表示当项趋于无穷大时给定函数的极限行为。如果我在计算机科学中应用这个概念,我们可以使用大 O 符号来表示描述执行时间的函数的极限行为。

换句话说,这种表示法将给我们一个大致的概念,了解我们的算法处理任意大输入将需要多长时间。然而,它不会给我们执行时间的精确数字,这需要更深入地分析源代码。

正如我之前所说的,我们可以利用这种趋势来分组算法。以下是一些最常见的分组:

常数时间 – O(1)

这是最简单的一种。这种表示法基本上意味着我们正在测量的动作将始终花费固定的时间,并且这个时间不依赖于输入的大小。

以下是一些具有 O(1) 执行时间的代码示例:

  • 判断一个数字是奇数还是偶数:

    if number % 2:
      odd = True 
    else:
      odd = False
    
  • 将消息打印到标准输出:

    print "Hello world!"
    

即使是更概念上复杂的任务,比如在字典(或哈希表)中查找键的值,如果实现正确,也可以在常数时间内完成。从技术上讲,访问哈希表中的元素需要 O(1) 平摊时间,这大致意味着每个操作的平均时间(不考虑边缘情况)是一个常数 O(1) 时间。

线性时间 – O(n)

线性时间规定,对于任意长度为 n 的给定输入,算法执行所需的时间与 n 成线性比例,例如,3n4n + 5,等等。

线性时间 – O(n)

前面的图表清楚地显示,当x趋向于无穷大时,蓝色(3n)线和红色线(4n + 5)与黑色线(n)具有相同的上限。因此,为了简化,我们可以说这三个函数都是O(n)

具有这种执行顺序的算法示例:

  • 在一个未排序的列表中寻找最小值

  • 比较两个字符串

  • 删除链表中的最后一个项目

对数时间 – O(log n)

具有对数执行时间的算法将具有一个非常确定的最高限时间。对数函数最初增长得很快,但随着输入规模的增大,它会减慢。它永远不会停止增长,但增长的量将非常小,以至于无关紧要。

对数时间 – O(log n)

前面的图表显示了三个不同的对数函数。你可以清楚地看到,它们都具有相似的形状,包括上限x,它不断增加到无穷大。

一些具有对数执行时间的算法示例:

  • 二分搜索

  • 计算斐波那契数(使用矩阵乘法)

对数时间 – O(nlog n)

前两个执行顺序的特定组合是线性对数时间。当x的值开始增加时,它增长得很快。

这里有一些具有这种执行顺序的算法示例:

  • 归并排序

  • 堆排序

  • 快速排序(至少是其平均时间复杂度)

让我们看看一些绘制出的线性对数函数,以更好地理解它们:

线性对数时间 – O(nlog n)

阶乘时间 – O(n!)

阶乘时间是算法可能得到的最糟糕的执行时间之一。它增长得如此之快,以至于很难绘制。

这里是算法执行时间在阶乘时间下的粗略近似:

阶乘时间 – O(n!)

一个具有阶乘执行时间的算法示例是使用穷举搜索解决旅行商问题(基本上是检查每一个可能的解决方案)。

二次时间 – O(n^)

二次执行时间是另一个快速增长的算法示例。输入规模越大,所需时间越长(这在大多数复杂度中都是如此,但特别对于这个来说更是如此)。二次执行时间甚至比对数时间效率更低。

一些具有这种执行顺序的算法示例:

  • 冒泡排序

  • 遍历一个二维数组

  • 插入排序

这里有一些绘制出的指数函数示例:

二次时间 – O(n^)

最后,让我们看看所有绘制在一起以获得算法效率的清晰概念:

二次时间 – O(n^)

除去恒定执行时间,这显然更快,但在复杂算法中通常难以实现,顺序或偏好应该是:

  • 对数

  • 线性

  • 线性对数

  • 二次

  • 阶乘

显然,在某些情况下,你将别无选择,只能得到二次执行时间作为最佳结果。目标是始终追求更快的算法,但你的问题和技术的限制将影响实际结果。

注意

注意,在二次和阶乘时间之间,还有其他几种替代方案(立方、n⁴ 等)。

另一个重要的考虑因素是,大多数算法不仅仅有一个执行时间顺序。它们可以有高达三个执行时间顺序:对于最佳情况、正常情况和最坏情况场景。场景由输入数据的属性决定。例如,如果输入已经排序(最佳情况),插入排序算法将运行得更快,而对于其他类型的输入,它将是最差的(指数级)。

值得关注的其他有趣案例是所使用的数据类型。它们本质上与你可以对其执行的操作(查找、插入、搜索等)相关的执行时间相关联。让我们看看一些最常见的数据类型及其相关操作:

数据结构 时间复杂度
平均情况
索引
列表 O(1)
链表 O(n)
双链表 O(n)
字典 -
二叉搜索树 O(log(n))

性能分析最佳实践

性能分析是一个重复性的任务。你需要在同一个项目中多次进行性能分析以获得最佳结果,并在下一个项目中再次进行。就像软件开发中的任何其他重复性任务一样,有一套最佳实践可以遵循,以确保你从过程中获得最大收益。让我们看看其中的一些:

构建回归测试套件

在开始任何类型的优化过程之前,你需要确保你对代码所做的更改不会以不良的方式影响其功能。特别是当代码库很大时,最好的方法就是创建一个测试套件。确保你的代码覆盖率足够高,以便提供你进行更改所需的信心。60%的代码覆盖率可能导致非常糟糕的结果。

一个回归测试套件将允许你进行尽可能多的优化尝试,而不用担心破坏代码。

注意你的代码

函数式代码通常更容易重构,主要是因为以这种方式组织的函数往往避免了副作用。这减少了影响系统不需要的部分的风险。如果你的函数避免了局部可变状态,那对你来说又是一个加分项。这是因为代码应该对你来说非常直观,易于理解和修改。不遵循前面提到的指南的函数在重构时将需要更多的工作和关注。

保持耐心

分析过程既不快,也不容易,也不是一个精确的过程。这意味着你不应该期望只运行分析器,并期望从它那里直接得到指向问题的数据。是的,这种情况可能发生。然而,大多数情况下,你试图解决的问题正是简单调试无法解决的。这意味着你将浏览数据,绘制图表以试图理解它,并缩小问题的根源,直到你需要重新开始,或者找到它。

请记住,你越深入分析所分析的数据,你就越深入到兔子洞中。数字会立即失去意义,所以请确保你在开始之前知道自己在做什么,并且拥有完成这项工作的正确工具。否则,你会浪费时间,最终只会感到沮丧。

收集尽可能多的数据

根据你处理的软件的类型和大小,你可能在开始分析之前想要尽可能多地收集数据。分析器是这一点的绝佳来源。然而,还有其他来源,例如网络应用程序的服务器日志、自定义日志、系统资源快照(如来自操作系统任务管理器)等。

预处理你的数据

在你从分析器、日志和其他来源收集到所有信息之后,你可能会在分析之前需要预处理这些数据。不要因为分析器无法理解非结构化数据就回避它。你的数据分析将受益于额外的数字。

例如,如果你正在分析一个网络应用程序,获取网络服务器日志是一个很好的主意,但这些文件通常是每条请求一行文本的文件。通过解析它并将数据导入某种数据库系统(如 MongoDB、MySQL 等),你将能够赋予这些数据意义(通过解析日期,根据源 IP 地址进行地理位置定位等),并在之后查询这些信息。

这个阶段的正式名称是 ETL,代表从其来源提取数据,将其转换成有意义的格式,并将其加载到另一个可以稍后查询的系统

可视化你的数据

如果你不知道你具体在寻找什么,只是想在你遇到问题之前优化你的代码,一个很好的想法是可视化你已经预处理的数据。计算机擅长处理数字,但另一方面,当我们想要寻找模式并理解我们能从我们所拥有的信息中收集到什么样的洞察时,人类更擅长处理图像。

例如,继续以 Web 服务器日志为例,一个简单的图表(比如你可以用 MS Excel 做的)展示每小时请求情况,可以为你提供一些关于用户行为的洞察:

可视化你的数据

前面的图表清楚地显示,大多数请求都是在下午晚些时候进行的,并持续到夜间。你可以利用这个洞察来进一步进行配置分析。例如,这里的一个可选改进是在那个时间为你基础设施提供更多资源(这可以通过像亚马逊云服务这样的服务提供商来完成)。

另一个例子,使用自定义分析数据,可能是以下图表:

可视化你的数据

它使用本章第一个代码示例中的数据,通过计算触发profile函数的每个事件的次数。然后我们可以绘制它,并了解最常见的活动。在我们的例子中,callreturn事件肯定占用了我们程序的大部分时间。

摘要

在本章中,我们介绍了分析的基础知识。你理解了分析及其重要性。你还学习了如何利用它来最大限度地发挥我们代码的潜力。

在下一章中,我们将通过查看一些 Python 分析器和如何在我们的应用程序中使用它们来开始“动手实践”。

第二章。分析器

在上一章中,我们介绍了分析的基础知识,并了解了它的重要性。你学习了如果我们将分析实践纳入开发周期,它将如何帮助开发过程。我们还讨论了一些良好的分析实践。

最后,我们讨论了程序可能具有的不同执行时间的理论。在本章中,我们将使用第一部分(关于分析的部分)。然后,借助两个特定的 Python 分析器(cProfileline_profiler),我们将开始将你所学的一些理论付诸实践。

在本章中,我们将涵盖以下主题:

  • 每个分析器的基本信息

  • 如何下载和安装每个分析器

  • 不同选项的使用案例

  • 两个分析器之间的差异和相似之处

认识我们的新好朋友:分析器

在上一章的所有理论和通用示例之后,现在是时候看看一些真正的 Python 了。所以,让我们从两个最知名和最常用的 Python 分析器开始:cProfileline_profiler。它们将帮助我们以两种不同的方式分析代码。

一方面,我们有 cProfile (docs.python.org/2/library/profile.html#module-cProfile),自 Python 2.5 版本起就默认包含在 Python 中,并且是大多数用例推荐的分析器。至少这是官方 Python 文档对它的描述。另一方面,我们有 line_profiler (github.com/rkern/line_profiler),它不是 Python 编程语言的官方部分,但它是那里广为人知的一个分析器。

让我们更详细地了解这两个分析器。

cProfile

如我之前提到的,cProfile 自 Python 2.5 版本起就默认包含在标准 Python 解释器(cPython)中。其他版本,如 PyPy,则没有这个功能。它是一个确定性分析器。它提供了一套 API,允许开发者收集有关 Python 程序执行的信息,更具体地说,是关于每个函数使用的 CPU 时间。它还提供了其他详细信息,例如一个函数被调用的次数。

它仅测量 CPU 时间,并不关注内存消耗和其他内存相关统计信息。尽管如此,它是一个很好的起点,因为大多数时候,如果我们试图优化代码,这种分析将提供一组立即的优化候选者。

由于它已经是语言的一部分,无需安装。要使用它,你只需导入 cProfile 包即可。

注意

确定性分析器只是基于事件的分析器(详情请参阅上一章)的另一个名称。这意味着这个分析器将知道我们代码执行过程中的每一个函数调用、返回语句和其他事件。它还会测量这段时间内发生的所有事情(与我们在上一章中看到的统计分析器不同)。

这里是一个来自 Python 文档的非常简单的例子:

import cProfile
import re
cProfile.run('re.compile("foo|bar")')

上一段代码输出了以下文本:

    197 function calls (192 primitive calls) in 0.002 seconds

Ordered by: standard name

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    0.000    0.000    0.001    0.001 <string>:1(<module>)
     1    0.000    0.000    0.001    0.001 re.py:212(compile)
     1    0.000    0.000    0.001    0.001 re.py:268(_compile)
     1    0.000    0.000    0.000    0.000 sre_compile.py:172(_compile_charset)
     1    0.000    0.000    0.000    0.000 sre_compile.py:201(_optimize_charset)
     4    0.000    0.000    0.000    0.000 sre_compile.py:25(_identityfunction)
   3/1    0.000    0.000    0.000    0.000 sre_compile.py:33(_compile)

从这个输出中,可以收集以下信息:

  • 第一行告诉我们,共监视了 197 个函数调用,其中 192 个是原始调用,这意味着没有涉及递归。

  • ncalls报告对函数的调用次数。如果这一列有两个数字,这意味着存在递归。第二个数字是原始调用的次数,第一个数字是总调用次数。这个数字可以帮助识别可能的错误(意外的过高数字)或可能的内联扩展点。

  • tottime是在函数内部花费的总时间(不包括调用其他函数的子调用的时间)。这个特定的信息可以帮助开发者找到可能被优化的长时间运行的循环。

  • percall只是tottime除以ncalls的商。

  • cumtime是在函数内部花费的总时间,包括在子函数中花费的时间(这包括递归调用)。这个数字可以帮助识别更高层次的错误,例如算法选择中的错误。

  • percallcumtime除以原始调用的商。

  • filename:lineno(function)提供了分析函数的文件名、行号和函数名。

关于限制的说明

没有这样的东西叫做不可见分析器。这意味着即使在cProfile这种开销非常小的案例中,它仍然会给我们的代码增加开销。在每次触发事件时,事件实际发生的时间和性能分析器查询内部时钟状态的时间之间会有一些延迟。同时,当程序计数器离开性能分析器的代码并返回用户代码以继续执行时,也会有一些延迟。

除此之外,作为计算机内部的数据,内部时钟有一个固定的精度,任何小于这个精度的测量都会丢失。因此,当开发者使用具有大量递归调用或特定情况下函数调用许多其他函数的代码进行性能分析时,需要特别注意,因为这种误差可能会累积并变得显著。

提供的 API

cProfile分析器提供了一套方法,可以帮助开发者在不同上下文中收集统计数据:

run(command, filename=None, sort=-1)

在前一个示例中使用的方法是一个经典方法,用于收集命令执行的统计数据。之后,它调用以下函数:

exec(command, __main__.__dict__, __main__.__dict__)

如果没有提供文件名,它将创建一个新的stats实例(关于这个类的更多内容,请稍后了解)。以下是前一个相同的示例,但使用了额外的参数:

import cProfile
import re
cProfile.run('re.compile("foo|bar")', 'stats', 'cumtime')

如果你运行前面的代码,你会注意到没有任何内容被打印出来。然而,如果你检查文件夹的内容,你会注意到一个名为stats的新文件。如果你尝试打开该文件,你将无法理解其含义,因为它是以二进制格式保存的。在几分钟内,我们将看到如何读取这些信息并对其进行操作以创建我们自己的报告:

runctx(command, globals, locals, filename=None)

此方法与前一个方法非常相似。唯一的区别是它还接收命令行字符串的globalslocals字典。之后,它执行以下函数:

exec(command, globals, locals)

它收集分析统计数据,就像run一样。让我们看看runrunctx之间主要区别的示例。

让我们坚持使用run并编写以下代码:

import cProfile
def runRe():
    import re 
    cProfile.run('re.compile("foo|bar")')
runRe()

实际运行代码时,我们会得到以下错误消息:

Traceback (most recent call last): 
  File "cprof-test1.py", line 7, in <module> 
    runRe() ...
  File "/usr/lib/python2.7/cProfile.py", line 140, in runctx 
    exec cmd in globals, locals 
  File "<string>", line 1, in <module> 
NameError: name 're' is not defined 

re模块在run方法中找不到,因为我们之前看到run使用__main__.__dict__作为参数调用exec函数。

现在,让我们以下面的方式使用runctx

import cProfile
def runRe():
    import re 
    cProfile.runctx('re.compile("foo|bar")', None, locals())
runRe()

然后,输出将变为以下有效格式:

         194 function calls (189 primitive calls) in 0.000 seconds 
  Ordered by: standard name 
   ncalls  tottime  percall  cumtime  percall filename:lineno(function) 
        1    0.000    0.000    0.000    0.000 <string>:1(<module>) 
        1    0.000    0.000    0.000    0.000 re.py:188(compile) 
        1    0.000    0.000    0.000    0.000 re.py:226(_compile) 
        1    0.000    0.000    0.000    0.000 sre_compile.py:178(_compile_charset) 
        1    0.000    0.000    0.000    0.000 sre_compile.py:207(_optimize_charset) 
        4    0.000    0.000    0.000    0.000 sre_compile.py:24(_identityfunction) 
      3/1    0.000    0.000    0.000    0.000 sre_compile.py:32(_compile) 
        1    0.000    0.000    0.000    0.000 sre_compile.py:359(_compile_info) 
        2    0.000    0.000    0.000    0.000 sre_compile.py:472(isstring) 
        1    0.000    0.000    0.000    0.000 sre_compile.py:478(_code) 
        1    0.000    0.000    0.000    0.000 sre_compile.py:493(compile) 
        5    0.000    0.000    0.000    0.000 sre_parse.py:126(__len__) 
       12    0.000    0.000    0.000    0.000 sre_parse.py:130(__getitem__) 
        7    0.000    0.000    0.000    0.000 sre_parse.py:138(append) 
      3/1    0.000    0.000    0.000    0.000 sre_parse.py:140(getwidth) 
        1    0.000    0.000    0.000    0.000 sre_parse.py:178(__init__) 
       10    0.000    0.000    0.000    0.000 sre_parse.py:182(__next) 
        2    0.000    0.000    0.000    0.000 sre_parse.py:195(match) 
        8    0.000    0.000    0.000    0.000 sre_parse.py:201(get) 
        1    0.000    0.000    0.000    0.000 sre_parse.py:301(_parse_sub) 
        2    0.000    0.000    0.000    0.000 sre_parse.py:379(_parse) 
        1    0.000    0.000    0.000    0.000 sre_parse.py:67(__init__) 
        1    0.000    0.000    0.000    0.000 sre_parse.py:675(parse) 
        3    0.000    0.000    0.000    0.000 sre_parse.py:90(__init__) 
        1    0.000    0.000    0.000    0.000 {_sre.compile} 
       15    0.000    0.000    0.000    0.000 {isinstance} 
    38/37    0.000    0.000    0.000    0.000 {len} 
        2    0.000    0.000    0.000    0.000 {max} 
       48    0.000    0.000    0.000    0.000 {method 'append' of 'list' objects} 
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects} 
        1    0.000    0.000    0.000    0.000 {method 'get' of 'dict' objects} 
        1    0.000    0.000    0.000    0.000 {method 'items' of 'dict' objects} 
        8    0.000    0.000    0.000    0.000 {min} 
        6    0.000    0.000    0.000    0.000 {ord} 

Profile(timer=None, timeunit=0.0, subcalls=True, builtins=True)方法返回一个类,在分析过程中比runrunctx提供了更多的控制。

timer参数是一个自定义函数,可以用来以不同于默认方式测量时间。它必须是一个返回表示当前时间的数字的函数。如果开发者需要自定义函数,它应该尽可能快,以降低开销并避免校准问题(请参阅几页前的关于限制的说明部分)。

如果计时器返回的数字是整数,则timeunit参数指定表示每个时间单位持续时间的乘数。例如,如果返回的值以毫秒为单位,则timeunit将是.001

让我们也看看返回类提供的方法:

  • enable(): 这开始收集分析数据

  • disable(): 这停止收集分析数据

  • create_stats(): 这停止收集数据并将收集到的信息记录为当前配置文件

  • print_stats(sort=-1): 这创建一个stats对象并将结果打印到STDOUT

  • dump_stats(filename): 这将当前配置文件的内容写入文件

  • run(cmd): 这与之前看到的run函数相同

  • runctx(cmd, globals, locals): 这与之前看到的runctx函数相同

  • runcall(func, *args, **kwargs): 这收集被调用的函数的分析信息

让我们看看前面的例子,这次使用以下方法:

import cProfile

def runRe():
    import re
    re.compile("foo|bar")

prof = cProfile.Profile() 
prof.enable() 
runRe()
prof.create_stats()
prof.print_stats()

为了启动分析,涉及更多的行,但这对原始代码的侵入性明显较小。当尝试分析已经编写和测试的代码时,这是一个优点。这样,我们可以在不修改原始代码的情况下添加和删除我们的分析代码。

有一个更不侵入的替代方案,它不涉及添加任何代码,而是在运行脚本时使用一些特定的命令行参数:

$ python -m cProfile your_script.py -o your_script.profile

注意,这将分析整个代码,所以如果你实际上只是分析脚本的一个特定部分,前面的方法不会返回相同的结果。

现在,在进入更详细和有趣的例子之前,让我们首先看看Stats类,并了解它能为我们做什么。

Stats

pstats模块为开发者提供了Stats类,它反过来允许他们读取和操作stats文件的内容(我们使用前面描述的方法之一保存到该文件中的分析信息)。

例如,以下代码加载stats文件并打印出排序后的统计信息:

import pstats
p = pstats.Stats('stats')
p.strip_dirs().sort_stats(-1).print_stats()

注意

注意,Stats类构造函数能够接收一个cProfile.Profile实例作为数据源,而不是文件名。

让我们更仔细地看看pstats.Stats类提供的方法:

  • strip_dirs(): 此方法从报告中文件名的所有前缀路径信息中删除。此方法修改了stats实例,因此任何执行了此方法的实例都将被视为其项目顺序是随机的。如果两个条目被认为是相同的(同一文件名上的同一行具有相同的函数名),则这些条目将被累积。

  • add(*filenames): 此方法将更多信息加载到stats中,来自文件名中引用的文件。值得注意的是,就像只有一个文件一样,引用相同函数(文件名、行和函数名)的stats条目将被累积。

  • dump_stats(filename): 就像在cProfile.Profile类中一样,此方法将加载到Stats类中的数据保存到文件中。

  • sort_stats(*keys): 此方法自 2.3 版本以来一直存在,它通过给定的标准对stats对象中的条目进行排序。当给出多个标准时,额外的标准仅在先前标准相等时才会使用。例如,如果使用sort_stats ('name', 'file'),则将所有条目按函数名称排序,当该名称相同时,将按文件名对这些条目进行排序。

该方法足够智能,可以理解只要它们是不含糊的缩写,所以请小心。目前支持的排序标准完整列表如下:

标准 含义 升序/降序
calls 调用总数 降序
cumulative 累计时间 降序
cumtime 累计时间 降序
file 文件名 升序
filename 文件名 升序
module 文件名 升序
ncalls 调用总数 降序
pcalls 原始调用计数 降序
line 行号 升序
name 函数名 升序
nfl 名称/文件/行组合 降序
stdname 标准名称 升序
time 内部时间 降序
tottime 内部时间 降序

注意

关于 nfl 与 stdname 的注意事项

这两种排序类型之间的主要区别在于后者是对打印名称的排序。这意味着行号将按字符串排序(这意味着对于 4、20 和 30,排序将是 20、30、4)。nfl排序对行号字段进行数值比较。

最后,出于向后兼容性的原因,一些数值被接受,而不是前面表格中的那些。它们是-1012,分别对应stdnamecallstimecumulative

  • reverse_order():此方法反转所选排序键的默认顺序(因此,如果键是默认升序,现在将是降序)。

  • print_stats(*restrictions):此方法负责将统计信息打印到STDOUT。可选参数旨在限制此函数的输出。它可以是整数值、小数值或字符串。它们在此处解释:

    • integer:这将限制打印的行数

    • Decimal between 0.0 and 1.0 (inclusive):这将选择行百分比

    • String:这是一个与标准名称匹配的正则表达式

    Stats 类

上一张截图显示了调用print_stats方法时得到的输出如下:

import cProfile
import pstats

def runRe():
    import re
    re.compile("foo|bar")
prof = cProfile.Profile()
prof.enable()
runRe()
prof.create_stats()

p = pstats.Stats(prof)
p.print_stats(10, 1.0, '.*.py.*') #print top 10 lines that match the given reg exp.

如果传递了多个参数,则它们将按顺序应用。正如我们在前面的代码行中看到的,这个分析器的输出可以相当长。然而,如果我们正确排序,则可以使用此参数总结输出,同时仍然获得相关信息。

print_callers(*restrictions)函数与之前的输入和限制规则相同,但输出略有不同。对于程序执行期间调用的每个函数,它将显示每个调用被调用的次数、总时间和累计时间,以及文件名、行号和函数名的组合。

让我们看看使用cProfile.ProfileStats如何渲染调用函数列表的快速示例:

import cProfile
import pstats

def runRe():
    import re
    re.compile("foo|bar")
prof = cProfile.Profile()
prof.enable()
runRe()
prof.create_stats()

p = pstats.Stats(prof)
p.print_callers()

注意我们是如何将pstats.Stats类与cProfile.Profile类结合使用的。它们共同工作,以我们所需的方式收集和展示信息。现在,看看输出:

Stats 类

print_callees(*restrictions)方法打印调用其他函数的函数列表。显示的数据格式和限制与前面的示例相同。

你可能会在输出中遇到如下截图所示的块:

Stats 类

这个输出意味着右侧的函数是由左侧的相同函数调用的。

性能分析示例

现在我们已经看到了如何使用cProfileStats的基本方法,让我们深入研究一些更有趣和实用的例子。

斐波那契再次

让我们回到斐波那契的例子,因为基本的递归斐波那契序列计算器有很多改进的空间。

让我们先看看未经性能分析、未经优化的代码:

import profile

def fib(n):
    if n <= 1:
  return n
    else:
        return fib(n-1) + fib(n-2)

def fib_seq(n):
    seq = [ ]
    if n > 0:
        seq.extend(fib_seq(n-1))
    seq.append(fib(n))
    return seq

profile.run('print fib_seq(20); print')

这段代码将输出以下结果:

斐波那契再次

输出打印正确,但请看前面截图中的高亮部分。这些部分在这里解释:

  • 在那 0.114 秒内,有 57.356 次函数调用

  • 其中只有 66 个是原始调用(不是通过递归调用)

  • 在我们代码的第 3 行,57.270(57.291—21)是递归引起的函数调用

如我们所知,调用另一个函数会增加我们的时间开销。由于(对于cumtime列)大多数执行时间似乎都花在这个函数内部,我们可以安全地假设,如果我们加快这个速度,整个脚本的时间也会受到影响。

现在,让我们给fib函数应用一个简单的装饰器,这样我们就可以缓存之前计算过的值(这种技术也称为记忆化,你将在接下来的章节中了解到)以便我们不必对每个值调用 fib 多次:

import profile

class cached:
    def __init__(self, fn):
        self.fn = fn
        self.cache = {}

    def __call__(self, *args):
        try:
            return self.cache[args]
        except KeyError:
            self.cache[args] = self.fn(*args)
            return self.cache[args]

@cached
def fib(n):
    if n <= 1:
        return n
    else:
        return fib(n-1) + fib(n-2)

def fib_seq(n):
    seq = [ ]
    if n > 0: 

        seq.extend(fib_seq(n-1))
    seq.append(fib(n))
    return seq

profile.run('print fib_seq(20); print')

现在,让我们再次运行代码并查看输出:

斐波那契再次

我们从大约 57k 的总调用次数减少到只有 145 次,从 0.114 秒减少到 0.001 秒。这是一个惊人的改进!然而,我们有了更多的原始调用,但我们也有显著更少的递归调用。

让我们继续进行另一个可能的优化。我们的例子对于单个调用来说运行得相当快,但让我们尝试连续运行几次,并获取该执行的组合统计数据。也许我们会得到一些有趣的结果。为此,我们需要使用 stats 模块。让我们看看一个示例:

import cProfile
import pstats
from fibo4 import fib, fib_seq

filenames = []
profiler = cProfile.Profile()
profiler.enable()
for i in range(5):
    print fib_seq(1000); print
profiler.create_stats()
stats = pstats.Stats(profiler)
stats.strip_dirs().sort_stats('cumulative').print_stats()
stats.print_callers()

我们在这里已经达到了极限。获取 1000 的斐波那契序列可能要求过高,尤其是从递归实现中获取。事实上,我们已经达到了递归深度限制。这主要是因为cPython有一个保护机制来防止由递归调用数量产生的栈溢出错误(理想情况下,尾递归优化可以解决这个问题,但cPython没有提供)。因此,我们发现了另一个问题。让我们尝试修复它并重新分析代码:

import profile
def fib(n):
    a, b = 0, 1 
    for i in range(0, n):
        a,b = b, a+b
    return a

def fib_seq(n):
    seq = [ ]
    for i in range(0, n + 1):
        seq.append(fib(i))
    return seq

print fib_seq(1000)

前面的代码行打印了一大堆非常大的数字,但这些行证明了我们已经做到了。现在,我们可以计算数字 1000 的斐波那契数列了。现在,让我们分析一下,看看我们发现了什么。

使用新的分析代码,但需要斐波那契实现的迭代版本,我们将得到以下结果:

import cProfile
import pstats
from fibo_iter import fib, fib_seq

filenames = []
profiler = cProfile.Profile()
profiler.enable()
for i in range(5):
    print fib_seq(1000); print
profiler.create_stats()
stats = pstats.Stats(profiler)
stats.strip_dirs().sort_stats('cumulative').print_stats()
stats.print_callers()

这反过来会在控制台产生以下结果:

Fibonacci again

我们的新代码计算 1000 的斐波那契数列需要 0.187 秒,计算五次。这并不是一个糟糕的数字,但我们知道我们可以通过缓存结果来改进它。正如你所见,我们有 5005 次对fib函数的调用。如果我们缓存它,我们将有更少的函数调用,这意味着更少的执行时间

只需做很少的工作,我们就可以通过缓存对fib函数的调用来提高这个时间,根据前面的报告,这个函数被调用了 5005 次:

import profile

class cached:
    def __init__(self, fn):
        self.fn = fn
        self.cache = {}

    def __call__(self, *args):
        try:
            return self.cache[args]
        except KeyError:
            self.cache[args] = self.fn(*args)
            return self.cache[args]

@cached
def fib(n):
    a, b = 0, 1 
    for i in range(0, n):
        a,b = b, a+b
    return a

def fib_seq(n):
    seq = [ ]
    for i in range(0, n + 1):
        seq.append(fib(i))
    return seq

print fib_seq(1000)

你应该得到以下类似的输出:

Fibonacci again

只需缓存对fib函数的调用,我们就从 0.187 秒缩短到了 0.006 秒。这是一个惊人的改进。做得好!

Tweet stats

让我们看看另一个例子,这个例子在概念上稍微复杂一些,因为计算斐波那契数列并不是一个日常用例。让我们做一些更有趣的事情。如今,Twitter 允许你以 CSV 文件的形式下载你的完整推文列表。我们将使用这个文件来从我们的源生成一些统计数据。

使用提供的数据,我们将计算以下统计数据:

  • 实际回复消息的百分比

  • 从网站(twitter.com)发布的推文的百分比

  • 使用手机发布的推文的百分比

我们脚本的输出将类似于以下截图所示:

Tweet stats

为了保持简单,我们将负责解析 CSV 文件和进行这些基本的计算。我们不会使用任何第三方模块;这样,我们将完全控制代码及其分析。这意味着省略一些明显的事情,比如使用 Python 的 CSV 模块。

之前展示的其他不良做法,例如inc_stat函数或我们在处理之前将整个文件加载到内存中的事实,将提醒你,这只是一个示例,用来展示基本的改进。

这里是脚本的初始代码:

def build_twit_stats():
    STATS_FILE = './files/tweets.csv'
    STATE = {
        'replies': 0,
        'from_web': 0,
        'from_phone': 0,
        'lines_parts': [],
        'total_tweets': 0
    }
    read_data(STATE, STATS_FILE)
    get_stats(STATE)
    print_results(STATE)

def get_percentage(n, total):
    return (n * 100) / total

def read_data(state, source):
    f = open(source, 'r')

    lines = f.read().strip().split("\"\n\"")
    for line in lines:

       state['lines_parts'].append(line.strip().split(',')) 
    state['total_tweets'] = len(lines)

def inc_stat(state, st):
    state[st] += 1

def get_stats(state):
    for i in state['lines_parts']:
        if(i[1] != '""'):
            inc_stat(state, 'replies')
        if(i[4].find('Twitter Web Client') > -1):
            inc_stat(state, 'from_web')
        else:
            inc_stat(state, 'from_phone')

def print_results(state):
    print "-------- My twitter stats -------------"
    print "%s%% of tweets are replies" % (get_percentage(state['replies'], state['total_tweets']))
    print "%s%% of tweets were made from the website" % (get_percentage(state['from_web'], state['total_tweets']))
    print "%s%% of tweets were made from my phone" % (get_percentage(state['from_phone'], state['total_tweets']))

公平地说,这段代码并没有做太多复杂的事情。它加载文件的內容,将其分割成行,然后又将每一行分割成不同的字段。最后,它进行计数。有人可能会认为,有了这个解释,就没有太多可以优化的地方了,但我们将看到,总有优化的空间。

另一个需要注意的重要事项是,我们将处理的 CSV 文件几乎有 150MB 的推文数据。

这里是导入这段代码、使用它并生成性能报告的脚本:

import cProfile
import pstats

from B02088_02_14 import build_twit_stats
profiler = cProfile.Profile()

profiler.enable()

build_twit_stats()

profiler.create_stats()
stats = pstats.Stats(profiler)
stats.strip_dirs().sort_stats('cumulative').print_stats()

这次执行得到的输出如下:

推文统计数据

在前面的屏幕截图中,有三个主要感兴趣的区域:

  1. 总执行时间

  2. 单个函数调用的累积时间

  3. 单个函数的总调用次数

我们的目的是降低总执行时间。为此,我们将特别关注单个函数的累积时间和单个函数的总调用次数。对于最后两点,我们可以得出以下结论:

  • build_twit_stats 函数是耗时最长的函数。然而,正如您在代码的前几行中看到的,它只是调用所有其他函数,所以这是有道理的。我们可以专注于 read_data,因为它是耗时第二多的函数。这很有趣,因为这意味着我们的瓶颈不是在计算统计数据时,而是在加载数据时。

  • 在代码的第三行,我们也可以看到 read_data 中的瓶颈。我们执行了太多的 split 命令,它们加在一起。

  • 我们还看到,第四个最耗时的函数是 get_stats

那么,让我们解决这些问题,看看我们是否能得到更好的结果。我们最大的瓶颈是我们加载数据的方式。我们首先将所有数据加载到内存中,然后迭代它来计算我们的统计数据。我们可以通过逐行读取文件并在每行之后计算统计数据来改进这一点。让我们看看这段代码会是什么样子。

新的 read_data 方法看起来像这样:

  def read_data(state, source):
    f = open(source)

    buffer_parts = []
    for line in f:
      #Multi line tweets are saved in several lines in the file, so we need to
      #take that into account.
      parts = line.split('","')
      buffer_parts += parts
      if len(parts) == 10:
        state['lines_parts'].append(buffer_parts) 
        get_line_stats(state, buffer_parts)
        buffer_parts = []
    state['total_tweets'] = len(state['lines_parts'])

我们不得不添加一些逻辑来考虑多行推文,这些推文也作为多行记录保存在我们的 CSV 文件中。我们将 get_stats 函数更改为 get_line_stats,这简化了其逻辑,因为它只为当前记录计算值:

def get_line_stats(state, line_parts):
  if line_parts[1] != '' :
      state['replies'] += 1
  if 'Twitter Web Client' in line_parts[4]:
      state['from_web'] += 1
  else:
      state['from_phone'] += 1

最后两项改进是移除对 inc_stat 的调用,因为,多亏了我们使用的字典,这个调用是不必要的。我们还用更高效的 in 操作符替换了查找方法的用法。

让我们再次运行代码并查看更改:

推文统计数据

我们从 2 秒减少到 1.6 秒;这是一个相当大的改进。read_data 函数仍然是最耗时的函数之一,但这仅仅是因为它现在也调用了 get_line_stats 函数。我们也可以在这方面进行改进,因为尽管 get_line_stats 函数做得很少,但我们通过在循环中频繁调用它而产生了查找时间。我们可以将这个函数内联,看看是否有所帮助。

新代码将看起来像这样:

def read_data(state, source):
    f = open(source)

    buffer_parts = []
    for line in f:
      #Multi line tweets are saved in several lines in the file, so we need to
      #take that into account.
      parts = line.split('","')
      buffer_parts += parts
      if len(parts) == 10:
        state['lines_parts'].append(buffer_parts) 
        if buffer_parts[1] != '' :
          state['replies'] += 1
        if 'Twitter Web Client' in buffer_parts[4]:
          state['from_web'] += 1
        else:
          state['from_phone'] += 1
        buffer_parts = []
    state['total_tweets'] = len(state['lines_parts'])

现在,随着新更改,报告将看起来像这样:

推文统计数据

第一张截图和前一张截图之间有一个显著的改进。我们将时间降低到略高于 1.4 秒,从 2 秒降至。函数调用的数量也显著降低(从大约 300 万次调用降至 170 万次),这反过来应该有助于降低查找和调用所花费的时间。

作为额外的奖励,我们将通过简化代码来提高代码的可读性。以下是所有代码的最终版本:

def build_twit_stats():
    STATS_FILE = './files/tweets.csv'
    STATE = {
        'replies': 0,
        'from_web': 0,
        'from_phone': 0,
        'lines_parts': [],
        'total_tweets': 0
    }
    read_data(STATE, STATS_FILE)
    print_results(STATE)

def get_percentage(n, total):
    return (n * 100) / total

def read_data(state, source):
    f = open(source)

    buffer_parts = []
    for line in f:
      #Multi line tweets are saved in several lines in the file, so we need to
      #take that into account.
      parts = line.split('","')
      buffer_parts += parts
      if len(parts) == 10:
        state['lines_parts'].append(buffer_parts) 
        if buffer_parts[1] != '' :
          state['replies'] += 1
        if 'Twitter Web Client' in buffer_parts[4]:
          state['from_web'] += 1
        else:
          state['from_phone'] += 1
        buffer_parts = []
    state['total_tweets'] = len(state['lines_parts'])

def print_results(state):
    print "-------- My twitter stats -------------"

    print "%s%% of tweets are replies" % (get_percentage(state['replies'], state['total_tweets']))

    print "%s%% of tweets were made from the website" % (get_percentage(state['from_web'], state['total_tweets']))

    print "%s%% of tweets were made from my phone" % (get_percentage(state['from_phone'], state['total_tweets']))

对于cProfile的回顾就到这里。通过它,我们成功地分析了脚本,得到了每个函数的数字和总函数调用次数。它帮助我们改进了对系统的整体视图。现在我们将查看一个不同的分析器,它将提供cProfile无法提供的每行细节。

line_profiler

这个分析器与cProfile不同。它帮助你逐行分析函数,而不是像其他分析器那样进行确定性分析。

要安装这个分析器,你可以使用 pip (pypi.python.org/pypi/pip) 命令行工具,以下是一个命令:

$ pip install line_profiler

注意

如果你在安装过程中遇到任何问题,例如缺少文件,请确保你已经安装了所有开发依赖项。在 Ubuntu 的情况下,你可以通过运行以下命令来确保所有依赖项都已安装:

$ sudo apt-get install python-dev libxml2-dev libxslt-dev

这个分析器试图填补cProfile和其他类似工具留下的空白。其他分析器覆盖了函数调用上的 CPU 时间。大多数情况下,这已经足够捕捉到问题并进行修复(我们之前已经看到过)。然而,有时问题或瓶颈与函数中的一行特定代码相关,这就是line_profiler发挥作用的地方。

作者推荐我们使用kernprof工具,因此我们将查看它的示例。Kernprof 将创建一个分析器实例,并将其以profile的名称插入到__builtins__命名空间中。分析器被设计成用作装饰器,因此你可以装饰任何你想要的函数,并且它会为每一行计时。

这是我们执行分析器的方式:

$ kernprof -l script_to_profile.py

装饰过的函数已经准备好进行分析了:

@profile
def fib(n):
    a, b = 0, 1 
    for i in range(0, n):
        a,b = b, a+b
    return a

默认情况下,kernprof会将结果保存到名为script_to_profile.py.lprof的文件中,但你可以使用-v属性来告诉它立即显示结果:

$ kernprof -l -v script_to_profile.py

这里有一个简单的输出示例,以帮助您理解您将看到的内容:

line_profiler

输出包含函数的每一行,旁边是时间信息。共有六列信息,以下是它们的含义:

  • 行号:这是文件内的行号。

  • 命中次数:这是在分析期间此行被执行的次数。

  • 时间: 这是该行的总执行时间,以计时器单位指定。在结果表之前的信息中,你会注意到一个名为 计时器单位 的字段,那个数字是转换为秒的转换因子(要计算实际时间,你必须将时间 x 计时器单位)。在不同的系统上可能会有所不同。

  • Per hit: 执行该行代码的平均时间。这也在计时器的单位中指定。

  • % 时间: 执行该行代码所花费的时间百分比,相对于整个函数执行的总时间。

如果你正在构建另一个利用 line_profiler 的工具,有两种方式让它知道要分析哪些函数:使用构造函数和使用 add_function 方法。

line_profiler 也提供了与 cProfile.Profile 相同的 runrunctxruncallenabledisable 方法。然而,在嵌套时最后两个并不安全,所以请小心。分析完成后,你可以使用 dump_stats(filename) 方法将 stats 输出到文件,或者使用 print_stats([stream]) 方法打印它们。它将结果打印到 sys.stdout 或你作为参数传递的任何其他流。

这里是之前相同函数的一个例子。这次,函数是使用 line_profiler API 进行分析的:

import line_profiler
import sys

def test():
    for i in range(0, 10):
        print i**2
    print "End of the function"

prof = line_profiler.LineProfiler(test) #pass in the function to profile

prof.enable() #start profiling
test()
prof.disable() #stop profiling

prof.print_stats(sys.stdout) #print out the results

kernprof

kernprof 是与 line_profiler 一起打包的剖析工具,允许我们将大部分剖析代码从我们的源代码中抽象出来。这意味着我们可以像之前看到的那样使用它来分析我们的应用程序。kernprof 会为我们做几件事情:

  • 它可以与 cProfilelsprof 以及甚至 profile 模块一起工作,具体取决于哪个可用。

  • 它会正确地找到我们的脚本。如果脚本不在当前文件夹中,它甚至会检查 PATH 变量。

  • 它会实例化并将剖析器插入到 __builtins__ 命名空间中,名称为 profile。这将允许我们在代码中使用剖析器。在 line_profiler 的情况下,我们甚至可以用作装饰器而无需担心导入任何内容。

  • 带有分析 stats 的输出文件可以使用 pstats.Stats 类查看,甚至可以从命令行如下查看:

    $ python -m pstats stats_file.py.prof
    
    

    或者,在 lprof 文件的情况下:

    $ python -m line_profiler stats_file.py.lprof
    
    

关于 kernprof 需要考虑的一些事项

在阅读 kernprof 的输出时,有一些事情需要考虑。在某些情况下,输出可能令人困惑,或者数字可能不匹配。以下是一些最常见问题的答案:

  • 当分析函数调用另一个分析函数时,按行时间不等于总时间:当分析一个被另一个分析函数调用的函数时,有时数字可能不会相加。这是因为 kernprof 只记录函数内部花费的时间,并试图避免测量分析器本身添加的任何开销,如下面的截图所示:关于 kernprof 需要考虑的一些事项

    前面的截图展示了这个例子。根据分析器,printI 函数耗时 0.010539 秒。然而,在 test 函数内部,似乎总共花费了 19567 个计时器单位,相当于 0.019567 秒。

  • 列表推导式行在报告中命中次数远多于应有的数量:这基本上是因为报告在表达式中每迭代一次就增加一个命中。以下是一个例子:关于 kernprof 需要考虑的一些事项

你可以看到实际的代码行有 102 次命中,每次调用 printExpression 函数时命中 2 次,其余 100 次是由于使用的范围导致的。

分析示例

现在我们已经了解了如何使用 line_profilerkernprof 的基础知识,让我们通过更有趣的例子来实际操作。

回到斐波那契数列

是的,让我们再次分析我们的原始斐波那契代码。比较两个分析器的输出将有助于了解它们的工作方式。

让我们先看看这个新分析器的输出:

回到斐波那契数列

在报告中的所有数字中,我们可以放心,计时并不是问题。在 fib 函数内部,没有任何一行代码耗时过长(也不应该)。在 fib_seq 中,只有一行,但这是因为 fib 函数内部的递归导致的。

因此,我们的问题(正如我们已经知道的)是递归和执行 fib 函数的次数(确切地说,是 57,291 次)。每次我们进行函数调用,解释器都必须通过名称进行查找然后执行函数。每次我们调用 fib 函数,就会再调用两个函数。

第一个想到的是降低递归调用的次数。我们可以将其重写为迭代版本,或者像之前那样通过添加缓存的装饰器进行快速修复。我们可以在以下报告中看到结果:

回到斐波那契数列

fib 函数的命中次数从 57,291 次减少到 21。这又是一个证明,在这个情况下,缓存的装饰器是一个很好的优化。

倒排索引

我们不再重复第二个例子,而是看看另一个问题:创建倒排索引 (en.wikipedia.org/wiki/inverted_index)。

倒排索引是许多搜索引擎用来同时查找多个文件中单词的资源。它们的工作方式是预先扫描所有文件,将它们的内容分割成单词,然后保存这些单词与文件之间的关系(有些甚至保存单词的位置)。这样,当对特定单词进行搜索时,搜索时间可以达到O(1)(常数)。

让我们看看一个简单的例子:

//With these files:
file1.txt = "This is a file"
file2.txt = "This is another file"
//We get the following index:
This, (file1.txt, 0), (file2.txt, 0)
is, (file1.txt, 5), (file2.txt, 5)
a, (file1.txt, 8)
another, (file2.txt, 8)
file, (file1.txt, 10), (file2.txt, 16)

因此,如果我们现在要查找单词file,我们知道它在两个文件中(在不同的位置)。让我们看看计算这个索引的代码(再次,以下代码的目的是展示经典的改进机会,所以请继续跟随我们,直到我们看到代码的优化版本):

#!/usr/bin/env python

import sys
import os
import glob

def getFileNames(folder):
  return glob.glob("%s/*.txt" % folder)

def getOffsetUpToWord(words, index):
  if not index:
    return 0
    subList = words[0:index]
    length = sum(len(w) for w in subList)
    return length + index + 1

def getWords(content, filename, wordIndexDict):
  STRIP_CHARS = ",.\t\n |"
  currentOffset = 0

  for line in content:
    line = line.strip(STRIP_CHARS)
    localWords = line.split()
    for (idx, word) in enumerate(localWords):
      word = word.strip(STRIP_CHARS)
      if word not in wordIndexDict:
        wordIndexDict[word] = []

      line_offset = getOffsetUpToWord(localWords, idx) 
      index = (line_offset) + currentOffset
      currentOffset = index 
      wordIndexDict[word].append([filename, index])

  return wordIndexDict

def readFileContent(filepath):
    f = open(filepath, 'r')
    return f.read().split( ' ' )

def list2dict(list):
  res = {}
  for item in list:
    if item[0] not in res:
      res[item[0]] = []
    res[item[0]].append(item[1])
  return res

def saveIndex(index):
  lines = []
  for word in index:
    indexLine = ""
    glue = ""
    for filename in index[word]:
      indexLine += "%s(%s, %s)" % (glue, filename, ','.join(map(str, index[word][filename])))
     glue = ","
    lines.append("%s, %s" % (word, indexLine))

  f = open("index-file.txt", "w")
  f.write("\n".join(lines))
  f.close()

def __start__():
  files = getFileNames('./files')
  words = {}
  for f in files:
    content = readFileContent(f)
    words = getWords(content, f, words)
  for word in (words):
    words[word] = list2dict(words[word])
  saveIndex(words)

__start__()

前面的代码尽可能简单。它能够处理简单的.txt文件,这正是我们目前想要的。它将加载文件文件夹内的所有.txt文件,将它们的内容分割成单词,并计算这些单词在文档中的偏移量。最后,它将所有这些信息保存到一个名为index-file.txt的文件中。

因此,让我们开始分析并看看我们能得到什么。由于我们并不确切知道哪些是重负载函数,哪些是轻负载函数,让我们给所有这些函数都添加@profile装饰器并运行分析器。

getOffsetUpToWord

getOffsetUpToWord函数看起来是一个很好的优化候选,因为它在执行过程中被调用了好几次。我们现在暂时保留这个装饰器。

getOffsetUpToWord

getWords

getWords函数做了很多处理。它甚至有两个嵌套的for循环,所以我们也保留这个装饰器。

getWords

list2dict

list2dict函数负责获取包含两个元素的数组列表,并返回一个字典,使用数组项的第一个元素作为键,第二个元素作为值。我们现在暂时保留@profile装饰器。

list2dict

readFileContent

readFileContent函数有两行,其中重要的一行只是简单地调用文件内容的split方法。这里没有太多可以改进的地方,所以我们将它排除,并专注于其他函数。

readFileContent

saveIndex

saveIndex函数将处理结果写入文件,使用特定的格式。我们也许能在这里得到一些更好的数字。

saveIndex

start

最后,主方法__start__负责调用其他函数,并没有做太多繁重的工作,所以我们也将其排除在外。

__start__

那么,让我们总结一下。我们最初有六个函数,其中我们排除了两个,因为它们太简单或者根本就没有做任何相关的事情。现在,我们总共有四个函数需要审查和优化。

getOffsetUpToWord

首先让我们看看getOffsetUpToWord函数,对于如此简单的任务——即计算到当前索引为止的单词长度之和——它却有很多行代码。可能存在一种更 Pythonic 的方式来处理这个问题,所以让我们试试看。

这个函数原本占用了总执行时间的 1.4 秒,所以让我们尝试通过简化代码来降低这个数字。单词长度的累加可以转换成一个 reduce 表达式,如下所示:

def getOffsetUpToWord(words, index):
  if(index == 0):
    return 0
  length =  reduce(lambda curr, w: len(w) + curr, words[0:index], 0)
  return length + index + 1

这种简化消除了进行变量赋值和查找额外时间的需要。这看起来可能不多。然而,如果我们用这段新代码再次运行性能分析器,时间会降低到 0.9 秒。这个实现仍然有一个明显的缺点:lambda 函数。我们每次调用getOffsetUpToWord时都会动态创建一个函数。我们调用了 313,868 次,所以最好提前创建这个函数。我们可以在 reduce 表达式中添加对这个函数的引用,如下所示:

def addWordLength(curr, w):
  return len(w) + curr

@profile
def getOffsetUpToWord(words, index):
  if not index:
    return 0
  length = reduce(addWordLength, words[0:index], 0)
  return length + index + 1

输出应该类似于以下截图:

getOffsetUpToWord

通过这个小小的改进,执行时间降低到了 0.8 秒。在先前的截图上,我们可以看到函数的前两行仍然有大量的不必要的调用(因此耗时)。这个检查是不必要的,因为 reduce 函数默认就是 0。最后,可以移除对长度变量的赋值,并直接返回长度、索引和整数 1 的总和。

这样一来,我们剩下的代码如下:

def addWordLength(curr, w):
  return len(w) + curr

@profile
def getOffsetUpToWord(words, index):
  return reduce(addWordLength, words[0:index], 0) + index + 1

这个函数的总执行时间从 1.4 秒降低到了惊人的 0.67 秒。

getWords

接下来,我们转向下一个函数:getWords函数。这是一个相当慢的函数。根据截图,这个函数的执行时间达到了 4 秒。这可不是什么好事。让我们看看我们能做些什么。首先,这个函数中最耗时的(耗时最多的)一行是调用getOffsetUpToWord函数的那一行。由于我们已经优化了那个函数,所以现在这个函数的总执行时间已经降低到了 2.2 秒(从 4 秒减少)。

这是一个相当不错的副作用优化,但我们还可以为这个函数做更多。我们使用普通的字典作为wordIndexDict变量,因此我们必须在使用之前检查键是否已设置。在这个函数中进行这个检查大约需要 0.2 秒。这虽然不多,但也是一种优化。为了消除这个检查,我们可以使用defaultdict类。它是dict类的一个子类,它增加了一个额外的功能。它为键不存在时设置一个默认值。这将消除函数内部 0.2 秒的需要。

另一个微不足道但很有帮助的优化是将结果赋值给变量。这看起来可能微不足道,但做 313,868 次无疑会损害我们的性能。所以,看看这些行:

    35    313868      1266039      4.0     62.9        line_offset = getOffsetUpToWord(localWords, idx) 
    36    313868       108729      0.3      5.4        index = (line_offset) + currentOffset
    37    313868       101932      0.3      5.1        currentOffset = index 

这些行可以简化为单行代码,如下所示:

      currentOffset = getOffsetUpToWord(localWords, idx) + currentOffset

通过这样做,我们节省了另外 0.2 秒。最后,我们在每一行和每个单词上执行了 strip 操作。我们可以通过在加载文件时多次调用replace方法来简化这一点,以处理我们将要处理的文本,并从getWords函数内部的查找和调用方法中移除额外的时间。

新代码看起来像这样:

def getWords(content, filename, wordIndexDict):
  currentOffset = 0
  for line in content:
    localWords = line.split()
    for (idx, word) in enumerate(localWords):
      currentOffset = getOffsetUpToWord(localWords, idx) + currentOffset
      wordIndexDict[word].append([filename, currentOffset])])])
  return wordIndexDict

运行时间仅需 1.57 秒。还有一个额外的优化我们可能想看看。它适用于这个特定情况,因为getOffsetUpToWord函数只在一个地方使用。由于这个函数简化为了一行代码,我们只需将这一行代码放在函数调用的位置。这一行代码将减少查找时间,给我们带来 1.07 秒的巨大提升(这是 0.50 秒的减少!)。这就是最新版本的函数看起来是这样的:

getWords

如果你将从多个地方调用该函数,这可能是一个不值得拥有的优化,因为它会损害代码的可维护性。在开发过程中,代码的可维护性也是一个重要的方面。在尝试确定何时停止优化过程时,它应该是一个决定性因素。

list2dict

接下来,对于list2dict函数,我们实际上无法做太多,但我们可以清理它以获得更易读的代码,并节省大约 0.1 秒。再次强调,我们这样做不是为了速度的提升,而是为了可读性的提升。我们有再次使用defaultdict类的机会,并移除对键的检查,使新代码看起来像这样:

def list2dict(list):
  res = defaultdict(lambda: [])
  for item in list:
    res[item[0]].append(item[1])
  return res

上述代码行数较少,更易于阅读,也更容易理解。

saveIndex

最后,让我们来看看saveIndex函数。根据我们的初步报告,这个函数预处理并保存数据到索引文件需要 0.23 秒。这已经是一个相当不错的数字了,但我们可以通过再次审视所有的字符串连接来做得更好。

在保存数据之前,对于每个生成的单词,我们通过连接几个部分来生成一个字符串。在同一个循环中,我们还将重置indexLineglue变量。这些操作将花费很多时间,所以我们可能想要改变我们的策略。

这在以下代码中显示:

def saveIndex(index):
  lines = []
  for word in index:
    indexLines = []
    for filename in index[word]:
      indexLines.append("(%s, %s)" % (filename, ','.join(index[word][filename])))
    lines.append(word + "," +  ','.join(indexLines))

  f = open("index-file.txt", "w")
  f.write("\n".join(lines))
  f.close()

如前述代码所示,我们改变了整个for循环。现在,我们不再将新字符串添加到indexLine变量中,而是将其追加到列表中。我们还移除了确保在join调用期间处理字符串的map调用。那个map被移动到list2dict函数中,在追加时直接将索引转换为字符串。

最后,我们使用了+运算符来连接字符串,而不是执行更昂贵的字符串展开操作。最终,这个函数的速度从 0.23 秒下降到 0.13 秒,给我们带来了 0.10 秒的速度提升。

摘要

总结一下,我们看到了两种在 Python 中使用的主要分析器:cProfile,它是语言自带的功能,以及line_profiler,它让我们有机会独立查看每一行代码。我们还介绍了使用它们进行分析和优化的几个示例。

在下一章中,我们将探讨一系列视觉工具,这些工具将通过以图形方式展示本章中涵盖的相同数据来帮助我们完成工作。

第三章。可视化——用于理解性能分析器输出的 GUI

尽管我们在上一章已经介绍了性能分析,但我们所经历的过程就像在黑暗中行走,或者至少是在光线非常微弱的地方。我们一直在看数字。基本上,我们一直在尝试减少命中次数、秒数或其他类似的数字。然而,根据我们所拥有的表示,很难理解这些数字之间的关系。

基于该输出,我们无法轻易地看到我们系统的整体蓝图。如果我们的系统更大,那么看到这个蓝图将会更加困难。

简单地因为我们是人类,而不是计算机本身,所以我们有某种视觉辅助时工作得更好。在这种情况下,如果我们能更好地理解一切是如何相互关联的,我们的工作将受益匪浅。为此,我们有工具可以提供我们在上一章看到的数字的视觉表示。这些工具将为我们提供急需的帮助。反过来,我们将能够更快地定位和修复我们系统的瓶颈。作为额外的奖励,我们将更好地理解我们的系统。

在本章中,我们将介绍属于这一类别的两个工具:

  • KCacheGrind / pyprof2calltree: 这个组合将使我们能够将cProfile的输出转换为 KCacheGrind 所需的格式,这反过来将帮助我们可视化信息。

  • RunSnakeRun (www.vrplumber.com/programming/runsnakerun/): 此工具将使我们能够可视化和分析cProfile的输出。它提供了方格图和可排序的列表,以帮助我们完成任务。

对于每一个,我们将介绍安装和 UI 解释的基础知识。然后,我们将从第二章 性能分析器中获取示例,并根据这些工具的输出重新分析它们。

KCacheGrind – pyprof2calltree

我们将看到的第一个 GUI 工具是 KCacheGrind。它是一个数据可视化工具,旨在解析和显示不同格式的性能分析数据。在我们的案例中,我们将显示cProfile的输出。然而,为了做到这一点,我们还需要命令行工具pyprof2calltree的帮助。

此工具是对一个非常受欢迎的工具lsprofcalltree.py(people.gnome.org/~johan/lsprofcalltree.py)的重新命名。它试图更像 Debian 中的kcachegrind-converter(packages.debian.org/en/stable/kcachegrind-converters)包。我们将使用此工具将cProfile的输出转换为 KCacheGrind 可以理解的内容。

安装

要安装pyprof2calltree,您首先需要安装pip命令行工具。然后,只需使用以下命令:

$ pip install pyprof2calltree

注意,所有安装步骤和说明都是针对 Ubuntu 14.04 Linux 发行版的,除非另有说明。

现在,对于 KCacheGrind 来说,安装略有不同。可视化器是 KDE 桌面环境的一部分,所以如果你已经安装了它,那么你很可能也已经安装了 KCacheGrind。然而,如果你没有安装它(也许你是 Gnome 用户),你只需使用你的包管理器并安装它。例如,在 Ubuntu 上,你会使用以下命令:

$ sudo apt-get install kcachegrind

注意

使用这个命令,你可能需要安装很多与实用程序不直接相关的包,而是与 KDE 相关。因此,安装可能需要一些时间,这取决于你的互联网连接。

对于 Windows 和 OS X 用户,有一个选项可以安装 KCacheGrind 的 QCacheGrind 分支,它已经预编译并可以作为二进制文件安装。

Windows 用户可以从 sourceforge.net/projects/qcachegrindwin/ 下载,而 OS X 用户可以使用 brew 安装:

$ brew install qcachegrind

使用方法

使用 pyprof2calltree 有两种方式:一种是从命令行传递参数,另一种是直接从 读取-评估-打印循环REPL)(甚至是从我们自己的被分析脚本中)。

第一个(命令行版本)在我们已经将分析结果存储在某个地方时非常有用。因此,使用这个工具,我们只需运行以下命令,并在需要时获取输出:

$ pyprof2calltree -o [output-file-name] -i input-file.prof

有一些可选参数,它们可以帮助我们在不同情况下。其中两个在这里进行了说明:

  • -k:如果我们想立即在输出数据上运行 KCacheGrind,这个选项会为我们完成

  • -r:如果我们还没有将分析数据保存到文件中,我们可以使用这个参数传入我们将用来收集这些数据的 Python 脚本

现在,如果你想从 REPL 中使用它,你可以简单地从 pyprof2calltree 包中导入(或同时导入)convert 函数或 visualize 函数。第一个会将数据保存到文件中,而第二个会使用分析器的输出启动 KCacheGrind。

这里有一个例子:

from xml.etree import ElementTree
from cProfile import Profile
import pstats
xml_content = '<a>\n' + '\t<b/><c><d>text</d></c>\n' * 100 + '</a>'
profiler = Profile()
profiler.runctx(
"ElementTree.fromstring(xml_content)",
locals(), globals())

from pyprof2calltree import convert, visualize
stats = pstats.Stats(profiler)
visualize(stats)      # run kcachegrind

这段代码将调用 KCacheGrind。它将显示类似于以下屏幕截图中的内容:

使用方法

在前面的屏幕截图中,你可以看到左侧的列表(1)显示了我们在上一章中看到的一些数字。在右侧(2),我们选择了一个标签,具体是 调用图 标签。它显示了一系列盒子,代表从左侧选中的函数调用到最底层的层次结构。

在左侧的列表中,有两个列我们需要特别注意:

  • 包含(从包含时间)列:这显示了每个函数在总体上花费的时间的指标。这意味着它加上其代码花费的时间以及它调用的其他函数花费的时间。如果一个函数在这个列中有很高的数字,这并不一定意味着函数花费了很长时间。这可能意味着它调用的函数花费了很长时间。

  • 自我列:这显示了在特定函数内部花费的时间,不考虑它调用的函数。所以,如果一个函数有很高的自我值,那么这可能意味着在它内部花费了很多时间,这是一个寻找优化路径的好起点。

另一个有用的视图是调用图,一旦在列表中选择了一个函数,就可以在右下角的框中找到。它将显示函数的表示,有助于解释每个函数是如何调用下一个函数的(以及调用的次数)。以下是从前面的代码中的一个示例:

使用示例

剖析示例 - TweetStats

现在,让我们回到第二章,剖析器的例子,并使用pyprof2calltree/kcachegrind组合来处理它们。

让我们避免斐波那契示例,因为它们相当简单,而且我们已经讨论过了。所以,让我们直接跳到 TweetStats 模块的代码。它将读取一系列推文并从中获取一些统计数据。我们不会修改代码。所以,仅供参考,请查看第二章,剖析器

至于使用类并打印实际统计信息的脚本,我们正在修改它以保存统计信息。正如你所看到的,这是一个非常简单的更改:

import cProfile
import pstats
import sys

from tweetStats import build_twit_stats

profiler = cProfile.Profile()
profiler.enable()

build_twit_stats()
profiler.create_stats()
stats = pstats.Stats(profiler)
stats.strip_dirs().sort_stats('cumulative').dump_stats('tweet-stats.prof') #saves the stats into a file called tweet-stats.prof, instead of printing them into stdout

现在,将统计数据保存到tweet-stats.prof文件中后,我们可以使用以下命令一次性转换它并启动可视化器:

$pyprof2calltree -i tweet-stats.prof -k

这反过来会显示类似以下截图的内容:

剖析示例 - TweetStats

再次,当在列表中选择第一个函数调用时,选择调用图,我们可以看到我们脚本的整个图。它清楚地显示了瓶颈在哪里(右侧最大的块):read_datasplit方法和地图最右边的get_data函数。

在地图的get_stats部分,我们可以看到有两个函数构成了部分大小:来自字符串的inc_statfind。我们从代码中知道第一个函数。这个函数做得很少,所以它的大小将完全归因于累积的查找时间(毕竟我们调用它大约 760k 次)。对于find方法也是同样的情况。我们调用它的次数太多,所以查找时间累积起来,开始变得引人注目。因此,让我们对这个函数应用一系列非常简单的改进。让我们移除inc_stat函数并将其内联。同时,让我们更改find方法行并使用 in 运算符。结果将类似于这个截图所示::

一个性能分析示例 – TweetStats

地图的另一侧发生了巨大变化。现在,我们可以看到get_stats函数不再调用其他函数,因此查找时间被移除。现在它只代表总执行时间的 9.45%,而之前是 23.73%。

是的,前面的结论与我们在上一章中得出的结论相同,但我们使用的是不同的方法。那么,让我们继续进行之前所做的相同优化,看看地图又发生了什么变化:

一个性能分析示例 – TweetStats

在前面的截图中,我们可以看到通过选择左侧列表中的build_twitt_stats函数,被调用的函数仅仅是字符串对象的方法。

很遗憾,KCacheGrind 没有显示执行的总时间。然而,地图清楚地表明我们无论如何已经简化并优化了我们的代码。

一个性能分析示例 – 倒排索引

再次,让我们从第二章,分析器,中获取另一个例子:倒排索引。让我们更新其代码以生成统计数据并将其保存到文件中,以便我们稍后可以用 KCacheGrind 分析它。

我们唯一需要更改的是文件的最后一行,而不是仅仅调用__start__函数。我们有以下代码:

profiler = cProfile.Profile()
profiler.enable()
__start__()
profiler.create_stats()
stats = pstats.Stats(profiler)
stats.strip_dirs().sort_stats('cumulative').dump_stats('inverted-index-stats.prof')

现在,执行脚本将数据保存到inverted-index-stats.prof文件中。稍后,我们可以使用以下命令启动 KCacheGrind:

$ pyprof2calltree -i inverted-index-stats.prof -k

这是我们首先看到的:

一个性能分析示例 – 倒排索引

让我们先根据左侧的第二列(Self)对左侧的函数进行重新排序。这样,我们可以查看由于代码原因(而不是因为调用的函数运行时间较长)执行时间最长的函数。我们将得到以下列表:

一个性能分析示例 – 倒排索引

根据前面的列表,目前最成问题的两个函数是getWordslist2dict

第一个可以通过以下几种方式改进:

  • wordIndexDict属性可以更改为defaultdict类型,这将移除检查现有索引的if语句

  • 可以从readFileContent函数中移除 strip 语句,从而简化我们这里的代码

  • 可以移除很多赋值操作,因此避免在这些操作中浪费毫秒,因为我们可以直接使用这些值

因此,我们新的getWords函数看起来是这样的:

def getWords(content, filename, wordIndexDict):
  currentOffset = 0
  for line in content:
    localWords = line.split()
    for (idx, word) in enumerate(localWords):
      currentOffset = getOffsetUpToWord(localWords, idx) + currentOffset 
      wordIndexDict[word].append([filename, currentOffset])
  return wordIndexDict

现在,如果我们再次运行统计,映射和数字看起来略有不同:

一个性能分析示例 – 倒排索引

因此,我们的函数现在使用的时间更少了,无论是总体上(Incl.列)还是内部(Self列)。然而,在离开这个函数之前,我们可能还想关注另一个细节。getWords函数总共调用了getOffsetUpToWord 141,295次,仅查找时间就足以值得审查。那么,让我们看看我们能做什么。

我们已经在前面的章节中解决了这个问题。我们看到了可以将整个getOffsetUpToWord函数简化为一行,我们可以在稍后直接将其写入getWords函数中,以避免查找时间。考虑到这一点,让我们看看我们的新映射是什么样的:

一个性能分析示例 – 倒排索引

现在,我们的总体时间有所增加,但不用担心。这是因为现在我们有一个函数可以分散时间,所以所有其他函数的数字都发生了变化。然而,我们真正关心的是(Self时间),下降了大约 4%。

前面的截图还显示了调用图视图,它帮助我们看到,尽管我们进行了改进,但reduce函数仍然被调用了超过100,000次。如果你查看getWords函数的代码,你会注意到我们实际上并不需要reduce函数。这是因为每次调用时,我们都在前一次调用的所有数字上加上一个,所以我们可以将以下代码简化:

def getWords(content, filename, wordIndexDict):
  currentOffset = 0
  prevLineLength = 0
  for lineIndex, line in enumerate(content):
    lastOffsetUptoWord = 0
    localWords = line.split()

    if lineIndex > 0:
      prevLineLength += len(content[lineIndex - 1]) + 1
    for idx, word in enumerate(localWords):
      if idx > 0:
        lastOffsetUptoWord += len(localWords[idx-1])
      currentOffset = lastOffsetUptoWord + idx +  1 + prevLineLength

      wordIndexDict[word].append([filename, currentOffset])

在对函数进行最后的润色后,数字又发生了变化:

一个性能分析示例 – 倒排索引

函数的总耗时显著降低,所以总的来说,这个函数现在执行所需的时间更少了(这正是我们的目标)。内部时间(Self列)有所下降,这是一个好现象。这是因为这也意味着我们在更短的时间内完成了同样的工作(特别是因为我们知道我们没有调用任何其他函数)。

RunSnakeRun

RunSnakeRun 是另一个 GUI 工具,帮助我们可视化性能分析输出,进而帮助我们理解它。这个特定项目是 KCacheGrind 的简化版本。虽然后者对 C 和 C++开发者也有用,但 RunSnakeRun 是专门为 Python 开发者设计和编写的。

之前,使用 KCacheGrind,如果我们想绘制cProfile的输出,我们需要一个额外的工具(pyprof2calltree)。这次我们不需要。RunSnakeRun 知道如何解释并显示它,所以我们只需要调用它并传入文件路径。

此工具提供以下功能:

  • 可排序的数据网格视图,具有如下字段:

    • 函数名称

    • 总调用次数

    • 累计时间

    • 文件名和行号

  • 函数特定信息,例如此函数的所有调用者和所有被调用者

  • 调用树平方图,大小与每个函数内花费的时间成比例

安装

为了安装此工具,您必须确保几个依赖项已覆盖,主要是以下这些:

  • Python 分析器

  • wxPython(2.8 或更高版本)(www.wxpython.org/)

  • Python(当然!)2.5 或更高版本,但低于 3.x

您还需要安装pip (pypi.python.org/pypi/pip),以便运行安装命令。

因此,在继续之前,请确保您已经安装了所有这些。如果您使用的是基于 Debian 的 Linux 发行版(例如 Ubuntu),您可以使用以下行来确保您拥有所需的一切(假设您已经安装了 Python):

$ apt-get install python-profiler python-wxgtk2.8 python-setuptools

注意

Windows 和 OS X 用户需要为之前提到的每个依赖项找到当前 OS 版本的正确预编译的二进制文件。

之后,您只需运行以下命令:

$ pip install  SquareMap RunSnakeRun

之后,您应该可以开始使用了。

使用方法

现在,为了快速向您展示如何使用它,让我们回到之前的最后一个示例:inverted-index.py

让我们使用cProfile分析器作为参数执行该脚本,并将输出保存到文件中。然后,我们只需调用runsnake并传入文件路径:

$ python -m cProfile -o inverted-index-cprof.prof inverted-index.py
$ runsnake inverted-index-cprof.prof

这将生成以下截图:

使用方法

从前面的截图,您可以看到三个主要感兴趣的区域:

  • 可排序的列表,其中包含cProfile返回的所有数字

  • 函数特定信息部分,其中包含几个有趣的标签,例如被调用者调用者源代码标签

  • 平方地图部分,它以图形方式表示执行调用树

注意

GUI 的一个很棒的小功能是,如果您将鼠标悬停在左侧列表中的函数上,它会在右侧突出显示相关的框。如果您将鼠标悬停在右侧的框上,同样会发生这种情况;列表中的对应条目将被突出显示。

分析示例 – 最低公共乘数

让我们看看一个非常基础、不实用的函数优化示例,以及使用此 GUI 会是什么样子。

我们的示例函数负责找到两个数字之间的最小公倍数。这是一个相当基本的例子:你可以在互联网上找到很多。然而,这也是开始了解这个 UI 的好地方。

函数的代码如下:

def lowest_common_multiplier(arg1, arg2):
    i = max(arg1, arg2)
    while i < (arg1 * arg2):
        if i % min(arg1,arg2) == 0:
            return i
        i += max(arg1,arg2)
    return(arg1 * arg2)

print lowest_common_multiplier(41391237, 2830338)

我很确信你只需看一眼就能找到每一个可能的优化点,但请跟我一起。让我们分析这个家伙,并将结果输出加载到 RunSnakeRun 中。

因此,要运行它,请使用以下命令:

$ python -m cProfile -o lcm.prof lcm.py

要启动 GUI,请使用以下命令:

$ runsnake lcm.prof

这是我们得到的结果:

配置示例 – 最小公倍数

我们之前没有提到的一件事,但它是正方形映射的一个很好的附加功能,是每个框的名称旁边我们可以看到运行该函数所需的时间。

因此,乍一看,我们可以发现几个问题:

  • 我们可以看到,maxmin函数总共只占用了我们函数运行时间的 0,228 秒,而我们的函数运行总时间是 0,621 秒。所以,我们的函数不仅仅是maxmin

  • 我们还可以看到,maxmin函数都被调用了943,446次。无论查找时间有多小,如果你几乎调用了 100 万次函数,这将会累积起来。

让我们对我们的代码进行一些明显的修复,并再次通过“蛇之眼”看看它:

def lowest_common_multiplier(arg1, arg2):
    i = max(arg1, arg2)
    _max = i
    _min = min(arg1,arg2)
    while i < (arg1 * arg2):
        if i % _min == 0:
            return i
        i += _max
    return(arg1 * arg2)

print lowest_common_multiplier(41391237, 2830338)

你应该得到以下截图所示的内容:

配置示例 – 最小公倍数

现在,minmax甚至没有在正方形映射上注册。这是因为我们只调用了一次,函数从 0.6 秒减少到 0.1 秒。这就是不进行不必要的函数查找的力量。

现在,让我们看看另一个更复杂、因此更有趣、急需优化的函数。

倒排索引的搜索示例

自从上一章以来,我们已经从所有可能的角度分析了倒排索引的代码。这很好,因为我们已经从几个不同的角度和不同的方法进行了分析。然而,使用 RunSnakeRun 来分析它就没有意义了,因为这个工具与我们刚刚尝试的工具非常相似(KCacheGrind)。

因此,让我们使用倒排搜索脚本的输出,并自己编写一个使用该输出的搜索脚本。我们最初的目标是编写一个简单的搜索函数,它将只查找索引中的一个单词。步骤相当直接:

  1. 将索引加载到内存中。

  2. 搜索单词并获取索引信息。

  3. 解析索引信息。

  4. 对于每个索引条目,读取相应的文件,并获取周围的字符串作为结果。

  5. 打印结果。

这是我们的代码的初始版本:

import re
import sys

#Turns a list of entries from the index file into a dictionary indexed
#by words
def list2dict(l):
  retDict = {}
  for item in l:
    lineParts = item.split(',')
    word = lineParts.pop(0)
    data = ','.join(lineParts)
    indexDataParts = re.findall('\(([a-zA-Z0-9\./, ]{2,})\)' ,data)
    retDict[word] = indexDataParts
  return retDict

#Load the index's content into memory and parse itdef loadIndex():
  indexFilename = "./index-file.txt"
  with open(indexFilename, 'r') as fh: 
    indexLines = []
    for line in fh:
      indexLines.append(line)
    index = list2dict(indexLines)

    return index

#Reads the content of a file, takes care of fixing encoding issues with utf8 and removes unwanted characters (the ones we didn't want when generating the index)
def readFileContent(filepath):
    with open(filepath, 'r') as f:
    return [x.replace(",", "").replace(".","").replace("\t","").replace("\r","").replace("|","").strip(" ") for x in f.read().decode("utf-8-sig").encode("utf-8").split( '\n' )]
def findMatch(results):
  matches = []
  for r in results:
    parts = r.split(',')
    filepath = parts.pop(0)
    fileContent = ' '.join(readFileContent(filepath))
    for offset in parts:
      ioffset = int(offset)
      if ioffset > 0:
        ioffset -= 1
      matchLine = fileContent[ioffset:(ioffset + 100)]
      matches.append(matchLine)
  return matches

#Search for the word inside the index
def searchWord(w):
  index = None
  index = loadIndex()
  result = index.get(w)
  if result:
    return findMatch(result)
  else:
      return []

#Let the user define the search word...
searchKey = sys.argv[1] if len(sys.argv) > 1 else None
if searchKey is None: #if there is none, we output a usage message
 print "Usage: python search.py <search word>"
else: #otherwise, we search
  results = searchWord(searchKey)
  if not results:
      print "No results found for '%s'" % (searchKey)
  else:
      for r in results:
      print r

要运行代码,只需运行以下命令:

$ python -m cProfile -o search.prof search.py John

我们将得到的输出类似于以下截图(假设我们在files文件夹中有几本书):

性能分析示例 – 使用倒排索引进行搜索

我们可以通过突出显示搜索词或显示一些上下文中的前缀词来改进输出。然而,我们暂时就这样进行。

现在,让我们看看在RunSnakeRun中打开search.prof文件时我们的代码看起来如何:

性能分析示例 – 使用倒排索引进行搜索

与我们之前示例中的最低公倍数相比,这里有很多框。然而,让我们先看看从第一眼就能得到的见解。

两个耗时最长的函数是loadIndexlist2dict,紧接着是readFileContent。我们可以在左侧列中看到这一点:

  • 所有这些函数实际上大部分时间都是在它们调用的其他函数内部度过的。所以,它们的累积时间很高,但它们的局部时间却相对较低。

  • 如果我们按局部时间对列表进行排序,我们会看到前五个函数是:

    • 文件对象的read方法

    • loadIndex函数

    • list2dict函数

    • 正则表达式对象的findAll方法

    • 以及readFileContent函数

因此,让我们首先看看loadIndex函数。尽管它的大部分时间是在list2dict函数内部度过的,但我们仍然有一个小的优化要做,这将简化其代码并显著减少其局部时间:

def loadIndex():
  indexFilename = "./index-file.txt"
  with open(indexFilename, 'r') as fh:
    #instead of looping through every line to append it into an array, we use the readlines method which does that already
    indexLines = fh.readlines()
    index = list2dict(indexLines)
    return index

这个简单的更改将函数的局部时间从 0.03 秒降低到 0.00002 秒。尽管它之前并不是一个大问题,但我们提高了其可读性并改善了其性能。所以,总的来说,我们做得不错。

现在,根据最后的分析,我们知道在这个函数内部花费的大部分时间实际上是在它调用的另一个函数内部度过的。所以,现在我们已经基本上将其局部时间降低到几乎为零,我们需要关注我们的下一个目标:list2dict

然而,首先,让我们看看通过我们之前所做的简单改进,图片是如何变化的:

性能分析示例 – 使用倒排索引进行搜索

现在,让我们继续讨论list2dict函数。这个函数负责将索引文件的每一行解析成我们可以稍后使用的格式。它将解析索引文件的每一行,更具体地说,将其解析成一个以单词为索引的哈希表(或字典),这样当我们进行搜索时,平均搜索时间复杂度将是 O(1)。如果你不记得这是什么意思,可以回读至第一章,性能分析 101

从我们的分析中,我们可以看出,尽管我们在函数内部花费了一些时间,但大部分的复杂性都集中在正则表达式方法中。正则表达式有很多优点,但有时我们倾向于在可以使用简单的 splitreplace 函数的情况下过度使用它们。那么,让我们看看我们如何解析数据,在不使用正则表达式的情况下获得相同的输出,并看看我们是否能在更少的 time:def list2dict(l) 时间内完成:

  retDict = {}
  for item in l:
    lineParts = item.split(',(')
    word = lineParts[0]
    ndexDataParts = [x.replace(")","") for x in lineParts[1:]]
  retDict[word] = indexDataParts
  return retDict

代码看起来已经更简洁了。没有任何地方使用正则表达式(这有时有助于可读性,因为并不是每个人都是阅读正则表达式的专家)。代码行数更少了。我们移除了 join 行,甚至去掉了不必要的 del 行。

然而,我们添加了一条列表解析行,但这只是对列表中的每个项目进行一行简单的 replace 方法,仅此而已。

让我们看看现在的映射看起来像什么:

一个性能分析示例 – 使用倒排索引进行搜索

好吧,确实有变化。如果你比较最后两个截图,你会注意到 list2dict 函数的框已经移动到了右边。这意味着它现在比 readFileContent 函数花费的时间更少。我们的函数框现在也更简单了。里面只有 splitreplace 方法。最后,以防有任何疑问,让我们看看这些数字:

  • 本地时间从 0.024 秒下降到 0.019 秒。本地时间没有大幅下降是有道理的,因为我们仍然在函数内部做所有的工作。这种下降主要是由于没有 del 行和 join 行。

  • 总累计时间显著下降。它从 0.094 秒下降到 0.031 秒,这是由于缺乏用于这项工作的复杂函数(正则表达式)。

我们将函数的总累计时间降低到了原来的三分之一。所以,这是一个很好的优化,特别是考虑到如果我们有更大的索引,那么时间会更大。

注意

最后的假设并不总是正确的。它很大程度上取决于所使用的算法类型。然而,在我们的情况下,因为我们正在遍历索引文件的每一行,所以我们可以安全地假设它是正确的。

让我们快速查看代码的第一分析和最后分析中的数字,以便我们可以看到整体时间是否真的有所改进:

一个性能分析示例 – 使用倒排索引进行搜索

最后,正如你所看到的,我们的执行时间从原始代码的约 0.2 秒下降到了 0.072 秒。

这是代码的最终版本,所有之前做出的改进都整合在一起:

import sys

#Turns a list of entries from the index file into a dictionary indexed
#by words
def list2dict(l):
  retDict = {}
  for item in l:
    lineParts = item.split(',(')
  word = lineParts[0]
    indexDataParts = [x.replace(")","") for x in lineParts[1:]]
  retDict[word] = indexDataParts
  return retDict

#Load the index's content into memory and parse it
def loadIndex():
  indexFilename = "./index-file.txt"
  with open(indexFilename, 'r') as fh:
    #instead of looping through every line to append it into an array, we use the readlines method which does that already
    indexLines = fh.readlines()
    index = list2dict(indexLines)
    return index

#Reads the content of a file, takes care of fixing encoding issues with utf8 and removes unwanted characters (the ones we didn't want when generating the index)#
def readFileContent(filepath):
    with open(filepath, 'r') as f:
    return [x.replace(",", "").replace(".","").replace("\t","").replace("\r","").replace("|","").strip(" ") for x in f.read().decode("utf-8-sig").encode("utf-8").split( '\n' )]

def findMatch(results):
  matches = []
  for r in results:
    parts = r.split(',')

    filepath = parts[0]
    del parts[0]
    fileContent = ' '.join(readFileContent(filepath))
    for offset in parts:
      ioffset = int(offset)
      if ioffset > 0:
        ioffset -= 1
      matchLine = fileContent[ioffset:(ioffset + 100)]
      matches.append(matchLine)
  return matches

#Search for the word inside the index
def searchWord(w):
  index = None
  index = loadIndex()
  result = index.get(w)
  if result:
    return findMatch(result)
  else:
    return []

#Let the user define the search word...
searchKey = sys.argv[1] if len(sys.argv) > 1 else None

if searchKey is None: #if there is none, we output a usage message
  print "Usage: python search.py <search word>"
else: #otherwise, we search
  results = searchWord(searchKey)
  if not results:
    print "No results found for '%s'" % (searchKey)
  else:
    for r in results:
    print r

摘要

总结来说,在本章中,我们介绍了 Python 开发者尝试理解由 cProfile 等分析器返回的数字时使用的两个最流行和常见的工具。我们用新的视角分析了旧代码。我们甚至分析了一些新代码。

在下一章中,我们将更详细地讨论优化问题。我们将涵盖一些我们在实践中已经看到的内容,以及一些在分析和优化代码时的良好实践建议。

第四章。优化一切

掌握 Python 性能提升之路才刚刚开始。性能分析只能带我们走一半的路。测量我们的程序如何使用其可用的资源只能告诉我们问题所在,但不能告诉我们如何修复它。在前几章中,我们看到了一些在介绍分析器时的实际例子。我们进行了一些优化,但从未真正详细解释过。

在本章中,我们将介绍优化的过程,为此,我们需要从基础知识开始。现在我们将将其保持在语言内部:没有外部工具,只有 Python 和正确使用它的方法。

本章我们将介绍以下主题:

  • 缓存/查找表

  • 默认参数的使用

  • 列推导

  • 生成器

  • ctypes

  • 字符串连接

  • Python 的其他技巧和窍门

缓存/查找表

这是提高代码(即函数)性能最常用的技术之一。我们可以保存与特定输入值集相关的高成本函数调用的结果,并在函数使用记住的输入值时返回保存的结果(而不是重新进行整个计算)。这可能会与缓存混淆,因为这是缓存的一种类型,尽管这个术语也指代其他类型的优化(如 HTTP 缓存、缓冲等)。

这种方法非常强大,因为在实践中,它将本应是一个非常昂贵的调用转换为一个O(1)函数调用(有关更多信息,请参阅第一章,性能分析 101),如果实现正确的话。通常,参数用于创建一个唯一的键,然后在该字典上使用该键来保存结果或获取它如果已经保存。

当然,这种技术也有权衡。如果我们打算记住缓存函数的返回值,那么我们将用内存空间来换取速度。这是一个非常可接受的权衡,除非保存的数据超过了系统可以处理的数据。

这种优化的经典用例是经常重复输入参数的函数调用。这将确保大多数时间,都会返回已缓存的值。如果有许多函数调用,但参数不同,我们只会存储结果,消耗内存而没有实际的好处,如下面的图像所示:

缓存/查找表

你可以清楚地看到,蓝色条(固定参数,已缓存)显然是最快的使用案例,而其他由于它们的性质都是相似的。

这里是生成前面图表值的代码。为了生成某种耗时函数,代码将在不同条件下多次调用twoParams函数或twoParamsMemoized函数数百次,并记录执行时间:

import math

import time

import random

class Memoized:

  def __init__(self, fn):

    self.fn = fn

    self.results = {}

  def __call__(self, *args):

    key = ''.join(map(str, args[0]))

    try:

      return self.results[key]

    except KeyError:

      self.results[key] = self.fn(*args)

    return self.results[key]

@Memoized

def twoParamsMemoized(values, period):

  totalSum = 0

  for x in range(0, 100):

    for v in values:

      totalSum = math.pow((math.sqrt(v) * period), 4) + totalSum

  return totalSum

def twoParams(values, period):

  totalSum = 0

  for x in range(0, 100):

    for v in values:

      totalSum = math.pow((math.sqrt(v) * period), 4) + totalSum

  return totalSum

def performTest():

    valuesList = []

    for i in range(0, 10):

        valuesList.append(random.sample(xrange(1, 101), 10))

    start_time = time.clock()

    for x in range(0, 10):

      for values in valuesList:

          twoParamsMemoized(values, random.random())

    end_time = time.clock() - start_time

    print "Fixed params, memoized: %s" % (end_time)

    start_time = time.clock()

    for x in range(0, 10):

      for values in valuesList:

          twoParams(values, random.random())

    end_time = time.clock() - start_time

    print "Fixed params, without memoizing: %s" % (end_time)

    start_time = time.clock()

    for x in range(0, 10):

      for values in valuesList:

          twoParamsMemoized(random.sample(xrange(1,2000), 10), random.random())

    end_time = time.clock() - start_time

    print "Random params, memoized: %s" % (end_time)

    start_time = time.clock()

    for x in range(0, 10):

      for values in valuesList:

          twoParams(random.sample(xrange(1,2000), 10), random.random())

    end_time = time.clock() - start_time

    print "Random params, without memoizing: %s" % (end_time)

performTest()

注意

从前面的图表中可以得出的主要见解是,就像编程的每个方面一样,没有一种银弹算法适用于所有情况。显然,记忆化是一种非常基本的代码优化方法,但在适当的条件下,它显然不会优化任何内容。

至于代码,并没有太多。这是一个非常简单、非现实世界的例子,用以说明我试图传达的观点。performTest 函数将负责为每个用例运行一系列 10 个测试,并测量每个用例所花费的总时间。请注意,我们目前并没有真正使用性能分析器。我们只是在非常基础和临时的方式下测量时间,这对我们来说已经足够了。

两个函数的输入只是一个数字集合,它们将在这些数字上运行一些数学函数,只是为了做点事情。

关于参数的另一个有趣之处在于,由于第一个参数是一个数字列表,我们无法在 Memoized 类的方法中使用 args 参数作为键。这就是为什么我们有以下这一行:

key = ''.join(map(str, args[0]))

这一行将第一个参数的所有数字连接成一个单一值,这个值将作为键。第二个参数在这里没有使用,因为它总是随机的,这意味着键永远不会相同。

上述方法的另一种变体是在初始化期间预先计算函数的所有值(当然,假设我们有一个有限的输入数量),然后在执行期间引用查找表。这种方法有几个前提条件:

  • 输入值数量必须是有限的;否则,无法预先计算所有内容

  • 包含所有值的查找表必须能够适应内存

  • 就像之前一样,输入必须至少重复一次,这样优化才有意义,并且值得额外的努力。

在构建查找表时,有不同方法,所有这些方法都提供了不同类型的优化。这完全取决于你试图优化的应用程序和解决方案的类型。这里有一组示例。

在列表或链表上执行查找

这种解决方案通过遍历一个未排序的列表,并将键与每个元素进行比较,以关联的值作为我们寻找的结果。

这显然是一种非常慢的实现方法,对于平均和最坏情况都有 O(n) 的大 O 表示法。尽管如此,在适当的条件下,它可能比每次都调用实际函数要快。

备注

在这种情况下,使用链表可以提高算法的性能,而不是使用简单的列表。然而,它仍然会严重依赖于链表的类型(双向链表、简单链表,可以直接访问第一个和最后一个元素,等等)。

在字典上进行简单查找

这种方法使用一维字典查找,通过由输入参数(足够多以创建一个唯一键)组成的键进行索引。在特定情况下(如我们之前所讨论的),这可能是最快的查找之一,在某些情况下甚至比二分查找更快,具有恒定的执行时间(Big O 表示法为 O(1))。

注意

注意,只要密钥生成算法每次都能生成唯一的密钥,这种方法就是高效的。否则,由于字典中存在许多冲突,性能可能会随着时间的推移而下降。

二分查找

这种特定方法仅在列表已排序的情况下才可行。这可能会根据要排序的值成为一个潜在的选择。然而,排序它们会需要额外的努力,这可能会损害整个工作的性能。然而,即使在长列表中,它也能提供非常好的结果(平均 Big O 表示法为 O(log n))。它是通过确定值在列表的哪一半,并重复进行,直到找到值或算法能够确定值不在列表中。

为了更全面地了解这一点,看看前面提到的 Memoized 类,它在一个字典上实现了一个简单的查找。然而,这将是实现其他算法的地方。

查找表的应用场景

这种类型的优化有一些经典的用例,但最常见的一个可能是三角函数的优化。根据计算时间,这些函数实际上非常慢。当重复使用时,它们可能会对程序的性能造成严重损害。

这就是为什么通常建议预先计算这些函数的值。对于处理可能输入值无限域宇宙的函数,这项任务变得不可能。因此,开发者被迫牺牲精度以换取性能,通过预先计算可能输入值的离散子域(即从浮点数降到整数)。

在某些情况下,这种方法可能并不理想,因为一些系统既需要性能也需要精度。因此,解决方案是折中一下,使用某种形式的插值来计算所需值,基于已经预先计算的那些值。这将提供更好的精度。尽管它可能不会像直接使用查找表那样高效,但它应该比每次都进行三角计算要快。

让我们看看一些这个方法的例子;例如,对于以下三角函数:

def complexTrigFunction(x):
  return math.sin(x) * math.cos(x)**2

我们将探讨简单的预先计算为什么不够准确,以及某种形式的插值如何导致更高的精度水平。

以下代码将预先计算从 -10001000 范围内(仅整数值)的函数值。然后它将尝试对浮点数进行相同的计算(仅针对更小的范围):

import math
import time
from collections import defaultdict
import itertools

trig_lookup_table = defaultdict(lambda: 0) 

def drange(start, stop, step):
    assert(step != 0)
    sample_count = math.fabs((stop - start) / step)
    return itertools.islice(itertools.count(start, step), sample_count)

def complexTrigFunction(x):
  return math.sin(x) * math.cos(x)**2

def lookUpTrig(x):
  return trig_lookup_table[int(x)]

for x in range(-1000, 1000):
  trig_lookup_table[x] = complexTrigFunction(x)

trig_results = []
lookup_results = []

init_time = time.clock()
for x in drange(-100, 100, 0.1):
  trig_results.append(complexTrigFunction(x))
print "Trig results: %s" % (time.clock() - init_time)

init_time = time.clock()
for x in drange(-100, 100, 0.1):
  lookup_results.append(lookUpTrig(x))
print "Lookup results: %s" % (time.clock() - init_time)

for idx in range(0, 200):
  print "%s\t%s" % (trig_results [idx], lookup_results[idx])

之前代码的结果将有助于展示简单的查找表方法并不足够准确(请参见以下图表)。然而,它通过速度来补偿这一点,因为原始函数运行需要 0.001526 秒,而查找表只需 0.000717 秒。

查找表的用例

之前的图表显示了缺乏插值如何影响准确性。你可以看到,尽管两个图表非常相似,但查找表执行的结果并不像直接使用的trig函数那样准确。所以,现在,让我们再次审视相同的问题。然而,这次,我们将添加一些基本的插值(我们将值域限制在-PIPI之间):

import math
import time
from collections import defaultdict
import itertools

trig_lookup_table = defaultdict(lambda: 0) 

def drange(start, stop, step):
    assert(step != 0)
    sample_count = math.fabs((stop - start) / step)
    return itertools.islice(itertools.count(start, step), sample_count)

def complexTrigFunction(x):
  return math.sin(x) * math.cos(x)**2

reverse_indexes = {}
for x in range(-1000, 1000):
  trig_lookup_table[x] = complexTrigFunction(math.pi * x / 1000)

complex_results = []
lookup_results = []

init_time = time.clock()
for x in drange(-10, 10, 0.1):
  complex_results .append(complexTrigFunction(x))
print "Complex trig function: %s" % (time.clock() - init_time)

init_time = time.clock()
factor = 1000 / math.pi
for x in drange(-10 * factor, 10 * factor, 0.1 * factor):
  lookup_results.append(trig_lookup_table[int(x)])
print "Lookup results: %s" % (time.clock() - init_time)

for idx in range(0, len(lookup_results )):
  print "%s\t%s" % (complex_results [idx], lookup_results [idx])

正如你可能在之前的图表中注意到的,生成的图表是周期性的(特别是因为我们已经将范围限制在-PIPI之间)。因此,我们将关注一个特定的值域,这将生成图表的单个部分。

之前脚本的输出还显示,插值解决方案仍然比原始的三角函数快,尽管不如之前快:

插值解决方案 原始函数
0.000118 秒 0.000343 秒

下面的图表与之前的图表略有不同,特别是因为它显示了(以绿色表示)插值值与原始值之间的误差百分比:

查找表的用例

我们最大的误差大约是 12%(这代表了图表上看到的峰值)。然而,这是对于最小的值,例如-0.000852248551417 与-0.000798905501416 之间的差异。这是一个需要根据上下文来理解误差百分比是否真正重要的案例。在我们的情况下,由于与该误差相关的值非常小,我们实际上可以忽略这个误差。

注意

查找表还有其他用例,例如在图像处理中。然而,为了这本书的目的,之前的例子应该足以展示它们的优点以及使用它们的权衡。

默认参数的使用

另一种优化技术,与记忆化相反,并不特别通用。相反,它直接与 Python 解释器的工作方式相关联。

默认参数可以在函数创建时确定值,而不是在运行时。

提示

这只能用于在程序执行期间不会改变的函数或对象。

让我们看看如何应用这种优化的一个例子。以下代码显示了同一函数的两个版本,该函数执行一些随机的三角计算:

import math 

#original function
def degree_sin(deg):
    return math.sin(deg * math.pi / 180.0)

#optimized function, the factor variable is calculated during function creation time, 
#and so is the lookup of the math.sin method.
def degree_sin(deg, factor=math.pi/180.0, sin=math.sin):
    return sin(deg * factor)

注意

如果没有正确记录,这种优化可能会出现问题。因为它使用属性来预计算在程序执行期间不应改变的项,这可能导致创建一个令人困惑的 API。

通过快速简单的测试,我们可以再次检查这种优化的性能提升:

import time
import math

def degree_sin(deg):
  return math.sin(deg * math.pi / 180.0) * math.cos(deg * math.pi / 180.0)

def degree_sin_opt(deg, factor=math.pi/180.0, sin=math.sin, cos = math.cos):
  return sin(deg * factor) * cos(deg * factor)

normal_times = []
optimized_times = []

for y in range(100):
  init = time.clock()
   for x in range(1000):
    degree_sin(x)
  normal_times.append(time.clock() - init)

  init = time.clock()
  for x in range(1000):
    degree_sin_opt(x)
  optimized_times.append(time.clock() - init)

print "Normal function: %s" % (reduce(lambda x, y: x + y, normal_times, 0) / 100)
print "Optimized function: %s" % (reduce(lambda x, y: x + y, optimized_times, 0 ) / 100)

之前的代码测量了脚本完成每个函数版本运行范围1000所需的时间。它保存了这些测量结果,并最终为每种情况创建一个平均值。结果如下表所示:

默认参数的使用

这显然不是一种惊人的优化。然而,它确实从我们的执行时间中节省了一些微秒,所以我们会记住它。只是记住,如果你是作为操作系统开发团队的一员工作,这种优化可能会引起问题。

列表推导和生成器

列表推导是 Python 提供的一种特殊结构,通过以数学家的方式编写来生成列表,通过描述其内容而不是描述内容应如何生成(使用经典的for循环)。

让我们通过一个例子来更好地理解它是如何工作的:

#using list comprehension to generate a list of the first 50 multiples of 2
multiples_of_two = [x for x in range(100) if x % 2 == 0]

#now let's see the same list, generated using a for-loop
multiples_of_two = []
for x in range(100):
  if x % 2 == 0:
    multiples_of_two.append(x)

现在,列表推导并不是要完全取代for循环。当处理像之前那样的创建列表的循环时,它们非常有帮助。然而,对于由于副作用而编写的for循环,它们并不特别推荐。这意味着你并没有创建一个列表。你很可能是调用它内部的一个函数或进行一些其他不转化为列表的计算。在这些情况下,列表推导表达式实际上会损害可读性。

要理解为什么这些表达式比常规for循环更高效,我们需要进行一些反汇编和阅读字节码。我们可以这样做,因为尽管 Python 是一种解释型语言,但它仍然被编译器翻译成字节码。这个字节码是解释器所解释的。因此,使用dis模块,我们可以将字节码转换成人类可读的形式,并分析其执行。

让我们看看代码:

import dis
import timeit

programs = dict(
    loop="""
multiples_of_two = []
for x in range(100):
  if x % 2 == 0:
    multiples_of_two.append(x)
""",
    comprehension='multiples_of_two = [x for x in range(100) if x % 2 == 0]',
)

for name, text in programs.iteritems():
    print name, timeit.Timer(stmt=text).timeit()
    code = compile(text, '<string>', 'exec')
    dis.disassemble(code)

那段代码将输出两件事:

  • 每段代码运行所需的时间

  • 由解释器生成的指令集,得益于dis模块

下面是输出结果的截图(在你的系统中,时间可能会变化,但其余部分应该相当相似):

列表推导和生成器

首先,输出证明代码的列表推导版本确实更快。现在,让我们将两个指令列表并排仔细查看,以更好地理解它们:

for循环指令 注释 列表推导指令 注释
BUILD_LIST BUILD_LIST
STORE_NAME 我们“倍数”列表的定义
SETUP_LOOP
LOAD_NAME 范围函数 LOAD_NAME 范围函数
LOAD_CONST 100(范围属性) LOAD_CONST 100(范围属性)
CALL_FUNCTION 调用 range CALL_FUNCTION 调用 range
GET_ITER GET_ITER
FOR_ITER FOR_ITER
STORE_NAME 我们临时变量 x STORE_NAME 我们临时变量 x
LOAD_NAME LOAD_NAME
LOAD_CONST X % 2 == 0 LOAD_CONST X % 2 == 0
BINARY_MODULO BINARY_MODULO
LOAD_CONST LOAD_CONST
COMPARE_OP COMPARE_OP
POP_JUMP_IF_FALSE POP_JUMP_IF_FALSE
LOAD_NAME LOAD_NAME
LOAD_ATTR 查找追加方法 LIST_APPEND 将值追加到列表
LOAD_NAME 加载 X 的值
CALL_FUNCTION 将实际值追加到列表
POP_TOP
JUMP_ABSOLUTE JUMP_ABSOLUTE
JUMP_ABSOLUTE STORE_NAME
POP_BLOCK LOAD_CONST
LOAD_CONST RETURN_VALUE
RETURN_VALUE

从前面的表中,你可以看到for循环如何生成更长的指令列表。推导式代码生成的指令几乎像是for循环生成的指令的一个子集,主要区别在于值的添加方式。对于for循环,它们是逐个添加的,使用三个指令(LOAD_ATTRLOAD_NAMECALL_FUNCTION)。另一方面,对于列表推导式列,这是通过一个单一的、优化的指令(LIST_APPEND)完成的。

提示

这就是为什么在生成列表时,for循环不应该成为你的首选武器。这是因为你正在编写的列表推导式更高效,有时甚至能写出更易读的代码。

话虽如此,请记住不要过度使用这些表达式,以替换每个for循环,即使是执行其他操作(副作用)的循环。在这些情况下,列表推导式没有优化,并且会比常规的for循环花费更长的时间。

最后,还有一个相关的考虑因素需要考虑:当生成大列表时,推导式可能不是最佳解决方案。这是因为它们仍然需要生成每个单独的值。所以,如果你正在生成包含 10 万个项目的列表,有一个更好的方法。你可以使用生成器表达式。它们不会返回列表,而是返回一个生成器对象,其 API 与列表类似。然而,每次你请求一个新的项目时,该项目将会动态生成。

生成器对象和列表对象之间的主要区别是,第一个对象不支持随机访问。所以,你实际上不能使用方括号表示法来执行任何操作。然而,你可以使用生成器对象来遍历你的列表:

my_list = (x**2 for x in range(100))
#you can't do this
print my_list[1]

#but you can do this
for number in my_list:
  print number

列表和生成器对象之间的另一个关键区别是,你只能对后者迭代一次,而你可以对列表进行多次相同的操作。这是一个关键的区别,因为它将限制你高效生成的列表的使用。因此,在决定使用列表推导式表达式还是生成器表达式时,请考虑这一点。

这种方法在访问值时可能会增加一点开销,但在创建列表时将会更快。以下是创建不同长度列表时列表推导式和生成器表达式的比较:

列表推导式和生成器

图表清楚地显示,对于较长的列表,生成器表达式的性能优于列表推导式表达式。对于较短的列表,列表推导式表达式更好。

ctypes

ctypes 库允许开发者深入 Python 的底层,并利用 C 语言的强大功能。这仅适用于官方解释器(CPython),因为它是用 C 编写的。其他版本,如 PyPy 或 Jython,不提供对这个库的访问。

这个 C 接口可以用来做很多事情,因为你实际上有加载预编译代码并从 C 中使用它的能力。这意味着你可以访问 Windows 系统上的 kernel32.dllmsvcrt.dll 库,以及 Linux 系统上的 libc.so.6 库。

对于我们的特定情况,我们将关注优化代码的方法,展示如何加载自定义 C 库以及如何加载系统库以利用其优化的代码。有关如何使用此库的完整详细信息,请参阅官方文档docs.python.org/2/library/ctypes.html

加载自己的自定义 C 库

有时候,无论我们在代码上使用多少优化技术,它们可能都不足以帮助我们达到最佳可能的时间。在这些情况下,我们总是可以将敏感的代码编写在我们的程序之外,用 C 语言编写,编译成库,然后将其导入到我们的 Python 代码中。

让我们看看如何实现这一点以及我们期望的性能提升。

要解决的问题非常简单,非常基础。我们将编写代码来生成一个包含一百万个整数的素数列表。

针对那个问题的 Python 代码可能如下所示:

import math
import time

def check_prime(x):
  values = xrange(2, int(math.sqrt(x)))
  for i in values:
    if x % i == 0:
      return False 

  return True

init = time.clock()
numbers_py = [x for x in xrange(1000000) if check_prime(x)]
print "%s" % (time.clock() - init)

上述代码已经足够简单。是的,我们可以通过将列表推导式表达式更改为生成器来轻松改进它。然而,为了展示从 C 代码中获得的改进,我们就不这样做。现在,C 代码的平均运行时间为 4.5 秒。

现在我们用 C 语言编写 check_prime 函数,并将其导出为一个共享库(.so 文件):

#include <stdio.h>
#include <math.h>

int check_prime(int a)
{
  int c;
  for ( c = 2 ; c <= sqrt(a) ; c++ ) { 
    if ( a%c == 0 )
     return 0;
  }

  return 1;

}

要生成库文件,请使用以下命令:

$gcc -shared -o check_primes.so -fPIC check_primes.c 

然后,我们可以编辑我们的 Python 脚本来运行函数的两个版本并比较时间,如下所示:

import time
import ctypes
import math

check_primes_types = ctypes.CDLL('./check_prime.so').check_prime

def check_prime(x):
  values = xrange(2, int(math.sqrt(x)))
  for i in values:
    if x % i == 0:
      return False 

  return True

init = time.clock()
numbers_py = [x for x in xrange(1000000) if check_prime(x)]
print "Full python version: %s seconds" % (time.clock() - init)

init = time.clock()
numbers_c = [x for x in xrange(1000000) if check_primes_types(x)]
print "C version: %s seconds" % (time.clock() - init)
print len(numbers_py)

前面的代码给出了以下输出:

完整 Python 版本 C 版本
4.49 秒 1.04 秒

性能提升相当显著。它已经从 4.5 秒降低到仅仅 1 秒!

加载系统库

有时,你不需要编写 C 函数。系统的库可能已经为你准备好了。你只需要导入那个库并使用该函数。

让我们再看看另一个简单的例子来演示这个概念。

下面的行生成了一百万个随机数字的列表,耗时 0.9 秒:

randoms = [random.randrange(1, 100) for x in xrange(1000000)]While this one, takes only 0.3 seconds:
randoms = [(libc.rand() % 100) for x in xrange(1000000)]

这里是运行这两行代码并打印出时间的完整代码:

import time
import random
from ctypes import cdll

libc = cdll.LoadLibrary('libc.so.6') #linux systems
#libc = cdll.msvcrt #windows systems

init = time.clock()
randoms = [random.randrange(1, 100) for x in xrange(1000000)]
print "Pure python: %s seconds" % (time.clock() - init)

init = time.clock()
randoms = [(libc.rand() % 100) for x in xrange(1000000)]
print "C version : %s seconds" % (time.clock() - init)

字符串连接

Python 字符串值得在这一章中单独讨论,因为它们与其他语言的字符串不同。在 Python 中,字符串是不可变的,这意味着一旦创建,就无法真正改变其值。

这是一个相对令人困惑的断言,因为我们习惯于对字符串变量进行诸如连接或替换之类的操作。然而,普通的 Python 开发者并没有意识到,幕后的操作远比他们想象的要多。

由于字符串对象是不可变的,每次我们对其进行任何更改内容的行为时,实际上是在创建一个全新的字符串,并让我们的变量指向那个新字符串。因此,我们在处理字符串时必须小心,确保我们确实想要这样做。

有一个非常简单的方法来检查前面的情况。下面的代码将创建一组具有相同字符串的变量(我们将每次都写这个字符串)。然后,使用id函数(在 CPython 中,它返回变量指向的值的内存地址),我们将它们相互比较。如果字符串是可变的,那么所有对象都将是不同的,因此返回的值应该是不同的。让我们看看代码:

a = "This is a string"
b = "This is a string"

print id(a) == id(b)  #prints  True

print id(a) == id("This is a string") #prints True

print id(b) == id("This is another String") #prints False

如代码注释所述,输出将是TrueTrueFalse,从而显示系统实际上每次我们写入This is a string字符串时都会重用该字符串。

下面的图像试图以更图形化的方式表示相同的概念:

字符串连接

尽管我们写了两次字符串,但内部,这两个变量都引用了相同的内存块(包含实际的字符串)。如果我们给其中一个赋值,我们不会改变字符串的内容。我们只是让我们的变量指向另一个内存地址。

字符串连接

在前面的例子中,我们有一个变量b直接指向变量a。尽管如此,如果我们尝试修改b,我们只是再次创建一个新的字符串。

字符串连接

最后,如果我们改变示例中两个变量的值会发生什么?内存中存储的 hello world 字符串会发生什么?嗯,如果没有其他引用,垃圾收集器最终会处理它,释放那块内存。

话虽如此,不可变对象并不全是坏事。如果使用得当,它们对性能实际上是有好处的,因为它们可以用作字典键,例如,甚至可以在不同的变量绑定之间共享(因为每次引用相同的字符串时都使用相同的内存块)。这意味着,无论你将这个字符串存储在什么变量中(就像我们之前看到的),字符串 hey there 都将是完全相同的对象。

有了这个想法,考虑一下一些常见情况会发生什么,例如以下这种情况:

full_doc = ""
for word in word_list:
  full_doc += word

上述代码将为 word_list 列表中的每个项目为 full_doc 生成一个新的字符串。这并不是真正的有效内存使用,对吧?当我们试图从不同部分重新创建一个字符串时,这是一个非常常见的情况。有一种更好、更节省内存的方法来做这件事:

full_doc = "".join(world_list)

这种替代方案更容易阅读,更快地编写,并且在内存和时间方面都更高效。以下代码显示了每个选项所需的时间。使用正确的命令,我们还可以看到 for 循环替代方案使用了更多的内存:

import time
import sys

option = sys.argv[1]

words =  [str(x) for x in xrange(1000000)]

if option == '1':
  full_doc = ""
  init = time.clock()
  for w in words:
    full_doc += w
  print "Time using for-loop: %s seconds" % (time.clock() - init)
else:
  init = time.clock()
  full_doc = "".join(words)
  print "Time using join: %s seconds" % (time.clock() - init)

使用以下命令我们可以执行脚本并测量内存使用,使用 Linux 工具 time

  • 对于 for-loop 版本:

    $ /usr/bin/time -f "Memory: %M bytes" python script.py 1 
    
    
  • 对于连接版本:

    $ /usr/bin/time -f "Memory: %M bytes" python script.py 0 
    
    

for-loop 版本的命令输出如下:

Time using for-loop: 0.155635 seconds
Memory: 66212 bytes

连接版本的命令输出如下:

Time using join: 0.015284 seconds
Memory: 66092 bytes

连接版本明显花费的时间更少,并且峰值内存消耗(由 time 命令测量)也更少。

当我们在 Python 中处理字符串时,我们还想考虑的其他用例是不同类型的连接;它在你只处理几个变量时使用,例如以下这种情况:

document = title + introduction + main_piece + conclusion

每当系统计算新的连接时,你都会创建一组子字符串。所以,一个更好、更高效的方法是使用变量插值:

document = "%s%s%s%s" % (title, introduction, main_piece, conclusion)

或者,使用 locals 函数创建子字符串甚至更好:

document = "%(title)s%(introduction)s%(main_piece)s%(conclusion)s" % locals()

其他技巧和窍门

之前提到的技巧是一些最常用的优化程序的技术。其中一些是 Python 特有的(例如字符串连接或使用 ctypes),而其他则是更通用的(例如记忆化和查找表)。

仍然有一些针对 Python 的特定的小技巧和窍门,我们将在下面介绍。它们可能不会带来显著的速度提升,但会更多地揭示语言的内部工作原理:

  • 成员测试:当试图确定一个值是否在列表中(我们在这里泛指“列表”,并不是特指类型 list)时,例如 "a in b",使用集合和字典(查找时间为 O(1))会比列表或元组得到更好的结果。

  • 不要重新发明轮子:Python 内置了用优化过的 C 编写的核心块。没有必要使用手工构建的替代品,因为后者很可能会更慢。建议使用如 liststuplessetsdictionaries 这样的数据类型,以及如 arrayitertoolscollections.deque 这样的模块。内置函数也适用于此处。它们总是比 map(operator.add, list1, list2) 这样的操作更快,而 map(lambda x, y: x+y, list1, list2) 则不是。

  • 别忘了 deque:当需要固定长度的数组或可变长度的栈时,列表表现良好。然而,当处理 pop(0)insert(0, your_list) 操作时,尽量使用 collections.deque,因为它在列表两端提供快速的(O(1))追加和弹出。

  • 有时最好是不要定义:调用函数会添加很多开销(如我们之前看到的)。因此,有时,尤其是在时间敏感的循环中,内联函数的代码,而不是调用该函数,将更高效。这个技巧有很大的权衡,因为它也可能大大损害可读性和可维护性。所以,只有在确实需要性能提升的情况下才应该这样做。以下简单的示例展示了简单的查找操作最终会增加相当多的时间:

    import time
    def fn(nmbr):
       return (nmbr ** nmbr) / (nmbr + 1)
    nmbr = 0
    init = time.clock()
    for i in range(1000):
       fn(i)
    print "Total time: %s" % (time.clock() - init)
    
    init = time.clock()
    nmbr = 0
    for i in range(1000):
      nmbr = (nmbr ** nmbr) / (nmbr + 1)
    print "Total time (inline): %s" % (time.clock() - init)
    
  • 当可能时,按键排序:在对列表进行自定义排序时,尽量不使用比较函数进行排序。相反,当可能时,按键排序。这是因为键函数每个项目只会被调用一次,而比较函数在排序过程中每个项目会被调用多次。让我们通过一个快速示例来比较这两种方法:

    import random
    import time
    
    #Generate 2 random lists of random elements
    list1 = [ [random.randrange(0, 100), chr(random.randrange(32, 122))] for x in range(100000)]
    list2 = [ [random.randrange(0, 100), chr(random.randrange(32, 122))] for x in range(100000)]
    
    #sort by string, using a comparison function
    init = time.clock()
    list1.sort(cmp=lambda a,b: cmp(a[1], b[1]))
    print "Sort by comp: %s" % (time.clock() - init) #prints  0.213434
    
    #sort by key, using the string element as key
    init = time.clock()
    list2.sort(key=lambda a: a[1])
    print "Sort by key: %s" % (time.clock() - init) #prints 0.047623
    
    
  • 1 比 True 好:Python 2.3 的 while 1 被优化为单次跳转,而 while True 则不是,因此需要多次跳转才能完成。这意味着编写 while 1 比编写 while True 更高效,尽管就像内联代码一样,这个技巧也有很大的权衡。

  • 多重赋值慢但...:多重赋值(a,b = "hello there", 123)通常比单次赋值慢。然而,再次强调,当进行变量交换时,它比常规方式更快(因为我们跳过了临时变量的使用和赋值):

    a = "hello world"
    b = 123
    #this is faster
    a,b = b, a
    #than doing
    tmp  = a
    a = b
    b = tmp
    
    
  • 链式比较是好的:当比较三个变量时,与其做 x < yy < z,你可以使用 x < y < z。这应该更容易阅读(更自然)并且运行更快。

  • 使用命名元组代替常规对象:当创建简单的对象来存储数据时,使用常规类表示法,实例包含一个用于属性存储的字典。对于属性较少的对象,这种存储可能会变得低效。如果你需要创建大量这样的对象,那么这种内存浪费就会累积。在这种情况下,你可以使用namedtuples。这是一个新的tuple子类,可以轻松构建,并且针对此任务进行了优化。有关namedtuples的详细信息,请查看官方文档docs.python.org/2/library/collections.html#collections.namedtuple。以下代码创建了 100 万个对象,既使用常规类也使用namedtuples,并显示了每个动作的时间:

    import time
    import collections
    
    class Obj(object):
      def __init__(self, i):
        self.i = i
        self.l = []
    all = {}
    
    init = time.clock()
    for i in range(1000000):
      all[i] = Obj(i)
    print "Regular Objects: %s" % (time.clock() - init) #prints Regular Objects: 2.384832
    
    Obj = collections.namedtuple('Obj', 'i l')
    
    all = {}
    init = time.clock()
    for i in range(1000000):
      all[i] = Obj(i, [])
    print "NamedTuples Objects: %s" % (time.clock() - init) #prints  NamedTuples Objects: 1.272023
    
    

摘要

在本章中,我们介绍了几种优化技术。其中一些旨在提供速度的大幅提升,以及/或节省内存。其中一些只是旨在提供轻微的速度提升。本章的大部分内容涵盖了 Python 特定的技术,但其中一些也可以翻译成其他语言。

在下一章中,我们将介绍优化技术。特别是,我们将涵盖多线程和多进程,你将学习何时应用每一种。

第五章:多线程与多进程

当谈到优化代码时,并发和并行性是两个很少被忽视的话题。然而,在 Python 的情况下,这些通常是用来批评该语言的议题。批评者通常指责使用这些机制与它们实际带来的好处(在某些情况下,这种好处可能根本不存在)之间的难度。

在这一章中,我们将看到批评者在某些情况下是正确的,在其他情况下是错误的。就像大多数工具一样,这些机制需要满足某些条件才能为开发者服务,而不是与他们作对。在我们对如何在 Python 中实现并行性以及何时这样做真正值得的内部结构进行巡游时,我们将讨论两个具体的话题:

  1. 多线程:这是尝试实现真正并行性的最经典方法。其他语言如 C++和 Java 也提供了这一功能。

  2. 多进程:尽管不太常见,并且可能存在一些难以解决的问题,但我们将讨论这一特性作为多线程的有效替代方案。

在阅读完这一章后,你将完全理解多线程和多进程之间的区别。此外,你还将了解什么是全局解释器锁GIL),以及它将如何影响你在尝试选择正确的并行技术时的决策。

并行性与并发性

这两个术语经常一起使用,甚至可以互换,但它们在技术上实际上是两回事。一方面,我们有并行性,这是指两个或更多进程可以同时运行的情况。例如,在多核系统中,每个进程都在不同的处理器上运行。

另一方面,并发性发生在两个或更多进程试图在同一个处理器上同时运行的情况下。这通常通过时间切片等技术来解决。然而,这些技术并不是真正并行地执行。只是因为处理器在任务之间切换的速度快,所以看起来像是并行的。

以下图表试图说明这一点:

并行性与并发性

并发性,例如,是所有现代操作系统使用的一种技术。这是因为无论计算机有多少处理器,系统本身可能需要同时运行更多的进程,更不用说用户可能想要做的事情了。因此,为了解决这个问题,操作系统将负责为每个需要它的进程调度处理器时间。然后,它将在它们之间切换上下文,给每个进程分配一段时间。

现在,考虑到这一点,我们如何在 Python 程序中实现并行性或并发性呢?这就是多线程和多进程发挥作用的地方。

多线程

多线程是程序在相同程序上下文中运行多个线程的能力。这些线程共享进程的资源,允许在并发模式(对于单处理器系统)和并行模式(对于多核系统)中同时运行多个操作。

将你的程序结构化以利用这些线程不是一项容易的任务。然而,它带来了一些非常有趣的好处:

  • 响应性:在单线程程序中,执行长时间运行的任务可能会使程序看起来冻结。多线程和将此类代码移动到工作线程,程序可以在同时执行长时间运行的任务的同时保持响应。

  • 执行速度更快:在多核处理器或多处理器系统中,多线程可以通过实现真正的并行性来提高程序的性能。

  • 资源消耗降低:使用线程,程序可以使用原始进程的资源来服务许多请求。

  • 简化共享和通信:由于线程已经共享相同的资源和内存空间,它们之间的通信比进程间通信要简单得多。

  • 并行化:多核或多处理器系统可以用来利用多线程并独立运行每个线程。Nvidia 的计算统一设备架构CUDA)(www.nvidia.com/object/cuda_home_new.html)或 Khronos Group 的 OpenCL(www.khronos.org/opencl/)是利用从几十到几百个处理器来并行运行任务的 GPU 计算环境。

多线程也有一些缺点:

  • 线程同步:由于线程可能处理相同的数据,你需要实现某种机制来防止竞态条件(导致数据读取损坏)。

  • 由问题线程引起的崩溃:尽管它可能看起来是独立的,但一个单独的问题线程出现问题并执行无效操作可能会使整个进程崩溃。

  • 死锁:这是与线程工作相关的一个常见问题。通常,当一个线程需要资源时,它会锁定该资源直到完成。当一条线程进入等待状态,等待第二条线程释放其资源,但第二条线程反过来又正在等待第一条线程释放其已锁定的资源时,就会发生死锁。

通常,这种技术应该足以在多处理器系统上实现并行性。然而,Python 的官方版本(CPython)有一个称为 GIL 的限制。这个 GIL 阻止多个原生线程同时运行 Python 的字节码,这实际上破坏了并行性。如果你有一个四处理器系统,你的代码不会以 400%的速度运行。相反,它只会以 100%或稍微慢一点的速度运行,因为线程带来的额外开销。

注意

注意,GIL 不仅仅是 Python(或 CPython)的发明。其他编程语言也有 GIL,例如 Ruby 的官方实现 Ruby MRI 或 OCaml(ocaml.org/))。

GIL 是必要的,因为 CPython 中的内存管理不是线程安全的。因此,通过强制所有内容按顺序运行,它确保没有任何东西会破坏内存。对于单线程程序来说,它也更快,简化了 C 扩展的创建,因为它们不需要考虑多线程。

然而,有一些方法可以绕过 GIL。例如,由于它只阻止线程同时运行 Python 的字节码,因此你可以在 C 中编码你的任务,让 Python 只是该代码的包装器。在这种情况下,GIL 不会阻止 C 代码同时运行所有线程。

另一个 GIL 不会影响性能的例子是一个网络服务器,它的大部分时间都花在从网络上读取数据包。在这种情况下,增加的并发性将允许服务更多的数据包,即使没有真正的并行性。这实际上提高了我们程序的性能(每秒可以服务更多的客户端),但它不会影响其速度,因为每个任务花费相同的时间

线程

现在,让我们简要谈谈 Python 中的线程,以便了解如何使用它们。它们由开始、执行序列和结束组成。还有一个指令指针,它跟踪线程在其上下文中当前运行的位置。

那个指针可以被抢占或中断,以便停止线程。或者,它也可以暂时挂起。这基本上意味着让线程休眠。

为了在 Python 中使用线程,我们有以下两种选择:

  • 线程模块:这提供了一些有限的能力来处理线程。它使用简单,对于小型任务,它增加的额外开销很小。

  • 线程模块:这是较新的模块,自 Python 2.4 版本以来就包含在 Python 中。它提供了更强大和更高层次的线程支持。

使用线程模块创建线程

虽然我们将重点关注线程模块,但我们会快速展示如何使用此模块进行更简单的时代,当脚本不需要大量工作时。

线程模块(docs.python.org/2/library/thread.html)提供了start_new_thread方法。我们可以传递以下参数:

  • 我们可以将其传递给一个包含实际要运行的代码的函数。一旦这个函数返回,线程将被停止。

  • 我们可以将其传递为一个参数元组。这个列表将被传递给函数。

  • 最后,我们可以将其传递为一个可选的命名参数字典。

让我们看看前面所有参数的一个例子:

#!/usr/bin/python

import thread
import time

# Prints the time 5 times, once every "delay" seconds
def print_time( threadName, delay):
   count = 0
   while count < 5:
      time.sleep(delay)
      count += 1
      print "%s: %s" % ( threadName, time.ctime(time.time()) )

# Create two threads as follows
try:
   thread.start_new_thread( print_time, ("Thread-1", 2, ) )
   thread.start_new_thread( print_time, ("Thread-2", 4, ) )
except:
   print "Error: unable to start thread"

# We need to keep the program working, otherwise the threads won't live

while True:
   pass

上述代码打印以下输出:

使用 thread 模块创建线程

上述代码足够简单,输出清楚地显示了两个线程实际上是并发运行的。关于这一点有趣的是,在代码中,print_time函数本身有一个内部循环。如果我们两次串行运行此函数,那么每次调用它都会持续5 * 延迟秒。

然而,使用线程并且不需要做任何改变,我们正在同时运行循环两次。

此模块还提供了其他有用的线程原语。以下是一个示例:

interrupt_main

此方法向主线程发送键盘中断异常。这实际上就像在程序运行时按下CTRL+C一样。如果没有捕获,发送信号的线程将终止程序。

exit

此方法静默地退出线程。这是一种在不影响其他任何事物的情况下终止线程的好方法。假设我们将我们的print_time函数改为以下代码行:

def print_time( threadName, delay):
   count = 0
   while count < 5:
      time.sleep(delay)
      count += 1
      print "%s: %s" % ( threadName, time.ctime(time.time()) )
      if delay == 2 and count == 2:
      thread.exit()

在这种情况下,输出结果如下:

使用 thread 模块创建线程

allocate_lock方法返回一个线程可以使用的锁。锁将帮助开发者保护敏感代码,并确保在执行期间没有竞态条件。

返回的锁对象具有这三个简单的方法:

  • acquire:这基本上是为当前线程获取锁。它接受一个可选的整数参数。如果它是零,只有在可以立即获取锁而无需等待的情况下,锁才会被获取。如果它不是零,锁将无条件地被获取(就像当你省略参数时)。这意味着如果线程需要等待以获取锁,它就会等待。

  • release:这将释放锁,以便下一个线程获取它。

  • locked:如果某个线程获取了锁,则返回TRUE。否则,返回FALSE

这是一个如何使用锁帮助多线程代码的非常基本的例子。以下代码使用 10 个线程递增全局变量。每个线程都会添加一个线程。所以,到最后,我们应该在该全局变量中有 10 个线程:

#!/usr/bin/python

import thread
import time

global_value = 0

def run( threadName ):
   global global_value
   print "%s with value %s" % (threadName, global_value)
   global_value = global_value + 1

for i in range(10):
   thread.start_new_thread( run, ("Thread-" + str(i), ) )

# We need to keep the program working, otherwise the threads won't live
while 1:
   pass

以下是前面代码的输出:

使用 thread 模块创建线程

不仅我们正确地增加了全局变量的值(我们只增加到2),我们还遇到了打印字符串的问题。在某些情况下,同一行中有两个字符串,而它们应该各自占据一行。这是因为当同一行中存在两个字符串时,两个线程都试图同时打印。那时,要打印的当前行在两种情况下都是相同的。

对于全局值,同样的情况也会发生。当线程 1368427 按顺序读取全局变量的值以添加 1 时,其值为 0(这是它们各自复制到 local_value 变量的值)。我们需要确保复制值、增加它并打印它的代码被保护(在锁内),以便没有两个线程可以同时运行它。为了实现这一点,我们将使用锁对象的两种方法:获取和释放。

使用以下代码行:

#!/usr/bin/python

import thread
import time

global_value = 0

def run( threadName, lock ):
   global global_value
   lock.acquire()
   local_copy = global_value
   print "%s with value %s" % (threadName, local_copy)
   global_value = local_copy + 1
   lock.release()

lock = thread.allocate_lock()

for i in range(10):
   thread.start_new_thread( run, ("Thread-" + str(i), lock) )

# We need to keep the program working, otherwise the threads won't live
while 1:
   pass

现在,输出结果更有意义了:

使用 thread 模块创建线程

现在的输出更有意义了,格式已修复,我们成功增加了变量的值。这两个修复都归因于锁定机制。关于代码,要增加 global_value 的值,锁阻止其他线程(那些尚未获取锁的线程)执行该代码部分(将其值读入局部变量并增加它)。因此,当锁处于活动状态时,只有获取锁的线程才能运行这些行。锁释放后,下一个排队等待的线程将执行相同的操作。前面的代码行返回当前线程的标识:

get_ident

这是一个非零整数,除了在活动线程列表中标识当前线程外,没有其他直接意义。线程死亡或退出后,此数字可以被回收,因此在程序的生命周期内不是唯一的。以下代码设置或返回创建新线程时使用的线程堆栈大小:

stack_size

这支持一个可选参数(“this”是要设置的堆栈大小)。此大小必须是 0 或至少 32.768(32 Kb)。根据系统,可能会有其他限制,甚至是对设置堆栈大小的限制。因此,在尝试使用此方法之前,请查阅您操作系统的手册。

注意

虽然这不是本书的目标版本,但在 Python 3 中,此模块已被重命名为 _thread

使用线程模块

这是当前和推荐在 Python 中处理线程的方式。此模块为此提供了一个更好、更高级的接口。它也增加了我们代码的复杂性,因为现在将不再可用 _thread 模块的简单性。

对于这种情况,我们可以引用本叔叔的话:

权力越大,责任越大

开个玩笑,threading 模块将线程的概念封装在一个类中,我们必须实例化它才能使用。

我们可以创建模块提供的 Thread 类的子类(docs.python.org/2/library/thread.html),这通常是首选方式。或者,如果我们想做一些非常简单的事情,我们甚至可以直接实例化该类。让我们看看前面的例子如何使用 threading 模块进行转换:

#!/usr/bin/python

import threading

global_value = 0

def run( threadName, lock ):
   global global_value
   lock.acquire()
   local_copy = global_value
   print "%s with value %s" % (threadName, local_copy)
   global_value = local_copy + 1
   lock.release()

lock = threading.Lock()

for i in range(10):
   t = threading.Thread( target=run, args=("Thread-" + str(i), lock) )
   t.start()

对于更复杂的事情,我们可能想要创建自己的线程类,以便更好地封装其行为。

当使用子类方法时,在编写自己的类时,你需要注意以下几点:

  • 它们需要扩展threading.Thread

  • 它们需要重写run方法,并且可选地重写__init__方法

  • 如果你重写了构造函数,请确保首先调用父类的构造函数(Thread.__init__)。

  • run方法停止或抛出未处理的异常时,线程将停止,因此请考虑这一点来规划你的方法。

  • 你可以在构造方法的name参数中为线程命名

虽然你将不得不重写run方法,其中将包含线程的主要逻辑,但你将无法控制该方法何时被调用。相反,你将调用start方法,该方法会创建一个新的线程,并使用该线程作为上下文调用run方法。

现在让我们看看一个关于线程工作中非常常见的陷阱的简单例子:

import threading
import time

class MyThread(threading.Thread):

  def __init__(self, count):
    threading.Thread.__init__(self)
    self.total = count

  def run(self):

    for i in range(self.total):
      time.sleep(1)
      print "Thread: %s - %s" % (self.name, i)

t = MyThread(4)
t2 = MyThread(3)

t.start()
t2.start()

print "This program has finished"

以下代码的输出如下:

使用线程模块

如前一个截图所示,程序在发送退出消息之前发送了其他消息。在这种情况下,这不是一个大问题。然而,如果我们有类似的情况:

#....
f = open("output-file.txt", "w+")
t = MyThread(4, f)
t2 = MyThread(3, f)

t.start()
t2.start()
f.close() #close the file handler
print "This program has finished"

注意

注意,前面的代码将失败,因为它将在任何线程试图以任何方式使用文件句柄之前关闭它。如果我们想避免这类问题,我们需要使用join方法,这将使调用线程暂停,直到目标线程完成执行。

在我们的情况下,如果我们从主线程中使用join方法,它将确保程序在两个线程完成执行之前不会继续执行主命令链。我们需要确保在两个线程启动后使用join方法。否则,我们可能会以串行方式运行它们:

#...
t.start()
t2.start()
#both threads are working, let's stop the main thread
t.join() 
t2.join()
f.close() #now that both threads have finished, lets close the file handler
print "This program has finished"

此方法还接受一个可选参数:一个超时时间(一个floatNone),单位为秒。然而,join方法始终返回None。因此,为了确定操作是否确实超时,我们需要在join方法返回后检查线程是否仍然存活(使用isAlive方法)。如果线程仍然存活,则操作超时。

现在让我们看看另一个简单脚本的例子,用于检查一系列网站的返回状态码。此脚本只需要几行代码就可以遍历列表并收集返回的状态码:

import urllib2

sites = [
  "http://www.google.com",
  "http://www.bing.com",
  "http://stackoverflow.com",
  "http://facebook.com",
  "http://twitter.com"
]

def check_http_status(url):
  return urllib2.urlopen(url).getcode()

http_status = {}
for url in sites:
  http_status[url] = check_http_status(url)

for  url in http_status#:
  print "%s: %s" % (url, http_status[url])

如果你使用 Linux 上的时间命令行工具运行前面的代码,你还可以得到执行所需的时间:

$time python non_threading_httpstatus.py

输出如下:

使用线程模块

现在,看看代码以及我们迄今为止所看到的,一个明显的优化是将 I/O 密集型函数(check_http_status)转换为线程。这样,我们可以并发地检查所有站点的状态,而不是等待每个请求完成后再处理下一个:

import urllib2
import threading

sites = [
  "http://www.google.com",
  "http://www.bing.com",
  "http://stackoverflow.com",
  "http://facebook.com",
  "http://twitter.com"
]

class HTTPStatusChecker(threading.Thread):

  def __init__(self, url):
    threading.Thread.__init__(self)
    self.url = url
    self.status = None

  def getURL(self):
    return self.url

  def getStatus(self):
    return self.status

  def run(self):
    self.status = urllib2.urlopen(self.url).getcode()

threads = []
for url in sites:
  t = HTTPStatusChecker(url)
  t.start() #start the thread
  threads.append(t) 

#let the main thread join the others, so we can print their result after all of them have finished.
for t in threads:
  t.join()

for  t in threads:
  print "%s: %s" % (t.url, t.status)

使用时间运行新脚本会产生以下结果:

$time python threading_httpstatus.py

我们将得到以下输出:

使用线程模块

显然,线程化的替代方案更快。在我们的例子中,它几乎快了三倍,这是一个惊人的改进。

使用事件进行线程间通信

尽管线程通常被认为是单个或并行工作者,但有时允许它们相互通信是有用的。

要实现这一点,线程模块提供了事件构造(docs.python.org/2/library/threading.html#event-objects)。它包含一个内部标志,调用线程可以使用set()clear()来操作。

Event类有一个非常简单的接口。以下是该类内部提供的方法:

  • is_set:如果事件的内部标志被设置,则返回True

  • set:这将内部标志设置为True。唤醒所有等待此标志被设置的线程。调用wait()的线程将不再被阻塞。

  • clear:这将重置内部标志。任何调用wait()方法的线程将变为阻塞状态,直到再次调用set()

  • wait:这将阻塞调用线程,直到事件的内部标志被设置。此方法接受一个可选的超时参数。如果指定了超时并且与none不同,则线程将只被该超时阻塞。

让我们看看使用事件在两个线程之间进行通信的简单示例,以便它们可以轮流向标准输出打印。两个线程将共享同一个事件对象。一个线程会在while循环的每次迭代中设置它,而另一个线程如果设置了它,就会清除它。在每次操作(setclear)时,它们会打印出正确的字母:

import threading
import time

class ThreadA(threading.Thread):

  def __init__(self, event):
    threading.Thread.__init__(self)
    self.event = event

  def run(self):
    count = 0
    while count < 5:
      time.sleep(1)
      if self.event.is_set():
        print "A"
        self.event.clear()
      count += 1

class ThreadB(threading.Thread):

  def __init__(self, evnt):
    threading.Thread.__init__(self)
    self.event = evnt

  def run(self):
    count = 0
    while count < 5:
      time.sleep(1)
      if not self.event.is_set():
        print "B"
        self.event.set()
      count += 1

event = threading.Event()

ta = ThreadA(event)
tb = ThreadB(event)

ta.start()
tb.start()

总之,以下表格显示了何时使用多线程以及何时不使用:

使用线程 不使用线程
对于重 I/O 密集型脚本 用于优化重 CPU 密集型脚本
当并行化可以被并发替代时 对于必须利用多核系统的程序
用于 GUI 开发

多进程

如我们之前所见,由于 GIL 的存在,Python 中的多线程无法实现真正的并行化。因此,某些类型的应用程序将不会从使用此模块中获得真正的益处。

相反,Python 提供了一个名为多进程的替代多线程的方法。在多进程中,线程被转换为单独的子进程。每个子进程都将运行自己的 GIL(这意味着可以同时运行多个并行 Python 进程,没有限制)。

为了澄清,线程都是同一进程的一部分,它们共享相同的内存、空间和资源。另一方面,进程不与它们的父进程共享内存空间,因此它们之间进行通信可能更复杂。

与多线程相比,这种方法既有优点也有缺点:

优点 缺点
利用多核系统 内存占用更大
分离的内存空间消除了竞争条件 在进程间共享可变数据更困难
子进程容易中断(可杀) 进程间通信IPC)比线程更困难
避免了全局解释器锁(GIL)的限制(尽管仅在 CPython 的情况下)

Python 的多进程

multiprocessing模块(docs.python.org/2/library/multiprocessing.html)提供了Process类,该类反过来又具有与threading.Thread类相似的 API。因此,将代码从多线程迁移到多进程并不像人们想象的那么困难,因为你的代码的基本结构将保持不变。

让我们快速看一下我们可能如何构建一个多进程脚本的结构:

#!/usr/bin/python

import multiprocessing

def run( pname ):
  print pname

for i in range(10):
  p = multiprocessing.Process(target=run, args=("Process-" + str(i), ))
  p.start()
  p.join()

前面的代码是一个基本示例,但它展示了代码与多线程是多么相似。

注意

注意,在 Windows 系统上,您需要添加一个额外的检查以确保当子进程包含主代码时,它不会再次执行。为了澄清,主代码应如下所示(如果您计划在 Windows 上运行它):

#!/usr/bin/python

import multiprocessing

def run( pname ):
  print pname

if __name__ == '__main__':
  for i in range(10):
    p = multiprocessing.Process(target=run, args=("Process-" + str(i), ))
    p.start()
    p.join()

退出状态

当每个进程完成(或终止)时,它都有一个退出代码,这是一个表示执行结果的数字。这个数字可能表示进程正确完成、错误完成,或者被另一个进程终止。

为了更精确:

  • 代码值等于0表示没有任何问题

  • 代码值大于0表示进程失败并以该代码退出

  • 代码值小于0表示进程被-1 exit_code信号杀死

以下代码展示了如何读取退出代码以及它是如何根据任务的结果设置的:

import multiprocessing
import time

def first():
  print "There is no problem here"

def second():
  raise RuntimeError("Error raised!")

def third():
  time.sleep(3)
  print "This process will be terminated"

workers = [ multiprocessing.Process(target=first), multiprocessing.Process(target=second), multiprocessing.Process(target=third)]

for w in workers:
  w.start()

workers[-1].terminate()

for w in workers:
  w.join()

for w in workers:
  print w.exitcode

该脚本的输出如下截图所示:

退出状态

注意到第三个工作进程的print属性从未被执行。这是因为该进程在sleep方法完成之前就被终止了。同样重要的是要注意,我们在三个工作进程上执行了两个不同的for循环:一个用于启动它们,另一个用于使用join()方法将它们连接起来。如果我们,例如,在启动每个子进程时执行join()方法,那么第三个子进程就不会失败。实际上,它会返回退出代码为零(没有问题),因为与多线程一样,join()方法将阻塞调用进程,直到目标进程完成。

进程池

此模块还提供了Pool类(docs.python.org/2/library/multiprocessing.html#module-multiprocessing.pool),它代表一组工作进程池,可以方便地在子进程中以不同的方式执行一系列任务。

此类提供的主要方法包括:

  • apply:在单独的子进程中执行一个函数。它也会阻塞调用进程,直到被调用函数返回。

  • apply_async:在单独的子进程中异步执行一个函数,这意味着它将立即返回。它返回一个ApplyResult对象。要获取实际的返回值,需要使用get()方法。此操作将阻塞,直到异步执行函数完成。

  • map:对一系列值执行一个函数。这是一个阻塞操作,因此返回的值是函数应用于列表中每个值的返回结果。

每一个都提供了不同的方式来遍历你的数据,无论是异步、同步,甚至是逐个。这完全取决于你的需求。

进程间通信

现在,让进程相互通信并不像我们之前提到的那么简单。然而,Python 为我们提供了几个工具来实现这一点。

Queue类提供了一个线程安全和进程安全的先进先出FIFO)(docs.python.org/2/library/multiprocessing.html#exchanging-objects-between-processes)机制来交换数据。多进程模块提供的Queue类几乎与Queue.Queue相同,因此可以使用相同的 API。以下代码展示了两个进程通过Queue进行交互的示例:

from multiprocessing import Queue, Process
import random

def generate(q):
  while True:
    value = random.randrange(10)
    q.put(value)
    print "Value added to queue: %s" % (value)

def reader(q):
  while True:
    value = q.get()
    print "Value from queue: %s" % (value)

queue = Queue()
p1 = Process(target=generate, args=(queue,))
p2 = Process(target=reader, args=(queue,))

p1.start()
p2.start()
管道

管道提供了(docs.python.org/2/library/multiprocessing.html#exchanging-objects-between-processes)两个进程之间双向通信的通道。Pipe()函数返回一对连接对象,每个对象代表管道的一侧。每个连接对象都有send()recv()方法。

以下代码展示了管道构造的简单用法,类似于前面的队列示例。此脚本将创建两个进程:一个进程将生成随机数并通过管道发送,另一个进程将读取相同的数并将数字写入文件:

from multiprocessing import Pipe, Process
import random

def generate(pipe):
   while True:
    value = random.randrange(10)
    pipe.send(value)
    print "Value sent: %s" % (value)

def reader(pipe):
   f = open("output.txt", "w")
   while True:
     value = pipe.recv()
     f.write(str(value))
     print "."

input_p, output_p = Pipe()
p1 = Process(target=generate, args=(input_p,))
p2 = Process(target=reader, args=(output_p,))

p1.start()
p2.start()
事件

它们也存在于多进程模块中,并且几乎以相同的方式工作。开发者只需要记住,事件对象不能传递给工作函数。如果你尝试这样做,将会引发运行时错误,指出信号量对象只能通过继承在进程间共享。这意味着你不能像以下代码所示进行操作:

from multiprocessing import Process, Event, Pool
import time

event = Event()
event.set()

def worker(i, e):
    if e.is_set():
      time.sleep(0.1)
      print "A - %s" % (time.time())
      e.clear()
    else:
      time.sleep(0.1)
      print "B - %s" % (time.time())
      e.set()

pool = Pool(3)
pool.map(worker, [ (x, event) for x in range(9)])
Instead, you'd have to do something like this:
from multiprocessing import Process, Event, Pool
import time

event = Event()
event.set()

def worker(i):
   if event.is_set():
     time.sleep(0.1)
     print "A - %s" % (time.time())
     event.clear()
   else:
     time.sleep(0.1)
     print "B - %s" % (time.time())
     event.set()

pool = Pool(3)
pool.map(worker, range(9))

概述

现在我们已经涵盖了这两种替代方案、它们的主要特性以及它们的优缺点,最终选择哪一个完全取决于开发者。显然,没有哪一个更好,因为它们适用于不同的场景,尽管它们可能看起来完成了相同的事情。

本章的主要收获应该是前面提到的要点,即每种方法的主要特性以及何时应该使用每种方法。

在下一章中,我们将继续探讨优化工具。这次,我们将探讨 Cython(一种允许您将 Python 代码编译为 C 的替代方案)和 PyPy(一种用 Python 编写的替代解释器,它不像 CPython 那样受 GIL 的限制)。

第六章. 通用优化选项

在不断追求掌握优化的道路上,我们首先在第四章优化一切中介绍了一些技巧和窍门。在第五章多线程与多进程中,我们讨论了两种主要的优化策略:多线程和多进程。我们看到了它们如何帮助我们以及何时使用它们。

最后,我们将处理 Python 语言(CPython)的许多实现之一。这意味着除了 CPython 之外还有其他替代方案。在本章中,我们将介绍其中的两个:

  • 我们将介绍 PyPy,这是本书中一直使用的标准 Python 解释器的替代方案。这个版本是用 Python 编写的,并且比标准版本有一些优势。

  • 我们将讨论 Cython,一个优化静态编译器,它将使我们能够轻松地编写 Python 代码并利用 C 和 C++的强大功能。

这两种替代方案都将为开发者提供以更优化方式运行代码的机会,当然,这取决于代码的特性。对于每个选项,我们将探讨它们究竟是什么,如何安装它们,以及一些示例代码来展示如何使用它们。

PyPy

就像 CPython 是 Python 规范的官方实现,并且是用 C 语言编写的(当然),PyPy 是 Python 的另一种实现,适用于 2.x 和 3.x 版本。它试图模仿 RPython 语言的行为,RPython 是 Python 的一个静态类型限制版本。

PyPy 项目(pypy.org/)是另一个名为 Psycho 的较老项目的延续,Psycho 是一个用 C 语言编写的 Python JIT 编译器。它在 32 位 Intel 处理器上工作得很好,但从未更新过。它的最新稳定版本是在 2007 年,因此现在已弃用。PyPy 在 2007 年通过其 1.0 版本接管了 Psycho。尽管最初被认为是一个研究项目,但随着时间的推移,它不断发展。最终,在 2010 年,发布了 1.4 版本。在这个版本中,人们对用 PyPy 编写的系统是生产就绪且与 Python 2.5 兼容的信心有所增加。

PyPy 的最新稳定版本,于 2014 年 6 月发布,版本为 2.5,它反过来与 Python 2.7 兼容。还有一个 PyPy3 的 beta 版本,正如预期的那样,是与 Python 3.x 兼容的 PyPy 版本。

我们将介绍 PyPy 作为我们脚本优化可行方法的原因是这些特性:

  • 速度:PyPy 的主要特性之一是其相对于常规 Python 的速度提升。这得益于内置的即时编译器JIT)。它提供了对静态编译代码的灵活性,因为它可以在执行时适应当前平台(处理器类型、操作系统版本等)。另一方面,静态编译程序可能需要一个可执行文件或每个单独的情况组合。

  • 内存:使用 PyPy 执行内存消耗型脚本时,比使用常规 CPython 消耗的内存要少得多。

  • 沙盒:PyPy 提供了一个沙盒环境,其中每个对外部 C 库的调用都被模拟。这些调用与处理实际策略的外部进程进行通信。尽管这个特性很有前景,但它仍然只是一个原型,需要更多的工作才能变得有用。

  • 无栈:PyPy 也提供了一组与 Stackless Python 相当的语言功能(www.stackless.com/)。有些人甚至认为它比后者更强大和灵活。

安装 PyPy

有几种方法可以将 PyPy 安装到您的系统中:

  • 您可以直接从他们的页面下载二进制文件(pypy.org/download.html#default-with-a-jit-compiler)。只需确保您下载了正确的文件,根据他们网站上链接旁边的操作系统指示即可。否则,有很大可能它不会在您的系统上运行:安装 PyPy

    如果您使用的是 Linux 发行版或 OS X,您可以检查其官方软件包仓库是否包含 PyPy 软件包。通常,像 Ubuntu、Debian、Homebrew、MacPorts、Fedora、Gentoo 和 Arch 这样的系统已经包含了它。对于 Ubuntu,您可以使用以下代码行:

    $ sudo apt-get install pypy
    
    
  • 最后,另一个选项是下载源代码并自行编译。这可能比下载二进制文件更困难。然而,如果操作正确,这将确保生成的安装与您的系统完全兼容。

    注意

    但是要警告,从源代码编译可能听起来像是一项简单的任务,但它将花费相当多的时间。在一个 i7 处理器上,8 GB 的 RAM,整个过程大约花费了一个小时,如下面的截图所示:

    安装 PyPy

即时编译器

这是 PyPy 提供的主要功能之一。这是它与常规 Python(CPython)相比速度更快的最主要原因。

根据 PyPy 的官方网站,性能可能会根据任务而变化,但平均而言,这个编译器声称比 CPython 快七倍。

通常,对于标准编译程序,我们在第一次执行之前将整个源代码转换成机器代码。否则,我们将无法尝试它。这是通常编译程序经过的标准步骤(预处理和源代码翻译,最后是汇编和链接)。

JIT 意味着我们的代码将在执行时而不是在执行前进行编译。通常发生的情况是,代码在两步过程中被翻译:

  1. 首先,原始源代码被转换成中间语言。对于某些语言,如 Java,它被称为字节码。

  2. 在我们得到字节码后,我们开始编译它并将其转换为机器码,但仅在我们需要时进行。即时编译器的一个特点是它们只编译需要运行的代码,而不是一次性编译所有代码。

第二步是区分此类实现与其他解释语言(如 CPython,当字节码被解释而不是编译时)的不同之处。此外,即时编译器通常缓存编译后的代码,以便下次需要时避免编译开销。

考虑到所有这些,很明显,为了使程序真正利用即时编译器,它至少需要运行几秒钟,以便指令缓存能够生效。否则,效果可能与预期相反,因为编译的开销将是开发者唯一能注意到的实时差异。

使用即时编译器的一个主要优点是正在执行的程序能够针对其运行的特定系统(包括 CPU、操作系统等)优化机器码。因此,它提供了一种完全超出静态编译(甚至解释)程序范围的灵活性。

沙盒技术

尽管 PyPy 的沙盒功能仍被视为原型,但我们将探讨其基本内部工作原理,以了解它提供的潜力。

沙盒技术包括提供一个安全的环境,其中不受信任的 Python 代码可以运行,而无需担心对宿主系统造成损害。

在 PyPy 中,这特别通过双进程模型实现:

  1. 在一方面,我们有一个定制的 PyPy 版本,专门编译以在沙盒模式下运行。特别是,这意味着任何库或系统调用(例如 I/O)都会被打包到stdout,等待打包的响应返回。

  2. 另一方面,我们有一个容器进程,它可能正在使用 PyPy 或 CPython 运行。此进程将负责处理来自内部 PyPy 进程的库和系统调用:沙盒技术

上述图示显示了整个过程,其中在沙盒模式下执行的 Python 代码正在执行外部库调用。

容器进程是决定提供哪种虚拟化的进程。例如,内部进程可能正在创建文件句柄,实际上这些句柄是由容器进程伪造的。该进程充当真实操作系统和沙盒进程之间的一个层。

注意,前面解释的机制与语言层面的沙盒技术非常不同。整个指令集都对开发者可用。因此,你可以通过代码实现一个非常透明且安全的系统,这些代码可以在标准系统和受保护系统中运行。

优化即时编译器

正如我们之前讨论的,PyPy 的 JIT 是使其与 CPython 实现区分开来的因素。正是这个特性使得它在运行 Python 代码时如此之快。

直接在我们的未更改的 Python 代码上使用 PyPy,我们很可能会得到更好的结果。然而,如果我们想进一步优化我们的代码,我们应该考虑一些指导原则。

想象函数

JIT 通过分析哪些函数比其他函数“更热”(执行次数更多)来工作。因此,我们最好将我们的代码结构化为函数,特别是对于将被重复执行的函数。

让我们看看一个快速示例。以下代码将显示直接内联执行相同计算与将其封装在函数内并处理与函数查找和函数调用本身相关的额外时间之间的时间差异:

import math
import time

TIMES = 10000000

init = time.clock()
for i in range(TIMES):
    value = math.sqrt(i * math.fabs(math.sin(i - math.cos(i))))

print "No function: %s" % ( init - time.clock())

def calcMath(i):
    return math.sqrt(i * math.fabs(math.sin(i - math.cos(i))))
init = time.clock()
for i in range(TIMES):
    value = calcMath(i)
print "Function: %s" % ( init – time.clock())

代码非常简单,但你仍然可以看到第二个输出显示了它是更快的实现。普通的旧 CPython 将以相反的方式工作,因为没有对代码进行实时优化。第二种方法会因为函数查找和函数调用代码的开销而得到稍微差一点的结果。然而,PyPy 和它的 JIT 再次证明,如果你想为它们优化代码,你需要停止用老的方式思考。

想象函数

前面的截图结果显示了我们之前一直在讨论的内容:

  • PyPy 运行的代码比 CPython 快得多

  • JIT 正在实时优化我们的代码,而 CPython 则不是

考虑使用 cStringIO 来连接字符串

这不是一个小优化,无论是从代码更改还是从实现的优化来看。我们已经讨论了对于 Python 来说,字符串是不可变对象的事实。所以,如果我们想将大量字符串连接成一个单一的字符串,我们最好使用另一种结构而不是字符串本身,因为那会带来最差的表现。

在 PyPy 的情况下,这仍然成立。然而,我们不会使用列表作为最佳选择,而是会使用 cStringIO 模块 (pymotw.com/2/StringIO/),正如我们将看到的,它提供了最佳结果。

注意,由于 PyPy 的特性,提到 cStringIO 而不是 StringIO 可能会令人困惑,因为我们引用的是 C 标准库而不是纯 Python 库。这是正确和有效的,因为一些在 CPython 中常见的 C 标准库在 PyPy 上也能正确工作。在我们的例子中,以下代码将以三种不同的方式(使用简单字符串、使用 cStringIO 库,最后使用列表)计算执行相同连接操作所需的时间:

from cStringIO import StringIO
import time

TIMES = 100000

init = time.clock()
value = ''
for i in range(TIMES):
    value += str(i)
print "Concatenation: %s" % ( init - time.clock())

init = time.clock()
value = StringIO()
for i in range(TIMES):
    value.write(str(i))
print "StringIO: %s" % ( init - time.clock())

init = time.clock()
value = []
for i in range(TIMES):
    value.append(str(i))
finalValue = ''.join(value)
print "List: %s" % ( init - time.clock())

在这三种替代方案中,StringIO 在 PyPy 中是最好的。它比简单字符串连接要好得多,甚至比使用列表还要好一点。

如果我们通过 CPython 运行相同的代码,我们将得到不同的结果。因此,最好的解决方案仍然是使用列表。

考虑使用 cStringIO 来连接字符串

前面的截图证实了这一点。注意,在使用 PyPy 时,第一种方法在性能方面尤其糟糕。

禁用 JIT 的操作

虽然这并不是直接的优化,但有一些特定的方法,如果我们使用它们,将会降低 JIT 的有效性。因此,了解这些方法很重要。

以下来自 sys 模块的三种方法可以禁用 JIT(根据当前 PyPy 版本;当然,这在未来可能会改变):

  • _getframe:此方法从 callstack 返回一个帧对象。它甚至接受一个可选的深度参数,可以从 callstack 返回帧对象。性能惩罚相当大,因此只有在绝对需要时才推荐使用,例如在开发调试器时。

  • exc_info:此方法返回一个包含三个元素的元组,提供了有关正在处理的异常的信息。这些元素是 typevaluetraceback。它们在这里解释:

    • type:这是正在处理的异常的类型

    • value:此方法获取异常参数

    • traceback:此方法获取 traceback 对象,它封装了异常抛出时的 callstack 对象

  • Settrace:此方法设置跟踪函数,允许您在 Python 中跟踪 Python 代码。如前所述,除非绝对必要,否则不建议使用,因为它需要禁用 JIT 才能正常工作。

代码示例

作为这个主题的最后一个例子,让我们来看看 great_circle 函数的代码(稍后解释)。大圆计算包括在地球表面上找到两点之间的距离。

脚本将执行 500 万次迭代的 for 循环。特别是,它反复调用相同的函数(精确地说是 500 万次)。这种场景对于 CPython 解释器来说并不理想,因为它将完成那么多次函数查找。

然而,另一方面,正如我们之前提到的,随着时间的推移调用相同的函数可以让 PyPy 的 JIT 开始优化这个调用。这基本上意味着在我们的情况下,代码已经对 PyPy 进行了某种程度的优化:

import math

def great_circle(lon1,lat1,lon2,lat2):
    radius = 3956 #miles
    x = math.pi/180.0

    a = (90.0-lat1)*(x)
    b = (90.0-lat2)*(x)
    theta = (lon2-lon1)*(x)
    c = math.acos((math.cos(a)*math.cos(b)) + (math.sin(a)*math.sin(b)*math.cos(theta))) 
    return radius*c

lon1, lat1, lon2, lat2 = -72.345, 34.323, -61.823, 54.826
num = 5000000

for i in range(num):great_circle(lon1,lat1,lon2,lat2)

前面的代码可以根据我们刚才提到的相同原则进一步优化。我们可以将 great_circle 函数中的一行移到单独的函数中,从而进一步优化执行,如下所示:

import math

def calcualte_acos(a, b ,theta):
 return math.acos((math.cos(a)*math.cos(b)) + (math.sin(a)*math.sin(b)*math.cos(theta)))

def great_circle(lon1,lat1,lon2,lat2):
    radius = 3956 #miles
    x = math.pi/180.0

    a = (90.0-lat1)*(x)
    b = (90.0-lat2)*(x)
    theta = (lon2-lon1)*(x)
    c = calcualte_acos(a, b, theta)
    return radius*c

lon1, lat1, lon2, lat2 = -72.345, 34.323, -61.823, 54.826
num = 5000000

for i in range(num):
  great_circle(lon1,lat1,lon2,lat2)

您可以看到我们如何将 acos 计算移动到单独的函数中,因为它是整个函数中最昂贵的行(那里总共调用了六个三角函数)。通过将这一行移动到另一个函数中,我们允许 JIT 处理其调用的优化。

最后,由于这个简单的改变以及我们使用 PyPy 而不是常规 Python 的事实,我们的执行时间为 0.5 秒。相反,如果我们使用常规 CPython 运行相同的代码,我们将会得到 4.5 秒的时间(在我的当前机器上),这要慢得多。

Cython

虽然技术上讲,Cython (cython.org/) 并非完全等同于使用标准的 CPython 解释器,但它允许我们编写 Python 代码并将其编译成 C 代码(这是 CPython 所不具备的)。

你会看到 Cython 可以被视为一个转译器,这仅仅意味着它是一段软件,旨在将源代码从一种语言翻译成另一种语言。还有其他类似的产品,例如 CoffeeScript 和 Dart。两者都是截然不同的语言,并且都被翻译成 JavaScript。

在我们的案例中,Cython 将 Python 的超集(语言的扩展版本)转换为优化的 C/C++代码。然后,它被编译成一个 Python 扩展模块。这反过来又允许开发者:

  • 编写调用 C 或 C++代码的 Python 代码

  • 使用静态类型声明将 Python 代码调整为 C 级性能

静态类型是允许这个转译器生成优化 C 代码的关键特性,从而使 Cython 从 Python 的动态特性中脱离出来,进入一个更静态、更快的领域(有时甚至可以快几个数量级)。

当然,这使得 Python 代码更加冗长,这反过来可能会损害其他方面,如可维护性和可读性。因此,通常情况下,除非有某种明确的证据表明添加它确实会生成运行速度更快的代码,否则不建议使用静态类型。

所有 C 类型都可供开发者使用。Cython 准备在赋值时自动执行类型转换。在 Python 的任意长整数特殊情况下,当转换为 C 的整数时,如果发生溢出,将引发 Python 溢出错误。

下表展示了纯 Python 版本和 Cython 版本的相同示例:

Python 版本 Cython 版本

|

def f(x):
    return x**2-x

def integrate_f(a, b, N):
    s = 0
    dx = (b-a)/N
    for i in range(N):
        s += f(a+i*dx)
    return s * dx

|

def f(double x):
    return x**2-x

def integrate_f(double a, double b, int N):
    cdef int i
    cdef double s, dx
    s = 0
    dx = (b-a)/N
    for i in range(N):
        s += f(a+i*dx)
    return s * dx

|

两个代码之间的主要区别被突出显示。这仅仅是每个变量的类型定义,包括两个函数接收的参数以及使用的局部变量。仅凭这一点,Cython 就可以生成左侧代码的优化 C 版本。

安装 Cython

安装 Cython 到你的系统中有几种方法。然而,在每种情况下,共同的要求是之前已经安装了一个 C 编译器。我们不会详细说明这一步骤,因为指令可能因系统而异。

一旦安装了 C 编译器,为了获取 Cython,你可以执行以下步骤:

  1. 从他们的网站上下载最新版本 (cython.org),解压 tarball 文件,进入目录,并运行以下命令:

    $python setup.py install
    
    
  2. 如果你已经在系统中安装了设置工具,你可以运行此命令:

    $pip install cython
    
    

    注意

    如果你已经使用以下开发环境之一,那么 Cython 很可能已经安装在你的系统中。然而,你也可以使用前面的步骤来更新你的当前版本:

    • Anaconda

    • Enthought Canopy

    • PythonXY

    • Sage

构建 Cython 模块

Cython 能够将我们的代码编译成 C 模块,我们稍后可以将这些模块导入到主代码中。为了做到这一点,你需要执行以下步骤:

  1. 首先,需要使用 Cython 将.pyx文件编译(或转换)成.c文件。这些是源代码文件,基本上是带有 Cython 添加的一些扩展的 Python 代码。我们稍后会看到一些示例。

  2. .c文件将被 C 编译器编译成.so库。这个库稍后可以被 Python 导入。

  3. 如前所述,我们有几种方法可以编译代码:

    • 我们可以创建一个distutils设置文件。Distutils 是一个模块,它简化了其他模块的创建,因此我们可以使用它来生成我们自定义的 C 编译模块。

    • 我们可以运行cython命令行,从.pyx文件创建一个.c文件。然后,使用 C 编译器手动将 C 代码编译成库。

    • 最后,另一个选项是使用pyximport模块,并将.pyx文件作为.py文件导入。

  4. 为了说明前面的要点,让我们通过使用distutils选项来查看一个示例:

    #test.pyx
    def join_n_print(parts):
        print ' '.join(parts)
    
    #test.py
    from test import join_n_print
    join_n_print( ["This", "is", "a", "test"] )
    
    #setup.py
    from distutils.core import setup
    from Cython.Build import cythonize
    
    setup(
      name = 'Test app',
      ext_modules = cythonize("test.pyx"),
    )
    
  5. 就这样!要导出的前面代码应该放在.pyx文件中。setup.py文件通常也是相同的。它将使用不同参数变体调用setup函数。最后,它将调用test.py文件,该文件导入我们的编译库并使用它。

  6. 为了有效地编译代码,你可以使用以下命令:

    $ python setup.py build_ext –inplace
    
    

下面的截图显示了前面命令的输出。你可以看到它不仅翻译(cythonize)了代码,而且还使用了安装的 C 编译器来编译库:

构建 Cython 模块

前面的示例显示了一个非常简单的模块。然而,通常,对于更复杂的情况,Cython 模块由两种类型的文件组成:

  • 定义文件:这些文件具有.pxd扩展名,包含需要供其他 Cython 模块使用的名称的 C 声明。

  • 实现文件:这些文件具有.pyx扩展名,包含在.pxd文件中声明的函数的实际实现。

定义文件通常包含 C 类型声明、外部 C 函数或变量声明,以及模块中定义的 C 函数的声明。它们不能包含任何 C 或 Python 函数的实现,也不能包含任何Python类的定义或任何可执行行。

另一方面,实现文件可以包含几乎任何类型的 Cython 语句。

这里是一个典型的两文件模块示例,摘自 Cython 的官方文档(docs.cython.org/src/userguide/sharing_declarations.html);它展示了如何导入 .pxd 文件:

#dishes.pxd
cdef enum otherstuff:
    sausage, eggs, lettuce

cdef struct spamdish:
    int oz_of_spam
    otherstuff filler

#restaurant.pyx:
cimport dishes
from dishes cimport spamdish

cdef void prepare(spamdish *d):
    d.oz_of_spam = 42
    d.filler = dishes.sausage

def serve():
    cdef spamdish d
    prepare(&d)
    print "%d oz spam, filler no. %d" % (d.oz_of_spam, d.filler)

默认情况下,当执行 cimport 时,它将在搜索路径中查找名为 modulename.pxd 的文件。每当定义文件更改时,导入它的每个文件都需要重新编译。幸运的是,对于我们来说,Cythin.Build.cythonize 工具将负责这一点。

调用 C 函数

就像常规的 Python 一样,Cython 允许开发者通过调用外部库编译的函数来直接与 C 进行接口交互。为了导入这些库,其过程与标准的 Python 过程类似:

from libc.stdlib cimport atoi

cimport 语句用于实现或定义文件,以便访问在其他文件中声明的名称。其语法与标准 Python 的 import 语句完全相同。

如果你还需要访问在库中定义的一些类型的定义,你需要头文件(.h 文件)。在这些情况下,使用 Cython 并不像引用文件那样简单。你还需要重新声明你将使用的类型和结构:

cdef extern from "library.h":
  int library_counter;
  char *pointerVar;

前面的示例对 Cython 执行以下操作:

  • 它让 Cython 知道如何在生成的 C 代码中放置 #include 语句,引用我们包含的库

  • 它阻止 Cython 为该块内的声明生成任何 C 代码

  • 它将块内的所有声明视为使用 cdef extern 创建的,这反过来意味着这些声明是在其他地方定义的

注意,这个语法是必需的,因为 Cython 在任何时候都不会读取头文件的内容。因此,你仍然需要重新声明其内容。作为一个警告,你实际上只需要重新声明你将使用的部分,省略掉任何你的代码不需要直接使用的部分。例如,如果你在你的头文件中声明了一个具有许多成员的大结构,你可以只重新声明你需要的成员。这会起作用,因为在编译时,C 编译器会使用具有完整版本的结构的原代码。

解决命名冲突

当导入的函数的名称与你的函数的名称相同时,会出现一个有趣的问题。

假设你有一个 myHeader.h 文件,它定义了 print_with_colors 函数,你需要将其包装在一个你也要调用 print_with_colors 的 Python 函数中;Cython 提供了一种方法让你绕过这个问题,并保持你想要的名称。

你可以将 extern C 函数声明添加到 Cython 声明文件(.pxd 文件)中,然后按照以下方式将其导入到你的 Cython 代码文件中:

#my_declaration.pxd
cdef extern "myHeader.h":
  void print_with_colors(char *)

#my_cython_code.pyx
from my_declaration cimport print_with_colors as c_print_with_colors

def print_with_colors(str):
  c_print_with_colors(str)

你也可以避免重命名函数,并使用声明文件的名称作为前缀:

#my_cython_code.pyx
cimport  my_declaration 
def print_with_colors(str):
  my_declaration.print_with_colors(str)

注意

这两种选择都是有效的,使用哪种完全取决于开发者。有关此主题的更多信息,请参阅:docs.cython.org/src/userguide/external_C_code.html

定义类型

如前所述,Cython 允许开发者定义变量的类型或函数的返回类型。在这两种情况下,用于此的关键字是 cdef。实际上,类型声明是可选的,因为 Cython 会尝试通过将其转换为 C 来优化 Python 代码。但话虽如此,在需要的地方定义静态类型肯定会很有帮助。

现在我们来看一个 Python 代码片段的非常基础的例子,以及相同的代码在其三个版本中的执行情况:纯 Python、Cython 编译无类型和最后,编译并使用类型。

代码如下:

Python Cython

|

def is_prime(num):
  for j in range(2,num):
    if (num % j) == 0:
      return False
  return True

|

def is_prime(int num):
  cdef int j;
  for j in range(2,num):
    if (num % j) == 0:
      return False
  return True

|

由于我们将 for 循环变量声明为 C 整数,Cython 会将这个循环转换为一个优化的 C for 循环,这将是对这段代码的主要改进之一。

现在,我们将设置一个主文件,该文件将导入该函数:

import sys
from <right-module-name> import is_prime

def main(argv):

  if (len(sys.argv) != 3):
    sys.exit('Usage: prime_numbers.py <lowest_bound> <upper_bound>')

  low = int(sys.argv[1])
  high = int(sys.argv[2])

  for i in range(low,high):
    if is_prime(i):
      print i,

if __name__ == "__main__":
  main(sys.argv[1:])

然后,我们将像这样执行我们的脚本:

$ time python script.py 10 10000

我们将得到以下有趣的结果:

纯 Python 版本 编译后无类型 编译后带类型
0.792 秒 0.694 秒 0.043 秒

尽管代码的非优化版本比纯 Python 版本更快,但我们只有在开始声明类型时才能看到 Cython 的真正威力。

在函数定义期间定义类型

在 Cython 中可以定义两种不同类型的函数:

  • 标准 Python 函数:这些是正常的函数,与纯 Python 代码中声明的函数完全相同。为此,你需要使用标准的 cdef 关键字,这些函数将接收 Python 对象作为参数,并返回 Python 对象。

  • C 函数:这些是标准函数的优化版本。它们可以接受 Python 对象或 C 值作为参数,也可以返回这两种类型。要定义这些函数,你需要使用特殊的 cdef 关键字。

任何类型的函数都可以在 Cython 模块内部调用。然而(这是一个非常重要的区别),如果你想在 Python 代码内部调用你的函数,你需要确保该函数被声明为标准类型,或者你需要使用特殊的 cpdef 关键字。这个关键字将为函数创建一个包装对象。因此,当函数在 Cython 内部调用时,它将使用 C 函数,而当在 Python 代码内部调用时,它将使用 Python 版本。

当处理函数的参数为 C 类型时,将自动进行(如果可能)从 Python 对象到 C 值的转换。目前这仅适用于数值类型、字符串struct 类型。如果你尝试使用任何其他类型,将导致编译时错误。

以下简单的例子说明了两种模式之间的区别:

#my_functions.pxd

#this is a pure Python function, so Cython will create a make it return and receive Python objects instead of primitive types.
cdef full_python_function (x):
    return x**2

#This function instead, is defined as both, a standard function and an optimized C function, thanks to the use of the cpdef keyword.
cpdef int c_function(int num):
    return x**2

注意

如果返回类型或参数类型未定义,则假定它是一个 Python 对象。

最后,不返回 Python 对象的 C 函数没有向其调用者报告 Python 异常的方法。因此,当发生错误时,会打印一条警告消息,并忽略异常。当然,这远远不是理想的。幸运的是,我们有一个解决办法。

我们可以在函数定义中使用except关键字。这个关键字指定了当函数内部发生异常时,将返回一个特定的值。以下是一个例子:

cdef int text(double param) except -1:

在前面的代码中,每当发生异常时,将返回-1。重要的是你不需要从你的函数中手动返回异常值。这尤其相关,如果你将False定义为你的异常值,因为这里的任何False值都适用。

对于任何可能的返回值都是有效返回值的情况,你可以使用另一种表示法:

cdef int text(double param) except? -1:

?符号将-1设置为可能的异常值。当返回时,Cython 将调用PyErr_Occurred()以确保它确实是一个错误,而不是一个正常的返回操作。

except关键字还有一个变体,确保在每次返回后调用PyErr_Occurred()

cdef int text(double param) except *:

上述表示法的唯一实际用途是对于返回void且需要传播错误的函数。这是因为在这些特殊情况下,没有值可以检查;否则,它没有真正的用例。

Cython 示例

让我们快速看一下我们之前为 PyPy 使用的相同例子。它展示了如何提高脚本的性能。代码将再次进行 5000 万次的相同计算:从math导入PIacoscossin

def great_circle(lon1,lat1,lon2,lat2):
    radius = 3956 #miles
    x = PI/180.0

    a = (90.0-lat1)*(x)
    b = (90.0-lat2)*(x)
    theta = (lon2-lon1)*(x)
    c = acos((cos(a)*cos(b)) +

                  (sin(a)*sin(b)*cos(theta)))
    return radius*c

然后,我们将通过以下脚本运行函数 5000,000 次来测试它:

from great_circle_py import great_circle

lon1, lat1, lon2, lat2 = -72.345, 34.323, -61.823, 54.826
num = 5000000

for i in range(num):
  great_circle(lon1,lat1,lon2,lat2)

再次,正如我之前提到的,如果我们使用 Linux 中的 time 命令行工具和 CPython 解释器运行这个脚本,我们会看到执行结果大约需要 4.5 秒(在我的当前系统中)。你的数字可能有所不同。

与我们在前面的章节中一样,我们将直接跳转到 Cython。我们将把之前讨论的一些改进实现到一个 Cython 模块中,我们可以从我们的测试脚本中导入它。

这是我们的第一次尝试:

#great_circle_cy_v1.pyx
from math import pi as PI, acos, cos, sin

def great_circle(double lon1,double lat1,double lon2,double lat2):
    cdef double a, b, theta, c, x, radius

    radius = 3956 #miles
    x = PI/180.0

    a = (90.0-lat1)*(x)
    b = (90.0-lat2)*(x)
    theta = (lon2-lon1)*(x)
    c = acos((cos(a)*cos(b)) +
                  (sin(a)*sin(b)*cos(theta)))
    return radius*c
#great_circle_setup_v1.py
from distutils.core import setup
from Cython.Build import cythonize

setup(
  name = 'Great Circle module v1',
  ext_modules = cythonize("great_circle_cy_v1.pyx"),
)

如前所述的代码所示,我们只是给代码中使用的所有变量和参数赋予了一个 C 类型。仅此一项就将执行时间从 4.5 秒降低到 3 秒。我们节省了 1.5 秒,但我们可能做得更好。

我们的代码仍然使用 Python 库math.。由于 Cython 允许我们混合 Python 和 C 库,当我们匆忙时它非常有用。它为我们处理转换,但正如我们所看到的,这并非没有代价。现在让我们尝试移除对该 Python 库的依赖,并调用 C 的math.h文件:

#great_circle_cy_v2.pyx
cdef extern from "math.h":
    float cosf(float theta)
    float sinf(float theta)
    float acosf(float theta)

def great_circle(double lon1,double lat1,double lon2,double lat2):
    cdef double a, b, theta, c, x, radius
    cdef double pi = 3.141592653589793

    radius = 3956 #miles
    x = pi/180.0

    a = (90.0-lat1)*(x)
    b = (90.0-lat2)*(x)
    theta = (lon2-lon1)*(x)
    c = acosf((cosf(a)*cosf(b)) +
                  (sinf(a)*sinf(b)*cosf(theta)))
    return radius*c

在移除了对 Python 数学库的所有引用并直接使用 C 的math.h文件后,我们的代码从之前优化的 3.5 秒提升到了惊人的 0.95 秒。

何时定义类型

之前的例子可能看起来很明显且易于优化。然而,对于更大的脚本,重新声明每个变量为 C 变量,并在可能的情况下导入所有 C 库而不是 Python 库( whenever possible),并不总是最佳选择。

以这种方式进行操作将导致可读性和可维护性问题。它还会损害 Python 代码的固有灵活性。实际上,它甚至可能通过添加不必要的类型检查和转换来损害性能。因此,必须有一种方法来确定添加类型和切换库的最佳位置。这种方法是使用 Cython。Cython 具有注释源代码的能力,并以非常直观的方式显示每一行代码如何被翻译成 C 代码。

使用 Cython 的-a属性,你可以生成一个 HTML 文件,该文件将以黄色突出显示你的代码。线条越黄,将该代码片段翻译成 C 所需的 C-API 交互就越多。白色线条(没有任何颜色的线条)将直接翻译成 C。让我们看看我们的原始代码在这个新工具下的渲染效果:

$ cython -a great_circle_py.py

下一张截图显示了上一条命令生成的 HTML 文件:

何时定义类型

我们可以清楚地看到,我们的大部分代码至少需要与 C-API 进行几次交互才能被翻译成 C(只有第 4 行是完全白色的)。重要的是要理解,我们的目标应该是尽可能多地使行变为白色。带有+符号的行表示可以点击,生成的 C 代码将显示如下:

何时定义类型

现在,通过查看我们的结果,我们可以看到浅黄色线条是简单的赋值(第 5、7、8 和 9 行)。它们可以通过我们最初的做法轻松修复:将这些变量声明为 C 变量,而不是让它们成为 Python 对象,这将需要我们转换代码。

通过进行转换,我们将得到类似下一张截图的内容。这张截图显示了分析great_circle_cy_v1.pyx文件的结果报告:

何时定义类型

现在好多了!现在,这些行都是完全白色的,除了第 7 行,它仍然是浅黄色。这当然是因为这一行实际上引用了 math.pi 对象。我们可以简单地通过用固定的值 PI 初始化 pi 变量来修复它。然而,我们仍然有一个大块的黄色,即第 12 和 13 行。这也是由于我们使用了 math 库。因此,在我们摆脱它之后,我们将得到以下文件:

何时定义类型

前面的截图显示了之前我们展示的最终代码。我们的大部分代码可以直接翻译为 C,并且从中获得了良好的性能。现在,我们仍然有两个黄色的行:6 和 18。

我们对第 6 行无能为力,因为那个函数是我们需要执行的 Python 函数。如果我们用 cdef 声明它,我们就无法访问它。然而,第 18 行并不完全是白色的。这是因为 great_circle 是一个 Python 函数,返回值是一个 Python 对象,需要被包装并转换为 C 值。如果我们点击它,我们可以看到生成的代码:

何时定义类型

我们唯一能够修复这个问题的方法是通过使用 cpdef 声明我们的函数,这将为其创建一个包装器。然而,它也将允许我们声明返回类型。因此,我们不再返回一个 Python 对象。相反,我们返回一个 double 值,结果代码和注释截图如下:

何时定义类型

我们可以看到,由于最新的更改,为返回语句生成的 C 代码得到了简化。性能也得到了小幅提升,因为我们从 0.95 秒降低到了 0.8 秒。

感谢我们对代码的分析,我们能够更进一步并对其进行一些优化。这种技术是检查为 Cython 优化代码进度的一个好方法。这种技术提供了一个直观且简单的指标,用于显示优化代码的复杂性。

注意

注意,在这个特定的情况下,通过 Cython 路线进行此优化获得的结果并不如本章前面使用 PyPy 获得的结果好(Cython 需要 0.8 秒,而 PyPy 需要 0.5 秒)。

局限性

到目前为止,我们所看到的一切似乎都表明 Cython 是满足我们性能需求的一个完美的选择。然而,事实是 Cython 还没有达到 100% 与 Python 语法兼容。遗憾的是,在使用这个工具来满足我们的性能增强需求之前,我们需要考虑一些限制。从项目当前公开的 bug 列表中,我们可以收集到当前的限制列表。

生成器表达式

这些表达式目前遭受的影响最大,因为它们在 Cython 当前版本中存在几个问题。这些问题如下:

  • 在生成器表达式中使用迭代器会导致问题,因为存在评估范围的问题。

  • 此外,与生成器内部的迭代器相关,Cython 似乎是在生成器的主体内部评估它们的。另一方面,CPython 则是在创建实际的生成器之前进行评估。

  • CPython 中的生成器具有允许进行内省的属性。Cython 在支持这些属性方面仍然没有完全跟上。

字符串字面量的比较

Cython 当前的实现基于指针使用的字面量进行比较,而不是字符串的实际值。

cdef char* str = "test string"
print str == b"test string"

前面的代码并不总是打印True。它将取决于存储第一个字符串的指针,而不是取决于实际的字符串值。

元组作为函数参数

尽管这只是 Python 2 的一个特性,但语言允许以下语法:

def myFunction( (a,b) ):
  return a + b
args = (1,2)
print myFunction(args)

然而,前面的代码甚至不能被 Cython 正确解析。这个特定的功能被标记为可能在 Cython 的未来版本中“无法修复”,因为 Python 3.x 也已经移除了它。

注意

注意,Cython 团队预计在发布 1.0 版本之前修复大多数之前提到的限制。

堆栈帧

目前,Cython 正在生成假的回溯作为其异常传播机制的一部分。它们没有填写localsco_code值。为了正确地做到这一点,它们必须在函数调用时生成堆栈帧,这可能会带来潜在的性能惩罚。因此,不清楚它们是否会在未来修复这个问题。

如何选择正确的选项

到目前为止,我们已经讨论了两种不同的方法来彻底优化我们的代码。然而,我们如何知道哪一个是正确的?或者更好的是,哪一个才是最好的?

对这两个问题的回答是相同的:没有单一的最佳或正确选项。哪一个选项更好或更差完全取决于一个或多个这些方面:

  • 你试图优化的实际用例

  • 开发者对 Python 或 C 的熟悉程度

  • 优化代码的可读性很重要

  • 可用于执行优化的时间量

何时选择 Cython

这里是当你应该选择 Cython 的情况:

  • 你熟悉 C 代码:你不会用 C 来编码,但你将使用与 C 共有的原则,例如静态类型,以及 C 库,如math.h。因此,熟悉该语言及其内部结构肯定会有所帮助。

  • 失去 Python 的可读性不是问题:你为 Cython 编写的代码不是完全的 Python 代码,因此其可读性的一部分将会丢失。

  • 需要完全支持 Python 语言:尽管 Cython 不是 Python,但它更像是语言的扩展而不是子集。因此,如果你需要与语言完全兼容,Cython 可能是一个正确的选择。

何时选择 PyPy

以下是你应该选择 PyPy 的情况:

  • 你不是在处理一次性执行的脚本:如果你的脚本是一个长时间运行的程序,有可以优化的循环,那么 PyPy 的 JIT 优化非常出色,但如果你的脚本是一次性运行并完成的,那么实际上 PyPy 比原始 CPython 要慢。

  • 不需要完全支持第三方库:尽管 PyPy 与 Python 2.7.x 兼容,但它与其外部库(尤其是 C 库)并不完全兼容。因此,根据你的代码,PyPy 可能并不是一个真正的选择。

  • 你需要你的代码与 CPython 兼容:如果你需要你的代码在两种实现(PyPy 和 CPython)上都能运行,那么 Cython 方案就完全不可行。PyPy 成为唯一的选择。

概述

在本章中,我们介绍了两种替代标准 Python 实现的方案。一个是 PyPy,它包含一个 Python 版本,并使用 RPython 实现。它有一个负责在执行时优化代码的即时编译器。另一个是 Cython,它基本上是将 Python 代码转换为 C 代码的编译器。我们看到了它们各自的工作原理、如何安装它们,以及我们的代码需要如何修改才能从中获益。

最后,我们讨论了如何以及何时选择其中一个而不是另一个的几个要点。

在下一章中,我们将专注于 Python 的一个非常具体的用例:数值计算。这个主题在 Python 社区中非常常见,因为这种语言经常用于科学目的。我们将介绍三个可以帮助我们更快编写代码的选项:Numba、Parakeet 和 pandas。

第七章:使用 Numba、Parakeet 和 pandas 进行闪电般的数值计算

数值计算是编程世界的一个特定主题。然而,鉴于 Python 经常被用于科学研究与数据科学问题,数值计算最终在 Python 世界中成为一个非常常见的话题。

话虽如此,我们同样可以很容易地使用前六章的信息来实现我们的算法,并且我们很可能会得到非常快速且性能良好的代码。再次强调,这些信息旨在用于通用用例。对于特定情况的优化,总会有一些讨论。

在本章中,我们将介绍三种选项,这些选项将帮助我们编写更快、更优化的代码,专注于科学问题。对于每一个,我们将介绍基本安装说明。我们还将查看一些代码示例,展示每个选项的优势。

本章我们将要回顾的工具如下:

  • Numba:这是一个模块,允许你通过生成优化的机器代码,在纯 Python 中编写高性能函数。

  • Parakeet:这是一个用于在 Python 子集编写的科学操作的运行时编译器。它非常适合表达数值计算。

  • pandas:这是一个提供高性能数据结构和分析工具的库。

Numba

Numba (numba.pydata.org/)是一个模块,允许你通过装饰器指示 Python 解释器哪些函数应该被转换为机器代码。因此,Numba 提供了与 C 或 Cython 相当的性能,而无需使用不同的解释器或实际用 C 语言编写代码。

该模块只需要求即可生成优化的机器代码。它甚至可以被编译在 CPU 或 GPU 硬件上运行。

这里是一个来自他们官方网站的非常基础的例子,展示了如何使用它。我们稍后会详细介绍:

from numba import jit
from numpy import arange

# jit decorator tells Numba to compile this function.
# The argument types will be inferred by Numba when function is called.
@jit
def sum2d(arr):
    M, N = arr.shape
    result = 0.0
    for i in range(M):
        for j in range(N):
            result += arr[i,j]
    return result

a = arange(9).reshape(3,3)
print(sum2d(a))

注意,尽管 Numba 的承诺听起来很吸引人,但这个库旨在优化数组操作。它与 NumPy(我们很快会回顾)有相当大的关联。因此,并非每个函数都可以通过它来优化,使用它甚至可能会损害性能。

例如,让我们看看一个类似的例子,一个不使用 NumPy 但完成类似任务的例子:

from numba import jit
from numpy import arange

# jit decorator tells Numba to compile this function.
# The argument types will be inferred by Numba when function is called.
@jit
def sum2d(arr):
    M, N = arr.shape
    result = 0.0
    for i in range(M):
        for j in range(N):
            result += arr[i,j]
    return result

a = arange(9).reshape(3,3)
print(sum2d(a))

上述代码的执行时间取决于我们是否保留了@jit行:

  • @jit行上:0.3 秒

  • 没有使用@jit行:0.1 秒

安装

实际上,安装 Numba 有两种方式:你可以使用 Anaconda 的conda包管理器,或者你可以直接克隆 GitHub 仓库并编译它。

如果你选择使用conda方法,你可以安装一个名为miniconda的命令行工具(可以从conda.pydata.org/miniconda.html下载)。安装后,你只需使用以下命令即可:

$ conda install numba

以下截图显示了此命令的输出。该命令列出了将要安装或更新的所有包,特别是 numpyllvmlite,它们是 Numba 的直接依赖项:

安装

如果你想使用源代码,你可以使用以下命令克隆仓库:

$ git clone git://github.com/numba/numba.git

你还需要安装 numpyllvmlite。之后,你可以使用以下命令:

$ python setup.py build_ext –inplace

注意

注意,即使你没有安装这些要求,前面的命令也会成功。然而,除非你安装了它们,否则你将无法使用 Numba。

为了检查你的安装是否成功,你可以在 Python REPL 中进行简单的检查:

>>> import numba
>>> numba.__version__
'0.18.2'

使用 Numba

现在你已经成功安装了 Numba,让我们看看我们可以用它做什么。此模块提供的主要功能如下:

  • 即时代码生成

  • 针对 CPU 和 GPU 硬件的原生代码生成

  • 由于 Numpy 依赖项,与 Python 的科学软件集成

Numba 的代码生成

当涉及到代码生成时,Numba 的主要功能是其 @jit 装饰器。使用它,你可以标记一个函数在 Numba 的 JIT 编译器下进行优化。

我们已经在上一章中讨论了拥有 JIT 编译器的优势,所以这里不再详细说明。相反,让我们看看如何使用装饰器来为我们带来好处。

有几种方法可以使用这个装饰器。默认的方法,也是推荐的方法,就是我们之前展示的方法:

Lazy compilation

以下代码将在函数被调用时让 Numba 生成优化代码。它将尝试推断其属性的类型和函数的返回类型:

from numba import jit

@jit
def sum2(a,b):
  return a + b

如果你用不同的类型调用相同的函数,那么将生成并优化不同的代码路径。

贪婪编译

另一方面,如果你知道你的函数将接收(可选地,返回)的类型,你可以将这些传递给 @jit 装饰器。然后,只有那个特定的案例会被优化。

以下代码显示了传递函数签名所需的附加代码:

from numba import jit, int32

@jit(int32(int32, int32))
def sum2(a,b):
  return a + b

这里列出了用于指定函数签名最常见的类型:

  • void:这些用于不返回任何内容的函数的返回类型

  • intpuintp:这些是指针大小的整数,分别是带符号和无符号的

  • intcuintc:这些是 C 语言中 int 和 unsigned int 类型的等价类型

  • int8int16int32int64:这些是相应位宽的固定宽度整数(对于无符号版本,只需在前面添加 u 作为前缀,例如,uint8

  • float32float64:这些是单精度和双精度浮点数

  • complex64complex128:这些表示单精度和双精度复数

  • 数组也可以通过索引任何数值类型来声明,例如,float32[:]用于一维浮点数数组,int32[:,:]用于二维整数数组

其他配置设置

除了急切编译之外,我们还可以将两个选项传递给@jit装饰器。这些选项将帮助我们强制 Numba 的优化。它们在这里进行了描述。

无 GIL

当我们的代码使用原生类型(而不是使用 Python 类型)进行优化时,GIL(我们在第六章中讨论过,通用优化选项)就不再必要了。

我们有一种方法可以禁用 GIL。我们可以将nogil=True属性传递给装饰器。这样,我们可以在其他线程中同时运行 Python 代码(或 Numba 代码)。

话虽如此,请记住,如果您没有 GIL 限制,那么您将不得不处理多线程系统的常见问题(一致性、同步、竞态条件等)。

无 Python 模式

此选项将允许我们设置 Numba 的编译模式。默认情况下,它将尝试在模式之间跳跃。它将根据优化函数的代码尝试决定最佳模式。

有两种模式可供选择。一方面,有object模式。它生成能够处理所有 Python 对象的代码,并使用 C API 对这些对象进行操作。另一方面,nopython模式通过避免调用 C API 来生成更快的代码。它的唯一问题是,只有一部分函数和方法可供使用。

除非 Numba 可以利用循环即时编译(这意味着可以提取并编译为nopython模式的循环),否则object模式不会生成更快的代码。

我们可以强制 Numba 进入nopython模式,并在这种事情不可能的情况下引发错误。这可以通过以下代码行来完成:

@jit(nopython=True)
def add2(a, b):
  return a + b

nopython模式的问题在于它有一些限制,除了支持的 Python 有限子集之外:

  • 函数内部使用的所有值的原生类型必须能够被推断

  • 函数内部不能分配新的内存

作为额外的补充,为了进行循环即时编译,待优化的循环不能包含返回语句。否则,它们将不符合优化条件。

那么,现在让我们看看我们的代码将如何呈现这个例子:

def sum(x, y):
    array = np.arange(x * y).reshape(x, y)
    sum = 0
    for i in range(x):
        for j in range(y):
            sum += array[i, j]
    return sum

以下示例取自 Numba 网站。它显示了一个符合循环即时编译条件的函数,也称为循环提升。为了确保它按预期工作,我们可以使用 Python REPL 如下:

无 Python 模式

或者,我们也可以直接从我们的代码中调用inspect_types方法。后者的好处是,我们还将有权访问我们函数的源代码。当尝试将 Numba 生成的指令与代码行匹配时,这是一个巨大的优势。

前面的输出有助于理解我们在使用 Numba 优化代码时幕后发生的动作。更具体地说,我们可以理解它是如何推断类型的,是否有任何自动优化正在进行,以及基本上每行 Python 代码被转换成多少条指令。

让我们看看从我们代码内部调用 inspect_types 方法会得到什么输出(这比使用 REPL 得到的输出详细得多):

注意

注意以下代码是整个输出的简化版本。如果你想完全研究它,你需要在自己的计算机上运行该命令。

sum_auto_jitting (int64, int64)
--------------------------------------------------------------------------------
# File: auto-jitting.py
# --- LINE 6 --- 

@jit

# --- LINE 7 --- 

def sum_auto_jitting(x, y):

    # --- LINE 8 --- 
    # label 0
    #   x = arg(0, name=x)  :: pyobject
    #   y = arg(1, name=y)  :: pyobject
    #   $0.1 = global(np: <module 'numpy' from '/home/fernando/miniconda/lib/python2.7/site-packages/numpy/__init__.pyc'>)  :: pyobject
    #   $0.2 = getattr(attr=arange, value=$0.1)  :: pyobject
    #   del $0.1
    #   $0.5 = x * y  :: pyobject
    #   $0.6 = call $0.2($0.5, )  :: pyobject
    #   del $0.5
    #   del $0.2
    #   $0.7 = getattr(attr=reshape, value=$0.6)  :: pyobject
    #   del $0.6
    #   $0.10 = call $0.7(x, y, )  :: pyobject
    #   del $0.7
    #   array = $0.10  :: pyobject
    #   del $0.10

    array = np.arange(x * y).reshape(x, y)

    # --- LINE 9 --- 
    #   $const0.11 = const(int, 0)  :: pyobject
    #   sum = $const0.11  :: pyobject
    #   del $const0.11

    sum = 0

    # --- LINE 10 --- 
    #   jump 40.1
    # label 40.1
    #   $const40.1.1 = const(LiftedLoop, LiftedLoop(<function sum_auto_jitting at 0x7ff5f94756e0>))  :: XXX Lifted Loop XXX
    #   $40.1.6 = call $const40.1.1(y, x, sum, array, )  :: XXX Lifted Loop XXX
    #   del y
...

    #   jump 103
    for i in range(x):
        # --- LINE 11 --- 
        for j in range(y):
            # --- LINE 12 --- 
            sum += array[i, j]
    # --- LINE 13 --- 
    # label 103
    #   $103.2 = cast(value=sum.1)  :: pyobject
    #   del sum.1
    #   return $103.2
    return sum
# The function contains lifted loops
# Loop at line 10
# Has 1 overloads
# File: auto-jitting.py
# --- LINE 6 --- 

@jit
# --- LINE 7 --- 
def sum_auto_jitting(x, y):
    # --- LINE 8 --- 
    array = np.arange(x * y).reshape(x, y)
    # --- LINE 9 --- 
    sum = 0
    # --- LINE 10 --- 
    # label 37
    #   y = arg(0, name=y)  :: int64
    #   x = arg(1, name=x)  :: int64
    #   sum = arg(2, name=sum)  :: int64
    #   array = arg(3, name=array)  :: array(int64, 2d, C)
    #   $37.1 = global(range: <built-in function range>)  :: range
    #   $37.3 = call $37.1(x, )  :: (int64,) -> range_state64
    #   del x
    #   del $37.1
    #   $37.4 = getiter(value=$37.3)  :: range_iter64
    #   del $37.3
    #   $phi50.1 = $37.4  :: range_iter64
    #   del $37.4
    #   jump 50
    # label 50
    #   $50.2 = iternext(value=$phi50.1)  :: pair<int64, bool>
    #   $50.3 = pair_first(value=$50.2)  :: int64
    #   $50.4 = pair_second(value=$50.2)  :: bool
    #   del $50.2
    #   $phi53.1 = $50.3  :: int64
    #   del $50.3
    #   branch $50.4, 53, 102
    # label 53
    #   i = $phi53.1  :: int64
    #   del $phi53.1

    for i in range(x):

        # --- LINE 11 --- 
        #   jump 56
        # label 56

...
        #   j = $phi72.1  :: int64
        #   del $phi72.1

        for j in range(y):

            # --- LINE 12 --- 
            #   $72.6 = build_tuple(items=[Var(i, auto-jitting.py (10)), Var(j, auto-jitting.py (11))])  :: (int64 x 2)
            #   del j
            #   $72.7 = getitem(index=$72.6, value=array)  :: int64

...
            #   return $103.3

            sum += array[i, j]

    # --- LINE 13 --- 

    return sum

为了理解前面的输出,请注意每个注释块都是以原始源代码的行号开始的。然后是那条指令生成的指令,最后你会看到你编写的未注释的 Python 代码行。

注意 LiftedLoop 行。在这行中,你可以看到 Numba 自动进行的优化。同时,注意 Numba 在大多数行末推断的类型。每当看到 pyobject 属性时,这意味着它没有使用原生类型。相反,它使用一个通用的对象,该对象封装了所有 Python 类型。

在 GPU 上运行你的代码

如前所述,Numba 支持在 CPU 和 GPU 硬件上运行我们的代码。在实践中,这将允许我们通过在更适合并行计算的 CPU 环境中运行某些计算来提高这些计算的性能。

更具体地说,Numba 通过将 Python 函数的子集转换为遵循 CUDA 执行模型的 CUDA 内核和设备,支持 CUDA 编程(www.nvidia.com/object/cuda_home_new.html)。

CUDA 是由 Nvidia 发明的并行计算平台和编程模型。它通过利用 GPU 的能力来实现显著的加速。

GPU 编程是一个可能填满整本书的主题,所以我们在这里不会深入细节。相反,我们只是提到 Numba 具有这种能力,并且可以使用 @cuda.jit 装饰器来实现。关于这个主题的完整文档,请参阅官方文档numba.pydata.org/numba-doc/0.18.2/cuda/index.html

pandas 工具

本章我们将讨论的第二款工具被称为 pandas(pandas.pydata.org/)。它是一个开源库,为 Python 提供高性能、易于使用的数据结构和数据分析工具。

这个工具是在 2008 年由开发者 Wes McKinney 发明的,当时他需要一个高性能的解决方案来对金融数据进行定量分析。这个库已经成为 Python 社区中最受欢迎和最活跃的项目之一。

关于使用 pandas 编写的代码的性能,有一点需要注意,那就是其关键代码路径的部分是用 Cython 编写的(我们已经在第六章通用优化选项中介绍了 Cython)。

安装 pandas

由于 pandas 的流行,有许多方法可以将它安装到您的系统上。这完全取决于您的设置类型。

推荐的方式是直接安装 Anaconda Python 发行版(docs.continuum.io/anaconda/),它包含了 pandas 和 SciPy 堆栈的其余部分(如 NumPy、Matplotlib 等)。这样,完成时,您将安装超过 100 个软件包,并在过程中下载了几个 100 兆字节数据。

如果您不想处理完整的 Anaconda 发行版,可以使用 miniconda(我们在讨论 Numba 的安装时已经介绍过它)。采用这种方法,您可以通过以下步骤使用 conda 包管理器:

  1. 使用以下代码行创建一个新的环境,您可以在其中安装 Python 的新版本:

    $ conda create -n my_new_environment python 
    
    
  2. 启用该环境:

    $ source activate my_new_environment
    
    
  3. 最后,安装 pandas:

    $ conda install  pandas
    
    

此外,可以使用 pip 命令行工具(可能是最简单、最兼容的方式)通过以下代码行安装 pandas:

$ pip install pandas

最后,还有一个选项是使用您的操作系统包管理器安装它,前提是该软件包可用:

发行版 仓库链接 安装方法
Debian packages.debian.org/search?keywords=pandas&searchon=names&suite=all&section=all
$ sudo apt-get install python-pandas

|

Ubuntu packages.ubuntu.com/search?keywords=pandas&searchon=names&suite=all&section=all
$ sudo apt-get install python-pandas

|

OpenSUSE 和 Fedora software.opensuse.org/package/python-pandas?search_term=pandas
$ zypper in python-pandas

|

如果前面的选项失败,并且您选择从源安装 pandas,您可以从他们的网站pandas.pydata.org/pandas-docs/stable/install.html获取说明。

使用 pandas 进行数据分析

在大数据和数据分析的世界里,拥有适合工作的正确工具意味着掌握主动权(当然,这只是故事的一方面;另一方面是知道如何使用它们)。对于数据分析,尤其是对于临时任务和数据清理过程,人们通常会使用一种编程语言。编程语言将提供比标准工具大得多的灵活性。

话虽如此,有两种语言在这次特定的性能竞赛中领先:R 和 Python。对于 Python 来说,这可能会让一些人感到有些惊讶,因为我们一直展示的证据表明,Python 本身在数值计算方面并不足够快。这就是为什么创建了像 pandas 这样的库。

它提供了旨在简化通常被称为“数据处理”任务的工具,例如:

  • 能够将大数据文件加载到内存中并流式传输

  • 简单地与matplotlibmatplotlib.org/)集成,这使得它能够用很少的代码创建交互式图表

  • 简单的语法来处理缺失数据、删除字段等

现在我们来看一个非常简单且快速的例子,说明使用 pandas 如何提高代码的性能以及改进程序的语法。以下代码抓取了一个 CSV 文件,其中包含从纽约市开放数据网站(data.cityofnewyork.us/Social-Services/311-Service-Requests-from-2010-to-Present/erm2-nwe9)获取的 2010 年至现在的311 服务请求的一部分(一个 500MB 的文件)。

然后它尝试简单地使用纯 Python 和 pandas 代码计算每个邮编的记录数:

import pandas as pd 
import time
import csv
import collections

SOURCE_FILE = './311.csv'

def readCSV(fname):
  with open(fname, 'rb') as csvfile:
    reader = csv.DictReader(csvfile)
    lines = [line for line in reader]
    return lines

def process(fname):
  content = readCSV(fname)
  incidents_by_zipcode = collections.defaultdict(int)
  for record in content:
    incidents_by_zipcode[toFloat(record['Incident Zip'])] += 1
  return sorted(incidents_by_zipcode.items(), reverse=True, key=lambda a: int(a[1]))[:10]

def toFloat(number):
  try:
    return int(float(number))
  except:
    return 0

def process_pandas(fname):
  df = pd.read_csv(fname, dtype={'Incident Zip': str, 'Landmark': str, 'Vehicle Type': str, 'Ferry Direction': str})

  df['Incident Zip'] = df['Incident Zip'].apply(toFloat)
  column_names =  list(df.columns.values)
  column_names.remove("Incident Zip")
  column_names.remove("Unique Key")
  return df.drop(column_names, axis=1).groupby(['Incident Zip'], sort=False).count().sort('Unique Key', ascending=False).head(10)

init = time.clock()
total = process(SOURCE_FILE)
endtime = time.clock() - init
for item in total:
  print "%s\t%s" % (item[0], item[1])

print "(Pure Python) time: %s" % (endtime)

init = time.clock()
total = process_pandas(SOURCE_FILE)
endtime = time.clock() - init
print total
print "(Pandas) time: %s" % (endtime)

process 函数非常简单。它只有五行代码。它加载文件,进行一些处理(主要是手动分组和计数),最后,它对结果进行排序并返回前 10 个。作为额外的奖励,我们使用了defaultdict数据类型,这是我们之前提到的一种可能的性能改进方法。

在另一方面,process_pandas函数基本上做了同样的事情,只是使用了 pandas。我们有一些更多的代码行,但它们很容易理解。它们显然是“数据处理导向”的,正如您所看到的,没有声明循环。我们甚至可以自动通过名称访问列,并在这些记录组上应用函数,而无需手动遍历它们。

以下截图显示了前面代码的输出:

使用 pandas 进行数据分析

如您所见,当我们简单地使用 pandas 重新实现算法时,我们的算法性能提高了 3 秒。现在让我们更深入地了解 pandas 的 API,以便获得更好的数据。我们可以对我们的代码进行两项主要改进,它们都与read_csv方法有关,该方法使用了许多参数。其中两个参数对我们来说非常有兴趣:

  • usecols:这将只返回我们想要的列,有效地帮助我们处理数据集中 40 多列中的仅 2 列。这也有助于我们摆脱在返回结果前必须删除列的逻辑。

  • converters:这允许我们使用一个函数自动转换数据,而不是像我们现在这样做调用 apply 方法。

我们的新函数看起来是这样的:

def process_pandas(fname):
  df = pd.read_csv(fname, usecols=['Incident Zip', 'Unique Key'], converters={'Incident Zip': toFloat}, dtype={'Incident Zip': str})
  return df.groupby(['Incident Zip'], sort=False).count().sort('Unique Key', ascending=False).head(10)

没错。只有两行代码!读者将为我们完成所有工作。然后,我们只需要简单地分组、计数和排序。现在,来看看这与我们之前的结果相比如何:

使用 pandas 进行数据分析

这是在我们的算法性能上提高了 10 秒,并且处理代码量大大减少,这通常被称为“双赢”的情况。

我们代码的一个额外好处是它具有可扩展性。基于 pandas 的函数只需 30 秒就能处理 5.9 GB 的文件,而且无需任何更改。另一方面,我们的纯 Python 代码甚至无法在这么短的时间内加载该文件,更不用说在没有足够资源的情况下处理它了。

Parakeet

这是迄今为止处理 Python 中数字的最具体工具。它非常具体,因为它只支持 Python 和 NumPy 结果组合的非常狭窄的子集。因此,如果你处理的是该宇宙之外的内容,这可能不是你的选择,但如果你可以将你的解决方案放入其中,那么请继续阅读。

要更具体地说明 Parakeet 支持的有限宇宙(通常仅用于表示数值计算,通常情况下并不常用),以下是一个简短的列表:

  • Python 支持的类型有数字、元组、切片和 NumPy 的数组

  • Parakeet 遵循向上提升规则,即当两个不同类型的值试图达到同一个变量时,它们将被提升为统一的类型。例如,Python 表达式 1.0 if b else false 将转换为 1.0 if b else 0.0,但当自动转换不可行时,例如 1.0 if b else (1,2),则在编译时将引发不可捕获的异常(见下一点)。

  • 在 Parakeet 中无法捕获或甚至引发异常;也无法使用 break 和 continue 语句。这是因为 Parakeet 使用结构化 SSA (citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.45.4503) 来表示程序。

  • 数组广播(NumPy 的一个特性)通过根据数组参数的类型插入显式的映射操作符部分实现。这是一个有限的实现,因为它实际上无法处理维度的扩展(例如广播 8 x 2 x 3 和 7 x 2 数组)。

  • 只有 Python 和 NumPy 内置函数的一小部分被实现。完整的列表可以在github.com/iskandr/parakeet/blob/master/parakeet/mappings.py中查看。

  • 列表推导表达式被视为数组推导表达式。

安装 Parakeet

Parakeet 的安装很简单。如果你选择使用 pip 路径,没有难以满足的要求。只需输入以下命令:

$ pip install parakeet

完成了!

如果你想要直接尝试源代码方法,你需要在之前安装一些其他包。以下是这些包的列表:

注意

如果你使用的是 Windows 系统,如果是 32 位机器,你可能会有更好的运气。否则,你可能运气不佳,因为关于这个主题没有官方文档。

如果你使用的是 OS X 系统,你可能需要使用 HomeBrew 安装更新版本的 C 编译器,因为无论是 clang 还是已安装的gcc版本可能更新不足。

满足先决条件后,只需从以下链接下载代码:github.com/iskandr/parakeet,并在代码文件夹内运行以下命令:

$ python setup.py install

Parakeet 是如何工作的?

我们不深入探讨 Parakeet 背后理论的细节,而是简单看看如何使用它来优化我们的代码。这将帮助你了解这个模块,而无需阅读所有文档。

这个库的主要结构是一个装饰器,你可以将其应用于你的函数,这样 Parakeet 就可以接管并尽可能优化你的代码。

对于我们的简单测试,让我们从 Parakeet 网站上提供的示例函数中选取一个,并对一个4000 * 4000的随机浮点数列表进行简单测试。代码将以优化和非优化两种方式运行相同的函数,然后测量每种方式处理相同输入所需的时间:

from parakeet import jit
import random
import numpy as np
import time

@jit 
def allpairs_dist_prkt(X,Y):
  def dist(x,y):
    return np.sum( (x-y)**2 )
  return np.array([[dist(x,y) for y in Y] for x in X])

def allpairs_dist_py(X,Y):
  def dist(x,y):
    return np.sum( (x-y)**2 )
  return np.array([[dist(x,y) for y in Y] for x in X])

input_a =  [ random.random()  for x in range(0, 4000)] 
input_b =  [ random.random()  for x in range(0, 4000)] 

print "----------------------------------------------"
init = time.clock()
allpairs_dist_py(input_a, input_b)
end = time.clock()
print "Total time pure python: %s" % (end - init)
print 
init = time.clock()
allpairs_dist_prkt(input_a, input_b)
end = time.clock()
print "Total time parakeet: %s" % (end – init)
print "----------------------------------------------"

在 i7 处理器和 8GB RAM 的情况下,我们得到了以下性能:

Parakeet 是如何工作的?

上述截图显示了在这个特定函数(符合 Parakeet 支持的 Python 所需子集)中我们获得的惊人的性能提升。

简而言之,被装饰的函数被用作模板,从中创建了多个类型特定的函数,每个输入类型一个(在我们的例子中,我们只需要一个)。这些新函数在转换为本地代码之前,会通过 Parakeet 以多种不同的方式优化。

注意

注意,尽管性能提升惊人,但 Parakeet 只支持非常有限的 Python 版本,因此它并不是真正意义上的通用优化器(实际上正好相反)。

摘要

在本章中,我们介绍了使用 Python 进行数据处理的三种替代方案。我们涵盖了具体的用例(但具有惊人的好处),例如 Parakeet,以及其他更通用的方案,如 pandas 和 Numba。对于这三个方案,我们都介绍了基础知识:描述、安装和示例。对于每一个方案,都有更多可以探索的内容,这取决于你的具体需求。然而,这里提供的信息应该足以让你开始正确的方向。

在下一章和最后一章中,我们将讨论一个需要优化的脚本的实际例子。我们将尝试应用本书中迄今为止所涵盖的所有内容(或尽可能多的内容)。

第八章. 知识应用

欢迎来到本书的最后一章。如果你已经走到这一步,你已经了解了几个优化技术,这些技术既适用于 Python 编程语言,也适用于其他类似技术。

你还阅读了关于配置文件和可视化这些结果的工具。我们还深入探讨了 Python 的一个特定用例,即用于科学目的的数值计算。你了解了允许你优化代码性能的工具。

在本章的最后,我们将讨论一个实际用例,该用例涵盖了我们在前面章节中介绍的所有技术(记住,我们看到的某些工具是替代品,所以同时使用所有这些工具并不是一个好的计划)。我们将编写代码的初始版本,测量其性能,然后进行优化过程,最终重写代码并再次测量性能。

要解决的问题

在我们甚至开始考虑编写代码的初始版本之前,我们需要理解我们试图解决的问题。

考虑到本书的范围,一个完整的应用程序可能是一项过于庞大的任务,因此我们将专注于一个小任务。这将使我们更好地控制我们想要做的事情,并且我们不会面临同时优化太多事物的风险。

为了保持趣味性,我们将问题分为以下两个部分:

  • 第一部分:这将负责找到我们想要处理的数据。这不仅仅是从某个给定的 URL 下载的数据集。相反,我们将从网络中抓取它。

  • 第二部分:这将专注于处理解决问题的第一部分后获得的数据。在这一步中,我们可能需要进行最耗 CPU 的计算,并从收集到的数据中计算一些统计数据。

在这两种情况下,我们将创建一个不考虑性能的代码初始版本来解决问题。之后,我们将单独分析每个解决方案,并尽可能改进它们。

从网络获取数据

我们将要抓取的网站是科幻与奇幻(scifi.stackexchange.com/)。该网站致力于回答关于科幻和奇幻主题的问题。它类似于 StackOverflow,但专为科幻和奇幻爱好者设计。

更具体地说,我们希望抓取最新问题的列表。对于每个问题,我们将获取包含问题文本和所有可用答案的页面。在所有抓取和解析完成后,我们将以 JSON 格式保存相关信息,以便于后续处理。

记住,我们将处理 HTML 页面。然而,我们不想这样做。我们希望移除所有 HTML 代码,只保存以下项目:

  • 问题的标题

  • 问题的作者

  • 问题的正文(问题的实际文本)

  • 答案的正文(如果有)

  • 答案的作者

使用这些信息,我们将能够进行一些有趣的后处理并获取一些相关的统计数据(稍后详细介绍)。

下面是一个此脚本输出应该看起来怎样的快速示例:

{
  "questions": [
    {
      "title": "Ending of John Carpenter's The Thing",
      "body": "In the ending of John Carpenter's classic 1982 sci-fi horror film The Thing, is ...",
      "author": "JMFB",
      "answers": [
        {
          "body": "This is the million dollar question, ... Unfortunately, he is notoriously ... ",
           "author": "Richard",
        },
        {
          "body": "Not to point out what may seem obvious, but Childs isn't breathing. Note the total absence of ",
          "author": "user42"
          }
      ]
    },
    {
      "title": "Was it ever revealed what pedaling the bicycles in the second episode was doing?",
      "body": "I'm going to assume they were probably some sort of turbine...electricity...something, but I'd prefer to know for sure.",
       "author": "bartz",
      "answers": [
        {
          "body": "The Wikipedia synopsis states: most citizens make a living pedaling exercise bikes all day in order to generate power for their environment",
          "author": "Jack Nimble"
        }
      ]
    }
  ]
}

此脚本将负责将所有信息保存到一个单独的 JSON 文件中,该文件将在其代码中预先定义。

我们将尝试保持两个脚本的初始版本简单。这意味着使用最少的模块。在这种情况下,主要的模块列表如下:

  • Beautiful Soup (www.crummy.com/software/BeautifulSoup/):这个库用于解析 HTML 文件,主要是因为它提供了一个完整的解析 API,自动编码检测(如果你在这个行业工作的时间足够长,你可能已经讨厌这种自动编码检测了)以及使用选择器遍历解析树的能力。

  • Requests (docs.python-requests.org/en/latest/):这个库用于发起 HTTP 请求。尽管 Python 已经提供了完成此任务所需的模块,但此模块简化了 API,并提供了一种更 Pythonic 的方式来处理这个任务。

您可以使用pip命令行工具安装这两个模块:

$ pip  install requests  beautifulsoup4

下面的截图显示了我们将抓取和解析以获取数据的页面示例:

从网络获取数据

数据后处理

第二个脚本将负责读取 JSON 编码的文件并从中获取一些统计数据。由于我们希望使其变得有趣,我们不会仅仅限制于统计每个用户的问题数量(尽管我们也会获取这个统计数据)。我们还将计算以下元素:

  • 提问最多的前十位用户

  • 回答最多的前十位用户

  • 最常见的问题主题

  • 最短的回答

  • 最常见的十个短语

  • 回答最多的前十道问题

由于本书的主要主题是性能而不是自然语言处理NLP),我们不会深入探讨此脚本将涉及到的少量 NLP 细节。相反,我们将仅限于根据我们迄今为止对 Python 的了解来提高性能。

在此脚本的第一版本中,我们将使用的唯一非内置模块是NLTK (www.nltk.org),用于处理所有的 NLP 功能。

初始代码库

现在我们来列出所有将在未来优化的代码,基于之前的描述。

下面的第一个点相当简单:一个单文件脚本,负责抓取和以我们之前讨论的 JSON 格式保存。流程简单,顺序如下:

  1. 它将逐页查询问题列表。

  2. 对于每一页,它将收集问题的链接。

  3. 然后,对于每个链接,它将收集之前列出的信息。

  4. 它将转到下一页并重新开始。

  5. 它最终将所有数据保存到一个 JSON 文件中。

以下代码如下:

from bs4 import BeautifulSoup
import requests
import json

SO_URL = "http://scifi.stackexchange.com"
QUESTION_LIST_URL = SO_URL + "/questions"
MAX_PAGE_COUNT = 20

global_results = []
initial_page = 1 #first page is page 1

def get_author_name(body):
  link_name = body.select(".user-details a")
  if len(link_name) == 0:
    text_name = body.select(".user-details")
    return text_name[0].text if len(text_name) > 0 else 'N/A'
  else:
    return link_name[0].text

def get_question_answers(body):
  answers = body.select(".answer")
  a_data = []
  if len(answers) == 0:
    return a_data

  for a in answers:
    data = {
      'body': a.select(".post-text")[0].get_text(),
      'author': get_author_name(a)
    }
    a_data.append(data)
  return a_data

def get_question_data ( url ):
  print "Getting data from question page: %s " % (url)
  resp = requests.get(url)
  if resp.status_code != 200:
    print "Error while trying to scrape url: %s" % (url)
    return
  body_soup = BeautifulSoup(resp.text)
  #define the output dict that will be turned into a JSON structue
  q_data = {
    'title': body_soup.select('#question-header .question-hyperlink')[0].text,
    'body': body_soup.select('#question .post-text')[0].get_text(),
    'author': get_author_name(body_soup.select(".post-signature.owner")[0]),
    'answers': get_question_answers(body_soup)
  }
  return q_data

def get_questions_page ( page_num, partial_results ):
  print "====================================================="
  print " Getting list of questions for page %s" % (page_num)
  print "====================================================="

  url = QUESTION_LIST_URL + "?sort=newest&page=" + str(page_num)
  resp = requests.get(url)
  if resp.status_code != 200:
    print "Error while trying to scrape url: %s" % (url)
    return
  body = resp.text
  main_soup = BeautifulSoup(body)

  #get the urls for each question
  questions = main_soup.select('.question-summary .question-hyperlink')
  urls = [ SO_URL + x['href'] for x in questions]
  for url in urls:
    q_data = get_question_data(url)
    partial_results.append(q_data)
  if page_num < MAX_PAGE_COUNT:
    get_questions_page(page_num + 1, partial_results)

get_questions_page(initial_page, global_results)
with open('scrapping-results.json', 'w') as outfile:
  json.dump(global_results, outfile, indent=4)

print '----------------------------------------------------'
print 'Results saved'

通过查看前面的代码,你会注意到我们遵守了承诺。目前,我们只使用了建议的外部模块,以及内置在 Python 中的 JSON 模块。

另一方面,第二个脚本被分成两部分,主要是为了组织目的:

  • analyzer.py:此文件包含主要代码。它负责将 JSON 文件加载到 dict 结构中,并执行一系列计算。

  • visualizer.py:此文件仅包含用于可视化分析器不同结果的函数集。

现在,让我们看看这两个文件中的代码。第一组函数将是用于清理数据、将其加载到内存中等的功能:

#analyzer.py
import operator
import string
import nltk
from nltk.util import ngrams
import json
import re
import visualizer

SOURCE_FILE = './scrapping-results.json'

# Load the json file and return the resulting dict
def load_json_data(file):
  with open(file) as input_file:
    return json.load(input_file)

def analyze_data(d):
  return {
    'shortest_answer': get_shortest_answer(d),
    'most_active_users': get_most_active_users(d, 10),
    'most_active_topics': get_most_active_topics(d, 10),
    'most_helpful_user': get_most_helpful_user(d, 10),
    'most_answered_questions': get_most_answered_questions(d, 10),
    'most_common_phrases':  get_most_common_phrases(d, 10, 4),
  }

# Creates a single, lower cased string from the bodies of all questions
def flatten_questions_body(data):
  body = []
  for q in data:
    body.append(q['body'])
  return '. '.join(body)

# Creates a single, lower cased string from the titles of all questions
def flatten_questions_titles(data):
  body = []
  pattern = re.compile('(\[|\])')
  for q in data:
    lowered = string.lower(q['title'])
    filtered = re.sub(pattern, ' ', lowered)
    body.append(filtered)
  return '. '.join(body)

以下一系列函数是实际执行 计数 数据并通过对 JSON 进行不同方式的分析来获取我们想要的统计信息的函数:

# Returns the top "limit" users with the most questions asked
def get_most_active_users(data, limit):
  names = {}
  for q in data:
    if q['author'] not in names:
      names[q['author']] = 1
    else:
      names[q['author']] += 1
  return sorted(names.items(), reverse=True, key=operator.itemgetter(1))[:limit]

def get_node_content(node):
  return ' '.join([x[0] for x in node])

# Tries to extract the most common topics from the question's titles
def get_most_active_topics(data, limit):
  body = flatten_questions_titles(data)
  sentences = nltk.sent_tokenize(body)
  sentences = [nltk.word_tokenize(sent) for sent in sentences]
  sentences = [nltk.pos_tag(sent) for sent in sentences]
  grammar = "NP: {<JJ>?<NN.*>}"
  cp = nltk.RegexpParser(grammar)
  results = {}
  for sent in sentences:
    parsed = cp.parse(sent)
    trees = parsed.subtrees(filter=lambda x: x.label() == 'NP')
    for t in trees:
      key = get_node_content(t)
      if key in results:
        results[key] += 1
      else:
        results[key] = 1
  return sorted(results.items(), reverse=True, key=operator.itemgetter(1))[:limit]

# Returns the user that has the most answers
def get_most_helpful_user(data, limit):
  helpful_users = {}
  for q in data:
    for a in q['answers']:
      if a['author'] not in helpful_users:
        helpful_users[a['author']] = 1
      else:
        helpful_users[a['author']] += 1

  return sorted(helpful_users.items(), reverse=True, key=operator.itemgetter(1))[:limit]

# returns the top "limit" questions with the most amount of answers
def get_most_answered_questions(d, limit):
  questions = {}

  for q in d:
    questions[q['title']] = len(q['answers'])
  return sorted(questions.items(), reverse=True, key=operator.itemgetter(1))[:limit]

# Finds a list of the most common phrases of 'length' length
def get_most_common_phrases(d, limit, length):
  body = flatten_questions_body(d)
  phrases = {}
  for sentence in nltk.sent_tokenize(body):
    words = nltk.word_tokenize(sentence)
    for phrase in ngrams(words, length):
      if all(word not in string.punctuation for word in phrase):
        key = ' '.join(phrase)
        if key in phrases:
          phrases[key] += 1
        else:
         phrases[key] = 1

  return sorted(phrases.items(), reverse=True, key=operator.itemgetter(1))[:limit]

# Finds the answer with the least amount of characters
def get_shortest_answer(d):

  shortest_answer = {
    'body': '',
    'length': -1
  }
  for q in d:
    for a in q['answers']:
      if len(a['body']) < shortest_answer['length'] or shortest_answer['length'] == -1:
        shortest_answer = {
          'question': q['body'],
          'body': a['body'],
          'length': len(a['body'])
        }
  return shortest_answer

以下代码显示了如何使用之前声明的函数并显示其结果。这一切都归结为三个步骤:

  1. 它将 JSON 数据加载到内存中。

  2. 它处理数据并将结果保存到字典中。

  3. 它遍历该字典以显示结果。

以下代码执行了前面的步骤:

data_dict = load_json_data(SOURCE_FILE)

results = analyze_data(data_dict)

print "=== ( Shortest Answer ) === "
visualizer.displayShortestAnswer(results['shortest_answer'])

print "=== ( Most Active Users ) === "
visualizer.displayMostActiveUsers(results['most_active_users'])

print "=== ( Most Active Topics ) === "
visualizer.displayMostActiveTopics(results['most_active_topics'])

print "=== ( Most Helpful Users ) === "
visualizer.displayMostHelpfulUser(results['most_helpful_user'])

print "=== ( Most Answered Questions ) === "
visualizer.displayMostAnsweredQuestions(results['most_answered_questions'])

print "=== ( Most Common Phrases ) === "
visualizer.displayMostCommonPhrases(results['most_common_phrases'])

以下文件中的代码仅用于以人类友好的方式格式化输出:

#visualizer.py
def displayShortestAnswer(data):
  print "A: %s" % (data['body'])
  print "Q: %s" % (data['question'])
  print "Length: %s characters" % (data['length'])

def displayMostActiveUsers(data):
  index = 1
  for u in data:
    print "%s - %s (%s)" % (index, u[0], u[1])
    index += 1

def displayMostActiveTopics(data):
  index = 1
  for u in data:
    print "%s - %s (%s)" % (index, u[0], u[1])
    index += 1

def displayMostHelpfulUser(data):
  index = 1
  for u in data:
    print "%s - %s (%s)" % (index, u[0], u[1])
    index += 1

def displayMostAnsweredQuestions(data):
  index = 1
  for u in data:
    print "%s - %s (%s)" % (index, u[0], u[1])
    index += 1

def displayMostCommonPhrases(data):
  index = 1
  for u in data:
    print "%s - %s (%s)" % (index, u[0], u[1])
    index += 1

分析代码

分析代码将分为两个步骤,就像我们之前所做的那样。对于每个项目,我们将分析代码,获取数字,考虑我们的优化选择,然后再次重构和测量代码的性能。

注意

如前所述的过程可能导致多次配置文件分析——重构——再次分析,我们将步骤限制在最终结果上。然而,请记住,这个过程是漫长的,需要花费时间。

抓取器

为了开始优化过程,我们首先需要获取一些测量数据,这样我们就可以将我们的更改与它们进行比较。

一个容易得到的数字是程序执行期间所花费的总时间(在我们的例子中,为了简单起见,我们将查询的总页数限制为 20)。

只需简单地使用 time 命令行工具,我们就可以得到这个数字:

$ time python scraper.py

以下截图显示,我们有 7 分钟 30 秒的时间抓取和解析 20 页的问题,这相当于一个 3 MB 的 JSON 文件:

抓取器

抓取脚本基本上是一个 I/O 密集型循环,以最小的处理从互联网上拉取数据。因此,我们可以在这里看到的第一个也是最有逻辑的优化是我们请求的缺乏并行化。由于我们的代码并不是真正 CPU 密集型的,我们可以安全地使用多线程模块(参考第五章 Multithreading versus Multiprocessing),并以最小的努力获得有趣的加速效果。

为了澄清我们将要做什么,以下图表显示了抓取脚本当前的状态:

Scraper

我们的大部分运行时间都花在了 I/O 操作上,更具体地说,是我们为了获取问题列表和每个问题的页面而进行的 HTTP 请求。

正如我们之前看到的,可以使用多线程模块轻松并行化 I/O 操作。因此,我们将转换我们的脚本,使其类似于以下图示:

Scraper

现在,让我们看看实际的优化代码。我们首先看看ThreadManager类,它将负责集中管理线程的配置以及整个并行过程的状态:

from bs4 import BeautifulSoup
import requests
import json
import threading

SO_URL = "http://scifi.stackexchange.com"
QUESTION_LIST_URL = SO_URL + "/questions"
MAX_PAGE_COUNT = 20

class ThreadManager:
  instance = None
  final_results = []
  threads_done = 0
  totalConnections = 4 #Number of parallel threads working, will affect the total amount of pages per thread

  @staticmethod
  def notify_connection_end( partial_results ):
    print "==== Thread is done! ====="
    ThreadManager.threads_done += 1
    ThreadManager.final_results += partial_results
    if ThreadManager.threads_done == ThreadManager.totalConnections:
      print "==== Saving data to file! ===="
      with open('scrapping-results-optimized.json', 'w') as outfile:
        json.dump(ThreadManager.final_results, outfile, indent=4)

以下函数负责使用BeautifulSoup从页面抓取信息,无论是获取页面列表还是获取每个问题的实际信息:

def get_author_name(body):
  link_name = body.select(".user-details a")
  if len(link_name) == 0:
    text_name = body.select(".user-details")
    return text_name[0].text if len(text_name) > 0 else 'N/A'
  else:
    return link_name[0].text

def get_question_answers(body):
  answers = body.select(".answer")
  a_data = []
  if len(answers) == 0:
    return a_data

  for a in answers:
    data = {
      'body': a.select(".post-text")[0].get_text(),
      'author': get_author_name(a)
    }
    a_data.append(data)
  return a_data

def get_question_data ( url ):
  print "Getting data from question page: %s " % (url)
  resp = requests.get(url)
  if resp.status_code != 200:
    print "Error while trying to scrape url: %s" % (url)
    return
  body_soup = BeautifulSoup(resp.text)
  #define the output dict that will be turned into a JSON structue
  q_data = {
    'title': body_soup.select('#question-header .question-hyperlink')[0].text,
    'body': body_soup.select('#question .post-text')[0].get_text(),
    'author': get_author_name(body_soup.select(".post-signature.owner")[0]),
    'answers': get_question_answers(body_soup)
  }
  return q_data

def get_questions_page ( page_num, end_page, partial_results  ):
  print "====================================================="
  print " Getting list of questions for page %s" % (page_num)
  print "====================================================="

  url = QUESTION_LIST_URL + "?sort=newest&page=" + str(page_num)
  resp = requests.get(url)
  if resp.status_code != 200:
    print "Error while trying to scrape url: %s" % (url)
  else:
    body = resp.text
    main_soup = BeautifulSoup(body)

    #get the urls for each question
    questions = main_soup.select('.question-summary .question-hyperlink')
    urls = [ SO_URL + x['href'] for x in questions]
    for url in urls:
      q_data = get_question_data(url)
     partial_results.append(q_data)
  if page_num + 1 < end_page:
    get_questions_page(page_num + 1,  end_page, partial_results)
  else:
    ThreadManager.notify_connection_end(partial_results)
pages_per_connection = MAX_PAGE_COUNT / ThreadManager.totalConnections
for i in range(ThreadManager.totalConnections):
 init_page = i * pages_per_connection
 end_page = init_page + pages_per_connection
 t = threading.Thread(target=get_questions_page,
 args=(init_page, end_page, [],  ),
 name='connection-%s' % (i))
  t.start()

上一段代码中突出显示的部分显示了最初脚本所做的主要更改。我们不是从第 1 页开始逐页前进,而是启动一个预配置的线程数量(直接使用threading.Thread类),这些线程将并行调用我们的get_question_page函数。我们唯一需要做的就是将那个函数作为每个新线程的目标。

之后,我们还需要一种方法来集中管理配置参数和每个线程的临时结果。为此,我们创建了ThreadManager类。

通过这个改变,我们的时间从 7 分钟标记下降到 2 分 13 秒,如下面的截图所示:

Scraper

调整线程数量,例如,可能会带来更好的数字,但主要的改进已经实现。

分析器

与抓取器相比,分析器的代码有所不同。我们不是有一个重 I/O 绑定的脚本,而是相反:一个 CPU 绑定的脚本。它进行的 I/O 操作非常少,主要是读取输入文件和输出结果。因此,我们将更详细地关注测量。

让我们首先进行一些基本测量,以便我们知道我们的位置:

Analyzer

前面的截图显示了time命令行工具的输出。因此,现在我们有一个基数可以工作,我们知道我们需要将执行时间降低到 3.5 秒以下。

第一种方法将是使用cProfile并从代码内部开始获取一些数字。这应该有助于我们获得程序的一般概述,从而开始了解我们的痛点在哪里。输出看起来像以下截图:

Analyzer

前面的截图中有两个感兴趣的区域:

  • 在左侧,我们可以看到函数及其消耗的时间。请注意,列表的大部分由外部函数组成,主要是来自nltk模块的函数(前两个只是下面其他函数的消费者,所以它们并不真正重要)。

  • 在右侧,调用者映射看起来非常复杂,难以解释(更不用说,其中列出的大多数函数都不是来自我们的代码,而是来自我们使用的库)。

话虽如此,直接改进我们的代码似乎不会是一件简单的事情。相反,我们可能想要走另一条路:既然我们在做很多计数,我们可能从使用类型化代码中受益。所以,让我们尝试使用 Cython。

使用 Cython 命令行工具进行初步分析显示,我们的大部分代码不能直接翻译成 C 语言,如下面的截图所示:

Analyzer

上一张截图显示了我们对代码分析的一部分。我们可以清楚地看到,大部分屏幕被较深的线条填充,这表明我们的大部分代码不能直接翻译成 C 语言。遗憾的是,这是因为我们在大多数函数中处理的是复杂对象,所以我们对此无能为力。

尽管如此,仅仅通过使用 Cython 编译我们的代码,我们就得到了更好的结果。所以,让我们看看我们需要如何修改源代码,以便我们可以使用 Cython 编译它。第一个文件基本上与原始分析器相同,代码中的更改被突出显示,并且没有实际的功能调用,因为我们现在正在将其转换为外部库:

#analyzer_cython.pyx
import operator
import string
import nltk
from nltk.util import ngrams
import json
import re

SOURCE_FILE = './scrapping-results.json'

# Returns the top "limit" users with the most questions asked
def get_most_active_users(data, int limit ):
  names = {}
  for q in data:
    if q['author'] not in names:
      names[q['author']] = 1
    else:
      names[q['author']] += 1
  return sorted(names.items(), reverse=True, key=operator.itemgetter(1))[:limit]

def get_node_content(node):
  return ' '.join([x[0] for x in node])

# Tries to extract the most common topics from the question's titles
def get_most_active_topics(data, int limit ):
  body = flatten_questions_titles(data)
  sentences = nltk.sent_tokenize(body)
  sentences = [nltk.word_tokenize(sent) for sent in sentences]
  sentences = [nltk.pos_tag(sent) for sent in sentences]
  grammar = "NP: {<JJ>?<NN.*>}"
  cp = nltk.RegexpParser(grammar)
  results = {}
  for sent in sentences:
    parsed = cp.parse(sent)
    trees = parsed.subtrees(filter=lambda x: x.label() == 'NP')
    for t in trees:
      key = get_node_content(t)
      if key in results:
        results[key] += 1
      else:
        results[key] = 1
  return sorted(results.items(), reverse=True, key=operator.itemgetter(1))[:limit]

# Returns the user that has the most answers
def get_most_helpful_user(data, int limit ):
  helpful_users = {}
  for q in data:
    for a in q['answers']:
      if a['author'] not in helpful_users:
        helpful_users[a['author']] = 1
      else:
        helpful_users[a['author']] += 1

  return sorted(helpful_users.items(), reverse=True, key=operator.itemgetter(1))[:limit]

# returns the top "limit" questions with the most amount of answers
def get_most_answered_questions(d, int limit ):
  questions = {}

  for q in d:
    questions[q['title']] = len(q['answers'])
  return sorted(questions.items(), reverse=True, key=operator.itemgetter(1))[:limit]

# Creates a single, lower cased string from the bodies of all questions
def flatten_questions_body(data):
  body = []
  for q in data:
    body.append(q['body'])
  return '. '.join(body)

# Creates a single, lower cased string from the titles of all questions
def flatten_questions_titles(data):
  body = []
  pattern = re.compile('(\[|\])')
  for q in data:
    lowered = string.lower(q['title'])
    filtered = re.sub(pattern, ' ', lowered)
    body.append(filtered)
  return '. '.join(body)

# Finds a list of the most common phrases of 'length' length
def get_most_common_phrases(d, int limit , int length ):
  body = flatten_questions_body(d)
  phrases = {}
  for sentence in nltk.sent_tokenize(body):
    words = nltk.word_tokenize(sentence)
    for phrase in ngrams(words, length):
      if all(word not in string.punctuation for word in phrase):
        key = ' '.join(phrase)
        if key in phrases:
          phrases[key] += 1
        else:
          phrases[key] = 1

  return sorted(phrases.items(), reverse=True, key=operator.itemgetter(1))[:limit]

# Finds the answer with the least amount of characters
def get_shortest_answer(d):
  cdef int shortest_length = 0;

  shortest_answer = {
    'body': '',
    'length': -1
  }
  for q in d:
    for a in q['answers']:
 if len(a['body']) < shortest_length or shortest_length == 0:
 shortest_length = len(a['body'])
        shortest_answer = {
          'question': q['body'],
          'body': a['body'],
          'length': shortest_length
        }
  return shortest_answer

# Load the json file and return the resulting dict
def load_json_data(file):
  with open(file) as input_file:
    return json.load(input_file)

def analyze_data(d):
  return {
    'shortest_answer': get_shortest_answer(d),
    'most_active_users': get_most_active_users(d, 10),
    'most_active_topics': get_most_active_topics(d, 10),
    'most_helpful_user': get_most_helpful_user(d, 10),
    'most_answered_questions': get_most_answered_questions(d, 10),
    'most_common_phrases':  get_most_common_phrases(d, 10, 4),
  }

以下文件是负责为 Cython 编译我们的代码设置一切所需的文件,我们之前已经见过这段代码(参考第六章,通用优化选项):

#analyzer-setup.py
from distutils.core import setup
from Cython.Build import cythonize

setup(
  name = 'Analyzer app',
  ext_modules = cythonize("analyzer_cython.pyx"),
)

最后一个文件是使用我们新导入的编译模块的外部库的文件。该文件调用load_json_dataanalyze_data方法,并最终使用可视化模块来格式化输出:

#analyzer-use-cython.py
import analyzer_cython as analyzer
import visualizer

data_dict = analyzer.load_json_data(analyzer.SOURCE_FILE)

results = analyzer.analyze_data(data_dict)

print "=== ( Shortest Answer ) === "
visualizer.displayShortestAnswer(results['shortest_answer'])

print "=== ( Most Active Users ) === "
visualizer.displayMostActiveUsers(results['most_active_users'])

print "=== ( Most Active Topics ) === "
visualizer.displayMostActiveTopics(results['most_active_topics'])

print "=== ( Most Helpful Users ) === "
visualizer.displayMostHelpfulUser(results['most_helpful_user'])

print "=== ( Most Answered Questions ) === "
visualizer.displayMostAnsweredQuestions(results['most_answered_questions'])

print "=== ( Most Common Phrases ) === "
visualizer.displayMostCommonPhrases(results['most_common_phrases'])

以下代码可以使用以下行进行编译:

$ python analyzer-setup.py build_ext –inplace

然后,通过运行analyzer-use-cython.py脚本,我们将得到以下执行时间:

Analyzer

时间从 3.5 秒下降到 1.3 秒。这比仅仅重新组织我们的代码并使用 Cython 编译(如我们在第六章中看到的)有了相当大的改进。这种简单的编译可以产生很好的结果。

代码可以被进一步分解和重写,以消除对复杂结构的绝大部分需求,从而允许我们为所有变量声明原始类型。我们甚至可以尝试移除nltk并使用一些用 C 语言编写的 NLP 库,例如 OpenNLP (opennlp.sourceforge.net/projects.html)。

摘要

你已经到达了本章的结尾,也是这本书的结尾。本章提供的例子旨在展示如何使用前几章中介绍的技术来分析和改进随机的一段代码。

由于并非所有技术都相互兼容,因此并非所有技术都适用于此处。然而,我们能够看到其中一些技术是如何工作的,更具体地说,是多线程、使用cProfilekcachegrind进行性能分析,以及最终使用 Cython 进行编译。

感谢您阅读,并希望您能享受这本书!

posted @ 2025-09-23 21:57  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报