第 1 章 基准测试与剖析

第 1 章 基准测试与剖析

就提高代码速度而言,最重要的是找出程序中速度缓慢的部分。所幸在大多数情况下,导致应用程序速度缓慢的代码都只占程序的很小一部分。确定这些关键部分后,就可专注于需要改进的部分,避免将时间浪费于微优化。

通过剖析(profiling),可确定应用程序的哪些部分消耗的资源最多。剖析器(profiler)是这样一种程序:运行应用程序并监控各个函数的执行时间,以确定应用程序中哪些函数占用的时间最多。

Python提供了多个工具,可帮助找出瓶颈并度量重要的性能指标。本章将介绍如何使用标准模块cProfile和第三方包line_profiler,还将介绍如何使用工具memory_profiler剖析应用程序的内存占用情况。本章还将介绍另一个很有用的工具——KCachegrind,使用它能以图形化方式显示各种剖析器生成的数据。

基准测试程序(benchmark)是用于评估应用程序总体执行时间的小型脚本。本章将介绍如何编写基准测试程序以及如何准确地测量程序的执行时间。

本章介绍如下主题:

  • 通用的高性能编程原则;
  • 编写测试和基准测试程序;
  • Unix命令time
  • Python模块timeit
  • 使用pytest进行测试和基准测试;
  • 剖析应用程序;
  • 标准工具cProfile
  • 使用KCachegrind解读剖析结果;
  • 工具line_profilermemory_profiler
  • 使用模块dis对Python代码进行反汇编。

1.1 设计应用程序

就设计高性能程序而言,最重要的是在编写代码期间不进行细微的优化。

“过早优化是万恶之源。”

——高德纳

在开发过程的早期阶段,程序的设计可能瞬息万变,你可能需要大规模地改写和重新组织代码。在此阶段,你需要对不同的原型进行测试,而不进行优化,这样可自由地分配时间和精力,确保程序能够得到正确的结果,同时具有灵活的设计。归根结底,谁都不想要一个运行速度很快但结果却不正确的应用程序。

优化代码时,必须牢记如下箴言。

  • 让它能够运行:必须让软件能够运行,并确保它生成的结果是正确的。这个探索阶段让你能够对应用程序有更深入的认识,并在早期发现重大设计问题。
  • 确保设计正确:必须确保程序的设计是可靠的。进行任何性能优化前务必先重构,这可帮助你将应用程序划分成独立而内聚且易于维护的单元。
  • 提高运行速度:确保程序能够运行且结构优良后,就可专注于性能优化了。例如,如果内存消耗是个问题,你可能想对此进行优化。

在本节中,我们将编写一个粒子模拟器测试应用程序并对其进行剖析。这个模拟器程序接受一些粒子,并根据我们指定的规则模拟这些粒子随时间流逝的运动情况。这些粒子可能是抽象实体,也可能是真实的物体,如运动的桌球、气体中的分子、在太空中移动的星球、烟雾颗粒、液体等。

在物理、化学、天文学等众多学科中,计算机模拟都很有用。对用于模拟系统的应用程序来说,性能非常重要,因此科学家和工程师会花费大量时间来优化其代码。为了研究真实的系统,通常必须模拟大量的实体,因此即便是细微的性能提升也价值不菲。

在这个模拟系统示例中,包含的粒子以不同的速度绕中心点不断地旋转,就像钟表的指针一样。

为了模拟这种系统,需要如下信息:粒子的起始位置、速度和旋转方向。我们必须根据这些信息计算粒子在下一个时刻的位置。下图说明了这个系统,其中原点为(0, 0),位置用向量 xy 表示,而速度用向量 vxvy 表示。

{%}

圆周运动的基本特征是,粒子的运动方向始终与其当前位置到中心点的线段垂直。要移动粒子,只需采取一系列非常小的步骤(对应于系统在很短时间内的变化),并在每个步骤中都根据粒子的运动方向修改其位置,如下图所示。

{%}

我们将以面向对象的方式设计这个应用程序。根据这个应用程序的需求,显然需要设计一个通用的Particle类,用于存储粒子的位置(xy)以及角速度(ang_vel)。

class Particle:
    def __init__(self, x, y, ang_vel):
        self.x = x
        self.y = y
        self.ang_vel = ang_vel

请注意,这里所有的参数都可正可负,其中ang_vel的符号决定了旋转方向。

