>>> from env_helper import info; info()
页面更新时间: 2024-04-08 07:54:17
运行环境:
    Linux发行版本: Debian GNU/Linux 12 (bookworm)
    操作系统内核: Linux-6.1.0-18-amd64-x86_64-with-glibc2.36
    Python版本: 3.11.2

14.9. 性能测算和计时

在开发阶段以及创建数据处理任务流时,经常都会出现多种可能的实现方案,每种都有各自优缺点,你需要在这之中进行权衡。在开发你的算法的早期阶段,过于关注性能很可能会影响你的实现效率。正如高德纳(译者注:Donald Knuth,《计算机程序设计艺术》作者,最年轻的ACM图灵奖获得者,计算机算法泰山北斗)的名言:“我们应该忘掉那些小的效率问题,在绝大部分情况下:过早的优化是所有罪恶之源。”

但是,一旦你的代码已经开始工作了,那么你就应该开始深入的考虑一下性能问题了。有时你会需要检查一行代码或者一系列代码的执行时间;有时你又需要对多个线程进行研究,找到一系列复杂操作当中的瓶颈所在。IPython提供了这类计时或性能测算的丰富功能。本章节中我们会讨论下述的IPython魔术指令:

  • %time: 测量单条语句的执行时间

  • %timeit: 对单条语句进行多次重复执行,并测量平均执行时间,以获得更加准确的结果

  • %prun: 执行代码,并使用性能测算工具进行测算

  • %lprun: 执行代码,并使用单条语句性能测算工具进行测算

  • %memit: 测量单条语句的内存占用情况

  • %mprun: 执行代码,并使用单条语句内存测算工具进行测算

后面四个指令并不是随着IPython一起安装的,你需要去获取安装line_profilermemory_profiler扩展,我们会在下面小节中介绍。

14.9.1. 代码计时工具:%timeit%time

我们在IPython魔术命令中已经介绍过%timeit行魔术指令和%%timeit块魔术指令;它们用来对于代码(块)进行重复执行,并测量执行时间:

>>> %timeit sum(range(100))
674 ns ± 4.14 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

这里说明一下,因为这个操作是非常快速的,因此%timeit自动做了很多次的重复执行。如果换成一个执行慢的操作,%timeit会自动调整(减少)重复次数。

>>> %%timeit
>>> total = 0
>>> for i in range(1000):
>>>     for j in range(1000):
>>>         total += i * (-1) ** j
141 ms ± 511 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

值得注意的是,有些情况下,重复多次执行反而会得出一个错误的测量数据。例如,我们有一个列表,希望对它进行排序,重复执行的结果会明显的误导我们。因为对一个已经排好序的列表执行排序是非常快的,因此在第一次执行完成之后,后面重复进行排序的测量数据都是错误的:

>>> import random
>>> L = [random.random() for i in range(100000)]
>>> %timeit L.sort()
725 µs ± 5.1 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

在这种情况下,%time魔术指令可能会是一个更好的选择。对于一个执行时间较长的操作来说,它也更加适用,因为与系统相关的那些持续时间很短的延迟将不会对结果产生什么影响。让我们对一个未排序和一个已排序的列表进行排序,并观察执行时间:

>>> import random
>>> L = [random.random() for i in range(100000)]
>>> print("sorting an unsorted list:")
>>> %time L.sort()
sorting an unsorted list:
CPU times: user 20.5 ms, sys: 4 µs, total: 20.5 ms
Wall time: 20.5 ms
>>> print("sorting an already sorted list:")
>>> %time L.sort()
sorting an already sorted list:
CPU times: user 735 µs, sys: 4 µs, total: 739 µs
Wall time: 747 µs

你应该首先注意到的是对于未排序的列表和对于已排序的列表进行排序的执行时间差别(译者注:在我的笔记本上,接近5倍的时间)。而且你还需要了解%time%timeit执行的区别,即使都是使用已经排好序的列表的情况下。这是因为%timeit会使用一种额外的机制来防止系统调用影响计时的结果。例如,它会阻止Python解析器清理不再使用的对象(也被称为垃圾收集),否则垃圾收集会影响计时的结果。因此,我们认为通常情况下%timeit的结果都会比%time的结果要快。

对于%time%timeit指令,使用两个百分号可以对一段代码进行计时:

>>> %%time
>>> total = 0
>>> for i in range(1000):
>>>     for j in range(1000):
>>>         total += i * (-1) ** j
CPU times: user 213 ms, sys: 5 µs, total: 213 ms
Wall time: 212 ms

更多关于%time%timeit的资料,包括它们的选项,可以使用IPython的帮助功能(如在IPython提示符下键入%time?)进行查看。

14.9.2. 脚本代码块性能测算:%prun

一个程序都是有很多条代码组成的,有的时候对整段代码块性能进行测算比对每条代码进行计时要更加重要。Python自带一个內建的代码性能测算工具(你可以在Python文档中找到它),而IPython提供了一个更加简便的方式来使用这个测算工具,使用%prun魔术指令。

我们定义一个简单的函数作为例子:

>>> def sum_of_lists(N):
>>>     total = 0
>>>     for i in range(5):
>>>         L = [j ^ (j >> i) for j in range(N)]
>>>         total += sum(L)
>>>     return total

然后我们就可以使用%prun来调用这个函数,并查看测算的结果:

>>> %prun sum_of_lists(1000000)
      14 function calls in 0.679 seconds

Ordered by: internal time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     5    0.608    0.122    0.608    0.122 3519952779.py:4(<listcomp>)
     1    0.033    0.033    0.669    0.669 3519952779.py:1(sum_of_lists)
     5    0.028    0.006    0.028    0.006 {built-in method builtins.sum}
     1    0.010    0.010    0.679    0.679 <string>:1(<module>)
     1    0.000    0.000    0.679    0.679 {built-in method builtins.exec}
     1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
14 function calls in 0.714 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        5    0.599    0.120    0.599    0.120 <ipython-input-19>:4(<listcomp>)
        5    0.064    0.013    0.064    0.013 {built-in method sum}
        1    0.036    0.036    0.699    0.699 <ipython-input-19>:1(sum_of_lists)
        1    0.014    0.014    0.714    0.714 <string>:1(<module>)
        1    0.000    0.000    0.714    0.714 {built-in method exec}

在译者的笔记本上,这个指令的结果输出如下:

14 function calls in 0.500 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        5    0.440    0.088    0.440    0.088 <ipython-input-8-f105717832a2>:4(<listcomp>)
        5    0.027    0.005    0.027    0.005 {built-in method builtins.sum}
        1    0.025    0.025    0.492    0.492 <ipython-input-8-f105717832a2>:1(sum_of_lists)
        1    0.008    0.008    0.500    0.500 <string>:1(<module>)
        1    0.000    0.000    0.500    0.500 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

这个结果的表格,使用的是每个函数调用执行总时间进行排序(从大到小)。从上面的结果可以看出,绝大部分的执行时间都发生在函数sum_of_lists中的列表解析之上。然后,我们就可以知道如果需要优化这段代码的性能,可以从哪个方面开始着手了。

更多关于%prun的资料,包括它的选项,可以使用IPython的帮助功能(在IPython提示符下键入%prun?)进行查看。