Python高性能编程(第2版)
上QQ阅读APP看书,第一时间看更新

2.6 使用模块cProfile

cProfile是标准库内置的一个剖析工具。它通过潜入CPython虚拟机来测量每个函数的执行时间。这引入了更大的开销,但提供的信息也更多。在有些情况下,这些额外的信息可能让你对代码有意外的洞见。

cProfile是标准库内置的两个剖析器之一,另一个是profile。profile是纯Python剖析器,历史更悠久,但速度更慢;cProfile与profile具有相同的接口,并且是用C语言编写的,因此开销更低。如果你对这些库的历史感兴趣,可参阅Armin Rigo于2005年撰写的文章(参见Bitly公司官网cProfile Request页面),该文呼吁将cProfile纳入标准库中。

对剖析来说,一种不错的做法是,在剖析前就代码各部分的速度做出假设。作者喜欢将代码打印出来并进行标注。预先做出假设后,你将发现有些假设非常离谱,进而加深对某些编码风格的认识。

警告:不要因为直觉会出错,就避免在剖析中依靠直觉。剖析前做出假设绝对是值得的,因为它可以帮助你学会如何找出代码中运行缓慢的部分。但是,你必须用证据来证明你的假设。

务必以测量结果为导向,并首先进行粗糙的剖析,确保将劲用对了地方。你可能巧妙地优化了一段代码,但几小时或几天后却发现自己忽视了进程中速度最慢的部分,根本没有解决真正的问题。没有什么比这更令人羞愧的了。

我们先假设函数calculate_z_serial_purepython是速度最慢的代码部分。在这个函数中,我们执行了大量的解引用操作和基本算术运算,还大量地调用了函数abs。这些很可能消耗大量的CPU资源。

这里将使用模块cProfile来运行代码的一个版本。输出虽然简单,但可以帮助我们确定该对哪部分代码做进一步分析。

标志-s cumulative让cProfile根据占用的累计时间对函数进行排序,这让我们知道哪部分代码的运行速度最慢。cProfile的输出被直接显示到屏幕上,它们位于print语句输出的后面:

$ python -m cProfile -s cumulative julia1_nopil.py
...
Length of x: 1000
Total elements: 1000000
calculate_z_serial_purepython took 11.498265266418457 seconds
36221995 function calls in 12.234 seconds
 
  Ordered by: cumulative time
 
  ncalls tottime percall cumtime percall filename:lineno(function)
1   0.000   0.000  12.234  12.234 {built-in method builtins.exec}
       1   0.038   0.038  12.234  12.234 julia1_nopil.py:1(<module>)
       1   0.571   0.571  12.197  12.197 julia1_nopil.py:23
                                          (calc_pure_python)
       1   8.369   8.369  11.498  11.498 julia1_nopil.py:9
                                          (calculate_z_serial_purepython)
34219980   3.129   0.000   3.129   0.000 {built-in method builtins.abs}
 2002000   0.121   0.000   0.121   0.000 {method 'append' of 'list' objects}
1   0.006   0.006   0.006   0.006 {built-in method builtins.sum}
3   0.000   0.000   0.000   0.000 {built-in method builtins.print}
2   0.000   0.000   0.000   0.000 {built-in method time.time}
4   0.000   0.000   0.000   0.000 {built-in method builtins.len}
       1   0.000   0.000   0.000   0.000 {method 'disable' of
                                          '_lsprof.Profiler' objects}
 

通过对累计时间排序,我们能够知道执行时间主要花在什么地方。上述结果表明,在仅仅12 s多点的时间(其中包括cProfile的开销)内,发生了36 221 995次函数调用。而以前,代码的执行时间大约8 s,换句话说,为测量每个函数的执行时间,增加了4 s的开销。

从上述输出可知,代码入口(julia1_nopil.py的第1行)花费的时间为12 s。这行代码位于主函数中,它调用calc_pure_python。ncalls为1,表明这行代码只执行了一次。

在calc_pure_python内部,调用calculate_z_serial_purepython的代码耗费了11 s。函数calc_pure_python和calculate_z_serial_purepython都只调用了一次,由此可以推断,在函数calc_pure_python中,除调用CPU密集型函数calculate_z_serial_ purepython外的其他代码耗费的时间大约为1 s。但是,仅使用cProfile无法推断出这些时间是函数中的哪些代码消耗的。