还需要设计另一个类——ParticleSimulator,它封装了运动定律,负责随时间流逝修改粒子的位置。在这个类中,方法__init__存储一个Particle实例列表,而方法evolve根据指定的定律修改粒子的位置。

我们要让粒子绕坐标(0, 0)以固定的速度旋转,而运动方向总是与从粒子当前位置到中心点的线段垂直(参见本章的第一个图示)。要将运动方向表示为xy向量(Python变量v_xv_y),使用下面的公式即可。

v_x = -y / (x**2 + y**2)**0.5
v_y = x / (x**2 + y**2)**0.5

对于特定的粒子,经过时间t后,它将到达圆周上的下一个位置。我们可以这样近似计算圆周轨迹:将时段 t 分成一系列很小的时段dt,在这些很小的时段内,粒子沿圆周的切线移动。这样就近似地模拟了圆周运动。为避免误差过大(如下图所示),时段 dt 必须非常短。

{%}

简而言之,为计算经过时间 t 后的粒子位置,必须采取如下步骤。

(1) 计算运动方向(v_xv_y)。

(2) 计算位移(d_xd_y),即时段dt、角速度和移动方向的乘积。

(3) 不断重复第(1)步和第(2)步,直到时间过去t

ParticleSimulator类的完整实现代码如下:

class ParticleSimulator:
 
    def __init__(self, particles):
        self.particles = particles
 
    def evolve(self, dt):
        timestep = 0.00001
        nsteps = int(dt/timestep)
 
        for i in range(nsteps):
            for p in self.particles:
                # 1. 计算方向
                norm = (p.x**2 + p.y**2)**0.5
                v_x = -p.y/norm
                v_y = p.x/norm
 
                # 2. 计算位移
                d_x = timestep * p.ang_vel * v_x
                d_y = timestep * p.ang_vel * v_y
 
                p.x += d_x
                p.y += d_y
                # 3. 不断重复,直到时间过去t

为了可视化这里的粒子,可使用matplotlib库。这个库不包含在Python标准库中,但可使用命令pip install matplotlib轻松地安装它。

 也可使用发行包Anaconda Python,它包含matplotlib以及本书使用的其他大多数第三方包。Anaconda是免费的,可用于Linux、Windows和Mac。

为了创建交互式可视化,我们使用函数matplotlib.pyplot.plot以点的方式显示粒子,并使用matplotlib.animation.FuncAnimation类以动画方式显示粒子随时间流逝的移动情况。

函数visualize将一个ParticleSimulator实例作为参数,并以动画方式显示粒子的运动轨迹。为了使用matplotlib来显示粒子的运动规则,必须采取的步骤如下。

  • 创建并设置坐标轴,再使用函数plot来显示粒子。函数plotx 坐标和 y 坐标列表作为参数。
  • 编写初始化函数init和函数animate,其中后者使用方法line.set_data来更新 xy 坐标。
  • 创建一个FuncAnimation实例:传入函数initanimate以及参数intervalblit,其中参数interval指定更新间隔,而blit可改善图像的更新速率。
  • 使用plt.show()运行动画。

 

from matplotlib import pyplot as plt
from matplotlib import animation
 
def visualize(simulator):
 
    X = [p.x for p in simulator.particles]
    Y = [p.y for p in simulator.particles]
 
    fig = plt.figure()
    ax = plt.subplot(111, aspect='equal')
    line, = ax.plot(X, Y, 'ro')
 
    # 指定坐标轴的取值范围
    plt.xlim(-1, 1)
    plt.ylim(-1, 1)
 
    # 这个方法将在动画开始时运行
    def init():
        line.set_data([], [])
        return line, # 这里的逗号必不可少!
 
    def animate(i):
        # 我们让粒子运动0.01个时间单位
        simulator.evolve(0.01)
        X = [p.x for p in simulator.particles]
        Y = [p.y for p in simulator.particles]
 
        line.set_data(X, Y)
        return line,
 
    # 每隔10毫秒调用一次动画函数
    anim = animation.FuncAnimation(fig,
                                   animate,
                                   init_func=init,
                                   blit=True,
                                   interval=10)
    plt.show()

为了测试这些代码,我们定义了一个简短的函数——test_visualize,它以动画方式模拟一个包含3个粒子的系统,其中每个粒子的运动方向各不相同。请注意,第三个粒子环绕一周的速度是其他两个粒子的3倍。

def test_visualize():
    particles = [Particle(0.3, 0.5, 1),
                 Particle(0.0, -0.5, -1),
                 Particle(-0.1, -0.4, 3)]
 
    simulator = ParticleSimulator(particles)
    visualize(simulator)
 
if __name__ == '__main__':
    test_visualize()

函数test_visualize很有用,可帮助你直观地理解系统随时间流逝的变化情况。在下一节,我们将再编写一些测试函数,以核实这个程序是正确的并测量其性能。

1.2 编写测试和基准测试程序

编写管用的模拟器后,便可着手测量其性能,并对代码进行优化,让模拟器能够处理尽可能多的粒子。首先,我们将编写测试和基准测试程序。

我们需要一个检查模拟结果是否正确的测试。为了优化程序,通常必须采取多种策略,但在反复重写代码的过程中,很容易引入bug。可靠的测试集可确保每次迭代后实现都是正确的,这让我们能够大胆地进行不同的尝试,并深信只要能够通过测试集,代码就依然是像期望的那样工作的。

我们的测试将接受3个粒子,模拟0.1个时间单位,并将结果与来自参考实现的结果进行比较。为了组织测试,一种不错的方式是,对于应用程序的每个方面(或者说单元)都使用一个不同的函数进行测试。鉴于这个应用程序的功能都是在方法evolve中实现的,因此我们将把测试函数命名为test_evolve。下面列出了函数test_evolve的实现代码。请注意,为了对浮点数进行比较,我们使用了函数fequal来确定它们的差在一定范围内。

def test_evolve():
    particles = [Particle( 0.3, 0.5, +1),
                 Particle( 0.0, -0.5, -1),
                 Particle(-0.1, -0.4, +3)]
 
    simulator = ParticleSimulator(particles)
 
    simulator.evolve(0.1)
 
    p0, p1, p2 = particles
 
    def fequal(a, b, eps=1e-5):
        return abs(a - b) < eps
 
    assert fequal(p0.x, 0.210269)
    assert fequal(p0.y, 0.543863)
 
    assert fequal(p1.x, -0.099334)
    assert fequal(p1.y, -0.490034)
 
    assert fequal(p2.x, 0.191358)
    assert fequal(p2.y, -0.365227)
 
if __name__ == '__main__':
    test_evolve()

测试可确保我们正确地实现了功能,但几乎没有提供任何有关运行时间的信息。基准测试程序是简单而有代表性的用例,可通过执行它来评估应用程序的运行时间。对于跟踪程序在每次迭代后运行速度有多快,基准测试程序很有用。

为了编写一个有代表性的基准测试程序,我们可实例化1000个坐标和角速度都是随机的Particle对象,并将它们提供给ParticleSimulator类,然后让系统运行0.1个时间单位。

from random import uniform
 
def benchmark():
    particles = [Particle(uniform(-1.0, 1.0),
                          uniform(-1.0, 1.0),
                          uniform(-1.0, 1.0))
                  for i in range(1000)]
 
    simulator = ParticleSimulator(particles)
    simulator.evolve(0.1)
 
if __name__ == '__main__':
    benchmark()

测量基准测试程序的运行时间

要计算基准测试程序的运行时间,一种非常简单的方法是使用Unix命令time。通过像下面这样使用命令time,可轻松地测量任何进程的执行时间。

    $ time python simul.py
real    0m1.051s
user    0m1.022s
sys     0m0.028s

在Windows系统中,没有命令time。要在Windows系统中安装Unix工具,如命令time,可使用cygwin shell(可从其官网下载)。你也可使用类似的PowerShell命令来测量执行时间,如Measure-Command

默认情况下,time显示3个指标。

  • real:从头到尾运行进程实际花费的时间,与人用秒表测量得到的时间相当。
  • user:在计算期间,所有CPU花费的总时间。
  • sys:在执行与系统相关的任务(如内存分配)期间,所有CPU花费的总时间。

请注意,在有些情况下,usersys的和可能大于real,这是因为可能有多个处理器在并行地工作。

 time还提供了丰富的格式设置选项,有关这方面的大致情况,可参阅用户手册(执行命令man time)。如果你要查看有关所有指标的摘要,可使用选项-v