在函数calculate_z_serial_purepython中,花在非函数调用代码上的时间为8 s。这个函数调用了函数abs 34 219 980次,总共花费了3 s;它还调用了其他一些开销不高的函数。

abs调用的情况如何呢?输出指出了在calculate_z_serial_purepython中每次调用abs花费的时间。虽然每次调用的开销微不足道(显示的结果为0.000 s),但34 219 980次调用花费的总时间为3 s。我们无法预测abs到底将被调用多少次,因为朱利亚函数具有不可预测性,这也是人们对它感兴趣的原因所在。

我们只能说它至少会被调用100万次,因为我们要计算1000像素×1000像素的值;它最多会被调用3亿次,因为我们要计算100万像素的值,而计算每个像素时最多迭代300次。上述输出表明,调用了大约3400万次,这意味着需要迭代300次的像素大约占10%。

如果我们查看灰度图(图2-3),并将其中的白色区域都移到一个角落中,将发现该区域的面积大约占整幅图像其余部分的10%。

剖析输出的下一行{method 'append' of 'list' objects},它指出创建了2 002 000个列表项。

提示:为何是2 002 000个列表项呢?在继续阅读之前,想一想创建了多少个列表项。
 

这2 002 000个列表项的创建工作是在calc_pure_python的设置阶段完成的。

列表zs和cs都包含1000项×1000项(调用次数为1 000 000 × 2),这些元素是由1000个x坐标和1000个y坐标的列表创建的,因此需要追加总共2 002 000次调用。

需要注意的是,cProfile的输出并不是按父函数排序的,它总结了被执行的代码块中所有函数的开销。使用cProfile很难搞明白每行代码的情况,因为它只提供了有关函数调用的剖析信息,而没有提供函数中各行代码的剖析信息。

在函数calculate_z_serial_purepython中,我们可获悉有关函数abs的信息,这个函数总共消耗了大约3.1 s。我们还知道,calculate_z_serial_purepython总共消耗了大约11.4 s。

上述剖析输出的最后一行包含字样lsprof,这是cProfile前身的名称,其占用的时间可忽略不计。

为更好地展现cProfile的结果,可将其写入一个统计文件,再使用Python对这个文件进行分析:

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

可以像下面这样将这个文件载入Python,这将提供与前面一样的累计时间报告:

In [1]: import pstats
In [2]: p = pstats.Stats("profile.stats")
In [3]: p.sort_stats("cumulative")
Out[3]: <pstats.Stats at 0x7f77088edf28>
In [4]: p.print_stats()
Fri Jun 14 17:59:28 2019   profile.stats
 
36221995 function calls in 12.169 seconds
 
   Ordered by: cumulative time
 
   ncalls tottime percall cumtime percall filename:lineno(function)
1   0.000   0.000  12.169  12.169 {built-in method builtins.exec}
        1   0.033   0.033  12.169  12.169 julia1_nopil.py:1(<module>)
        1   0.576   0.576  12.135  12.135 julia1_nopil.py:23
                                           (calc_pure_python)
        1   8.266   8.266  11.429  11.429 julia1_nopil.py:9
                                           (calculate_z_serial_purepython)
34219980   3.163   0.000  3.163    0.000 {built-in method builtins.abs}
  2002000   0.123   0.000  0.123    0.000 {method 'append' of 'list' objects}
1   0.006   0.006  0.006    0.006 {built-in method builtins.sum}
3   0.000   0.000  0.000    0.000 {built-in method builtins.print}
4   0.000   0.000  0.000    0.000 {built-in method builtins.len}
2   0.000   0.000  0.000    0.000 {built-in method time.time}
        1   0.000   0.000  0.000    0.000 {method 'disable' of
                                           '_lsprof.Profiler' objects}

为追踪我们剖析了哪些函数,可将调用者信息打印出来。从下面的输出可知,calculate_z_serial_purepython是开销最高的函数,但只在一个地方调用了它。如果在很多地方都调用了它,这些输出可帮助我们找出开销最高的父函数:

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

也可以反过来,显示哪些函数调用了其他函数:

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

cProfile的输出非常详细,为避免在大量单词中间换行,我们可能需要两个显示器。cProfile是内置的,为你快速找出瓶颈提供了极大的便利。本章后面将介绍line_profiler和memory_profiler,它们可以帮助你深入剖析需要关注的代码行。