为测量基准测试程序的执行时间,Unix命令是最简单也比较直接的方式之一。为确保测量结果是准确的,基准测试程序的执行时间应足够长(为秒级),以确保创建和删除进程的时间相比于应用程序的执行时间来说很短。指标user适合用于监视CPU的性能,而指标real也包含等待输入/输出操作期间用在其他进程上的时间。

为了测量Python脚本的执行时间,另一种方便的方式是使用模块timeit。这个模块在循环中运行代码片段n次,并测量总执行时间,然后重复这种操作r(默认为3)次,并记录其中最短的那次时间。鉴于其测量时间的方式,timeit非常适合用于准确地测量少量语句的执行时间。

可在命令行或IPython中将模块timeit作为Python包执行。

IPython是一个Python shell,是为改善Python解释器的交互性而设计的。它支持按Tab键进行补全,还提供了很多用于对代码进行计时、剖析和调试的工具。本书自始至终都将使用这个shell来执行代码片段。IPython shell支持魔法命令(magic command),即以符号%打头的语句,这赋予了它特殊行为。以%%打头的命名被称为单元格魔法命令,可应用于多行的代码片段(被称为单元格)。

在大多数Linux系统中,都可通过pip命令来安装IPython。另外,Anaconda也包含IPython。

 可将IPython作为常规Python shell使用(ipython),但它还有基于Qt的版本(ipython qtconsole)以及使用基于浏览器的界面的版本(jupyter notebook)。

在IPython和命令行界面中,还可使用选项-n-r分别指定循环次数和重复次数。如果没有指定,timeit将自动推断出它们。从命令行调用timeit时,还可使用选项-s传入一些设置代码——在基准测试程序之前执行的代码。下面演示了如何在IPython和命令行中执行timeit

# IPython界面
$ ipython
In [1]: from simul import benchmark
In [2]: %timeit benchmark()
1 loops, best of 3: 782 ms per loop
 
# 命令行界面
$ python -m timeit -s 'from simul import benchmark' 'benchmark()'
10 loops, best of 3: 826 msec per loop
 
# Python界面
# 将这个函数放到脚本simul.py中
 
import timeit
result = timeit.timeit('benchmark()',
 setup='from __main__ import benchmark',
 number=10)
 
# 结果为整个循环的执行时间(单位为秒)
result = timeit.repeat('benchmark()',
 setup='from __main__ import benchmark',
 number=10,
 repeat=3)
# 结果是一个列表,其中包含每次的执行时间(这里重复3次)

请注意,在命令行界面和IPython界面中,会自动确定合理的循环次数(n),但在Python界面中,必须通过参数number显式地指定循环次数。

1.3 使用pytest-benchmark编写更佳的测试和基准测试程序

Unix命令time是个多功能工具,可在各种平台上用来评估小程序的执行时间。对于较大的Python应用程序和库,要对其进行测试和基准测试,一种更全面的解决方案是结合使用pytest及其插件pytest-benchmark

在本节中,我们将使用测试框架pytest为应用程序编写一个简单的基准测试程序。如果读者想更详细地了解这个框架及其用法,pytest文档是最佳的资源,其网址为http://doc.pytest.org/en/latest/

 可在控制台中使用命令pip install pytest来安装pytest,其基准测试插件也可以类似的方式安装——使用命令pip install pytest-benchmark

测试框架是一组测试工具,可简化编写、执行和调试测试的工作,还提供了丰富的测试结果报告和摘要。使用框架pytest时,建议将测试和应用程序代码放在不同的文件中。在下面的示例中,我们创建了文件test_simul.py,其中包含函数test_evolve

from simul import Particle, ParticleSimulator
 
def test_evolve():
    particles = [Particle( 0.3, 0.5, +1),
                 Particle( 0.0, -0.5, -1),
                 Particle(-0.1, -0.4, +3)]
 
    simulator = ParticleSimulator(particles)
 
    simulator.evolve(0.1)
 
    p0, p1, p2 = particles
 
    def fequal(a, b, eps=1e-5):
        return abs(a - b) < eps
 
    assert fequal(p0.x, 0.210269)
    assert fequal(p0.y, 0.543863)
 
    assert fequal(p1.x, -0.099334)
    assert fequal(p1.y, -0.490034)
 
    assert fequal(p2.x, 0.191358)
    assert fequal(p2.y, -0.365227)

可在命令行中运行可执行文件pytest,它将找到并运行Python模块中的测试。要执行特定的测试,可使用语法pytest path/to/module.py::function_name。为执行test_evolve,可在控制台中输入如下命令,这将获得简单但信息丰富的输出。

$ pytest test_simul.py::test_evolve
 
platform linux -- Python 3.5.2, pytest-3.0.5, py-1.4.32, pluggy-0.4.0
rootdir: /home/gabriele/workspace/hiperf/chapter1, inifile: plugins:
collected 2 items
 
test_simul.py .
 
=========================== 1 passed in 0.43 seconds
===========================

编写好测试后,就可使用插件pytest-benchmark将测试作为基准测试程序来执行。如果我们修改函数test_evolve,使其接受一个名为benchmark的参数,框架pytest将自动将资源benchmark作为参数传递给这个函数。在pytest中,这些资源被称为测试夹具(fixture)。为调用基准测试资源,可将要作为测试基准程序的函数作为第一个参数,并在它后面指定其他参数。下面演示了为对函数ParticleSimulator.evolve进行基准测试,需要对代码做哪些修改。

from simul import Particle, ParticleSimulator
 
def test_evolve(benchmark):
    # ……以前的代码
    benchmark(simulator.evolve, 0.1)

为运行基准测试,只需再次执行命令pytest test_simul.py::test_evolve即可。输出将包含有关函数test_evolve的详细计时信息,如下所示。

对于收集的每个测试,pytest-benchmark都将执行基准测试函数多次,并提供有关其运行时间的统计摘要。前面的输出很有趣,因为它表明每次运行时执行时间都不同。

在这个示例中,test_evolve中的基准测试函数运行了34次(见Rounds列),它们的执行时间为29~41毫秒不等(见MinMax列),但平均值和中间值很接近,都是30毫秒左右,这与最短的执行时间相当接近。这个示例表明,每次运行时性能差别很大,因此使用只进行单次计时的工具(如time)时,最好运行程序多次,并记录有代表性的结果,如最小值或中间值。

pytest-benchmark还有很多其他的功能和选项,可用来精确地测量时间和分析结果。有关这方面的详细信息,可参阅其文档。

1.4 使用cProfile找出瓶颈

核实程序的正确性并测量其执行时间后,便可着手找出需要进行性能优化的代码片段了。与整个程序相比,这些代码的规模通常很小。

在Python标准库中,有两个剖析模块。

  • 模块profile:这个模块是完全使用Python编写的,给程序执行增加了很大的开销。这个模块之所以出现在标准库中,原因在于其强大的平台支持和易于扩展。
  • 模块cProfile:这是主要的剖析模块,其接口与profile相同。这个模块是使用C语言编写的,因此开销很小,适合用作通用剖析器。

可以三种不同的方式使用模块cProfile

  • 在命令行中使用;
  • 作为Python模块使用;
  • 在IPython中使用。

无须对其源代码做任何修改,就可对现有Python脚本或函数执行cProfile。要在命令行中使用cProfile,可像下面这样做:

$ python -m cProfile simul.py

这将打印长长的输出,其中包含针对应用程序中调用的所有函数的多个指标。要按特定的指标对输出进行排序,可使用选项-s。在下面的示例中,输出是按后面将介绍的指标tottime排序的。

$ python -m cProfile -s tottime simul.py

要将cProfile生成的数据保存到输出文件中,可使用选项-ocProfile使用模块stats和其他工具能够识别的格式。下面演示了选项-o的用法。

$ python -m cProfile -o prof.out simul.py

要将cProfile作为Python模块使用,必须像下面这样调用函数cProfile.run

from simul import benchmark
import cProfile
 
cProfile.run("benchmark()")

你还可在调用对象cProfile.Profile的方法的代码之间包含一段代码,如下所示。

from simul import benchmark
import cProfile
 
pr = cProfile.Profile()
pr.enable()
benchmark()
pr.disable()
pr.print_stats()

也可在IPython中以交互的方式使用cProfile。魔法命令%prun让你能够剖析特定的函数调用,如下图所示。

cProfile的输出分成了5列。

  • ncalls:函数被调用的次数。
  • tottime:执行函数花费的总时间,不考虑其他函数调用。
  • cumtime:执行函数花费的总时间,考虑其他函数调用。
  • percall:单次函数调用花费的时间——可通过将总时间除以调用次数得到。
  • filename:lineno:文件名和相应的行号。调用C语言扩展模块时,不包含这种信息。

最重要的指标是tottime,它表示执行函数体花费的实际时间(不包含子调用),让我们能够知道瓶颈到底在哪里。

大部分时间都花在函数evolve上,这没什么可奇怪的。可以想见,循环是需要进行性能优化的那部分代码。

cProfile只提供函数级信息,而不会指出导致瓶颈的具体是哪些语句。所幸工具line_profiler能够提供函数中各行的时间花费信息,这将在下一节介绍。

对于包含大量调用和子调用的大型程序来说,分析cProfile的文本输出可能是项令人望而却步的任务。有一些可视化工具可帮助完成这些任务,它们使用交互式图形界面,让你能够轻松地导航。

KCachegrind就是一个这样的图形用户界面(GUI)工具,可帮助你分析cProfile生成的剖析输出。

 Ubuntu 16.04官方仓库中包含KCachegrind。Windows用户可从http://sourceforge.net/projects/qcachegrindwin/下载Qt port——QCacheGrind。Mac用户可使用Mac Ports编译QCacheGrind,至于如何编译,请参阅博文“Install kcachegrind on MacOSX with ports”中的说明。

KCachegrind无法直接读取cProfile生成的输出文件,所幸第三方Python模块pyprof2-calltree能够将cProfile输出文件转换为KCachegrind能够读取的格式。

 要安装Python Package Index中的pyprof2calltree,可使用命令pip install pyprof2calltree

为最大限度地展示KCachegrind的功能,我们将使用另一个结构更为多样化的示例。我们定义一个递归函数factorial,还有另外两个使用factorial的函数—— taylor_exptaylor_sin,它们分别计算exp(x)sin(x)的泰勒展开式的多项式系数。

def factorial(n):
    if n == 0:
        return 1.0
    else:
        return n * factorial(n-1)
 
def taylor_exp(n):
    return [1.0/factorial(i) for i in range(n)]
 
def taylor_sin(n):
    res = []
    for i in range(n):
        if i % 2 == 1:
            res.append((-1)**((i-1)/2)/float(factorial(i)))
        else:
            res.append(0.0)
    return res
 
def benchmark():
    taylor_exp(500)
    taylor_sin(500)
 
if __name__ == '__main__':
    benchmark()

为访问剖析信息,首先需要生成cProfile输出文件。

$ python -m cProfile -o prof.out taylor.py

然后,就可使用pyprof2calltree对输出文件进行转换并启动KCachegrind。

$ pyprof2calltree -i prof.out -o prof.calltree
$ kcachegrind prof.calltree # 或使用命令qcachegrind prof.calltree

输出如下面的屏幕截图所示。

该屏幕截图显示了KCachegrind的用户界面。左边的输出与cProfile的输出很像,但列名稍有不同:Incl对应于cProfile模块中的cumtime,而Self对应于tottime。如果你单击菜单栏中的Relative按钮,将以百分比的方式显示值。通过单击列名,可按相应的属性进行排序。

在右上方,如果你单击标签Callee Map,将显示一个函数开销图。在该图中,函数占用的时间百分比与矩形面积成正比。矩形可包含子矩形,而这些子矩形表示对其他函数的子调用。在这个示例中,很容易看出有两个表示函数factorial的矩形,其中左边那个对应于taylor_exp调用的factorial,而右边那个对应于taylor_sin调用的factorial

你可单击右边底部的标签Call Graph来显示另一个图——调用图。调用图是函数间调用关系的图形化表示,其中每个矩形都表示一个函数,而箭头表示调用关系。例如,taylor_exp调用了factorial 500次,而taylor_sin调用了factorial 250次。KCachegrind还能够发现递归调用:factorial调用了自己187 250次。

你可双击矩形来切换到Call Graph或Caller Map选项卡,在这种情况下,界面将相应地更新,指出计时属性是相对于选定函数的。例如,双击taylor_exp将导致图形发生变化,只显示taylor_exp对总开销的贡献。

 另一个用于生成调用图的流行工具是Gprof2Dot。使用支持的剖析器生成的输出文件启动时,Gprof2Dot将生成一个表示调用图的.dot图。

1.5 使用line_profiler逐行进行剖析

知道哪个函数需要优化后,就可使用模块line_profiler来提供有关时间是如何在各行之间分配的信息。在难以确定哪些语句最费时时,这很有用。line_profiler是Python Package Index提供的一个第三方模块,其安装说明请参阅https://github.com/rkern/line_profiler

要使用line_profiler,需要对要监视的函数应用装饰器@profile。请注意,无须从其他模块中导入函数profile,因为运行剖析脚本kernprof.py时,它将被注入全局命名空间。要对我们的程序进行剖析,需要给函数evolve添加装饰器@profile

@profile
def evolve(self, dt):
    # 代码

脚本kernprof.py生成一个输出文件,并将剖析结果打印到标准输出。运行这个脚本时,应指定两个选项。

  • -l:以使用函数line_profiler
  • -v:以立即将结果打印到屏幕

下面演示了kernprof.py的用法:

$ kernprof.py -l -v simul.py

也可在IPython shell中运行这个剖析器,这样可以进行交互式编辑。你应首先加载line_profiler扩展,它提供了魔法命令lprun。使用这个命令,就无须添加装饰器@profile

输出非常直观,分成了6列。

  • Line #:运行的代码行号。
  • Hits:代码行运行的次数。
  • Time:代码行的执行时间,单位为微秒。
  • Per Hit:Time/Hits。
  • % Time:代码行总执行时间所占的百分比。
  • Line Contents:代码行的内容。

只需查看% Time列,就可清楚地知道时间都花在了什么地方。在这个示例中,for循环中几条语句占用的时间百分比都在10%~20%。

1.6 优化代码

确定应用程序的大部分时间都花在什么地方后,就可做些修改,并评估修改对性能的影响。

要优化纯粹的Python代码,方式有多种,其中效果最显著的方式是对使用的算法进行改进。就这个示例而言,相比于计算速度并逐步累积位移,效率更高(而且绝对准确而不是近似)的方式是,使用半径(r)和角度(alpha)来表示运动方程,再使用下面的方程来计算粒子在圆周上的位置。

x = r * cos(alpha)
y = r * sin(alpha)

另一种方式是最大限度地减少指令数。例如,可预先计算不随时间变换的因子timestep * p.ang_vel factor。为此,我们可交换循环顺序(先迭代粒子,再迭代时段),并将计算前述因子的代码放在迭代粒子的循环外面。

逐行剖析结果还表明,即便是简单的赋值操作,也可能消耗大量的时间。例如,下面的语句占用的时间超过了总时间的10%。

v_x = (-p.y)/norm

可通过减少赋值操作数量来改善这个循环的性能,为此可将这个表达式改写为单条更复杂些的语句,以消除中间变量(请注意,将先计算等号右边的部分,再将结果赋给变量)。

p.x, p.y = p.x - t_x_ang*p.y/norm, p.y + t_x_ang * p.x/norm

这样修改后的代码如下所示。

    def evolve_fast(self, dt):
        timestep = 0.00001
        nsteps = int(dt/timestep)
 
        # 调整了循环顺序
        for p in self.particles:
            t_x_ang = timestep * p.ang_vel
            for i in range(nsteps):
                norm = (p.x**2 + p.y**2)**0.5
                p.x, p.y = (p.x - t_x_ang * p.y/norm,
                            p.y + t_x_ang * p.x/norm)

修改代码后,应运行测试以核实结果与以前相同,再使用基准测试对执行时间进行比较。

$ time python simul.py # 优化性能后
real    0m0.756s 
user    0m0.714s
sys     0m0.036s
 
$ time python simul.py # 原来的代码
real    0m0.863s
user    0m0.831s
sys     0m0.028s

如你所见,进行纯粹而细微的Python优化后,速度有所提高,但并不显著。

1.7 模块dis

在某些情况下,要估计Python语句将执行多少操作并不容易。在本节中,我们将深入Python内部,以估计各条语句的性能。在CPython解释器中,Python代码首先被转换为中间表示——字节码,再由Python解释器执行。

要了解代码是如何转换为字节码的,可使用Python模块dis(dis表示disassemble,即反汇编)。这个模块的用法非常简单,只需对目标代码(这里是方法ParticleSimulator.evolve)调用函数dis.dis即可。

import dis
from simul import ParticleSimulator
dis.dis(ParticleSimulator.evolve)

这将打印每行代码对应的字节码指令列表。例如,语句v_x =-p.y/norm被转换为下面一组指令。

29              85 LOAD_FAST                   5 (p)
                88 LOAD_ATTR                   4 (y)
                91 UNARY_NEGATIVE
                92 LOAD_FAST                   6 (norm)
                95 BINARY_TRUE_DIVIDE
                96 STORE_FAST                  7 (v_x)

其中LOAD_FAST将指向变量p的引用加载到栈中,而LOAD_ATTR加载栈顶元素的属性y。其他指令(UNARY_NEGATIVEBINARY_TRUE_DIVIDE)只是对栈顶元素执行算术运算。最后,结果被存储到v_x中(STORE_FAST)。

通过分析dis的输出可知,循环的第一个版本被转换为51个字节码指令,而第二个版本被转换为35个指令。

模块dis能够让你知道语句是如何被转换的,但主要用作探索和学习Python字节码的工具。

要进一步改善性能,可继续尝试找出其他减少指令数量的方法。然而,这种方法显然最终受制于Python解释器的速度,而对有些工作来说,Python解释器可能不是合适的工具。在后续章节中,我们将介绍如何提高那些受制于解释器的计算的速度——执行使用C或Fortan等低级语言编写的快速专用版本。

1.8 使用memory_profiler剖析内存使用情况

在有些情况下,消耗大量的内存是个问题。例如,如果我们要处理大量的粒子,就需要创建大量的Particle实例,这将带来很大的内存开销。

模块memory_profiler以类似于line_profiler的方式,提供有关进程内存使用情况的摘要。

 Python Package Index也提供了memory_profiler包。你应同时安装模块psutil,这样将极大地提高memory_profiler的速度。

line_profiler一样,memory_profiler也要求对源代码进行处理:给要监视的函数加上装饰器@profile。在这个示例中,我们要分析的是函数benchmark。

我们可稍微修改函数benchmark,以实例化大量(100 000个)Particle实例,并缩短模拟时间。

def benchmark_memory():
    particles = [Particle(uniform(-1.0, 1.0),
                          uniform(-1.0, 1.0),
                          uniform(-1.0, 1.0))
                  for i in range(100000)]
 
    simulator = ParticleSimulator(particles)
    simulator.evolve(0.001)

我们可在IPython shell中使用memory_profiler,为此可使用魔法命令%mprun,如下面的屏幕截图所示。

 在IPython shell中,也可使用命令 mprof run来运行memory_profiler,但这样做之前必须先给要监视的函数添加@profile装饰器。

Increment列可知,100 000个Particle对象占用了23.7MiB的内存。

 1MiB(兆字节)相当于1 048 576字节,这不同于1MB(百万字节),后者相当于1 000 000字节。

为减少内存消耗,可在Particle类中使用__slots__。这将避免将实例的变量存储在内部字典中,从而节省一些内存。然而,这种策略也有缺点:不能添加__slots__中没有指定的属性。

class Particle:
    __slots__ = ('x', 'y', 'ang_vel')
 
    def __init__(self, x, y, ang_vel):
        self.x = x
        self.y = y
        self.ang_vel = ang_vel

现在可以再次运行基准测试,以评估内存消耗的变化情况,结果如下面的屏幕截图所示。

通过使用__slots__重写Particle类,节省了大约10MiB内存。

1.9 小结

本章介绍了基本的优化原则,并将这些原则应用于一个测试应用程序。优化时,首先要做的是测试,并找出应用程序的瓶颈。你学会了如何编写基准测试程序,以及如何使用Unix命令time、Python模块timeit和功能齐备的pytest-benchmark包来测量基准测试程序的执行时间。你还学习了如何使用cProfileline_profilermemory_profiler对应用程序进行剖析,以及如何使用KCachegrind以图形化方式分析和导航剖析数据。

下一章将探索如何使用Python标准库中的算法和数据结构来改善性能,你将学习可伸缩性(scaling)、多个数据结构的用法以及缓存和memoization等技巧。

目录

  • 版权声明
  • 前言
  • 致谢
  • 第 1 章 基准测试与剖析
  • 第 2 章 纯粹的Python优化
  • 第 3 章 使用NumPy和Pandas快速执行数组操作
  • 第 4 章 使用Cython获得C语言性能
  • 第 5 章 探索编译器
  • 第 6 章 实现并发性
  • 第 7 章 并行处理
  • 第 8 章 分布式处理
  • 第 9 章 高性能设计