Python性能调优

Python性能分析与优化 阅读笔记

性能分析软件有两类方法论:基于事件的性能分析(event-based profiling)和统计式性能分析(statistical profiling)。

  • 基于事件的性能分析器(event-based profiler,也称为轨迹性能分析器,tracing profiler)是通过收集程序执行过程中的具体事件进行工作的。这些性能分析器会产生大量的数据。基本上,它们需要监听的事件越多,产生的数据量就越大。
  • 统计式性能分析器以固定的时间间隔对程序计数器(program counter)进行抽样统计。这样做可以让开发者掌握目标程序在每个函数上消耗的时间。由于它对程序计数器进行抽样,所以数据结果是对真实值的统计近似。

优化通常被认为是一个好习惯。但是,如果违背了软件的设计原则就不好了。在开始开发一个新软件时,经常犯的错误就是过早优化(permature optimization)。如果进行了过早优化,结果可能会和原来的代码截然不同。它可能只是完整解决方案的一部分,还可能包含因优化驱动的设计决策而导致的错误。一条经验法则是,如果还没有对代码做过性能分析,优化往往不是个好主意。首先,应该集中精力完成代码,然后通过性能分析发现真正的瓶颈,最后对代码进行优化。

fib.py是一段未经任何优化的求斐波那契数列的代码(请勿吐槽这段代码,因为它实在是很糟糕),下文将围绕这段代码进行探讨

# -*-coding:UTF-8 -*-
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

fib_seq(20)

cProfile

cProfile 是一种基于事件的性能分析器,它只测量CPU时间,并不关心内存消耗和其他与内存相关的信息统计。
在终端中我们可以使用

python -m cProfile fib.py -o fib.prof

来使用 cProfile 进行性能分析,结果如下

         57355 function calls (65 primitive calls) in 0.012 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.012    0.012 fib.py:2(<module>)
 57291/21    0.012    0.000    0.012    0.001 fib.py:2(fib)
     21/1    0.000    0.000    0.012    0.012 fib.py:9(fib_seq)
       21    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}
       20    0.000    0.000    0.000    0.000 {method 'extend' of 'list' objects}

从这个结果中可以收集到如下信息: 第一行告诉我们一共有57355个函数调用被监控,其中65个是原生(primitive)调用,这些调用不涉及递归。
ncalls 表示函数调用的次数。如果在这一列中有两个数值,就表示有递归调用。第二个数值是原生调用的次数,第一个数值是总调用次数。这个数值可以帮助识别潜在的bug (当性能分析器数值大得异乎寻常时可能就是bug),或者可能需要进行内联函数扩展(inline expansion)的位置。
tottime 是函数内部消耗的总时间(不包括调用其他函数的时间)。这列信息可以帮助找到可以进行优化的、运行时间较长的循环。
第一个percall 是 tottime 除以 ncalls ,表示一个函数每次调用的平均消耗时间。另一个 percall 是用 cumtime 除以原生调用的数量,表示到该函数调用时,每个原生调用的平均消耗时间。
cumtime 是之前所有子函数消耗时间的累计和(也包含递归调用时间)。这个数值可以帮助开发者从整体视角识别性能问题,比如算法选择错误。
filename:lineno(function) 显示了被分析函数所在的文件名、行号、函数名。

我们也可以通过 cProfile 提供的方法进行分析。

# -*-coding:UTF-8 -*-
import cProfile


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

prof = cProfile.Profile()
prof.enable()
fib_seq(20)
prof.create_stats()
prof.dump_stats('fib.prof')
prof.print_stats()
  • enable() :表示开始收集性能分析数据。
  • disable() :表示停止收集性能分析数据。
  • create_stats() :表示停止收集数据,并为已收集的数据创建 stats 对象。
  • print_stats(sort=-1) :打印分析结果。
  • dump_stats(filename) :把当前性能分析的内容写进一个文件。
  • run(cmd) :用来收集命令执行的性能统计信息
  • runctx(cmd, globals, locals) :收集命令执行的性能统计信息,支持两个字典参数: globals 和 locals
  • runcall(func, args, *kwargs) :收集被调用函数 func 的性能分析信息。

pstats 模块提供了Stats类,可以读取和操作stats文件(Stats类的构造器可以接收 cProfile.Profile 类型的参数,可以不用文件名称作为数据源)

import pstats
p = pstats.Stats('fib.prof')
p.print_stats()
p.print_callers()

print_callers会显示程序执行过程中调用的每个函数的调用次数、总时间和累计时间,以及文件名、行号和函数名的组合。

         57355 function calls (65 primitive calls) in 0.016 seconds

   Random listing order was used

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       21    0.000    0.000    0.000    0.000 {method 'append' of 'list' objects}
     21/1    0.000    0.000    0.016    0.016 fib.py:11(fib_seq)
       20    0.000    0.000    0.000    0.000 {method 'extend' 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 /usr/lib/python2.7/cProfile.py:90(create_stats)
 57291/21    0.016    0.000    0.016    0.001 fib.py:4(fib)


   Random listing order was used

Function                                          was called by...
                                                      ncalls  tottime  cumtime
{method 'append' of 'list' objects}               <-      21    0.000    0.000  fib.py:11(fib_seq)
fib.py:11(fib_seq)                                <-    20/1    0.000    0.011  fib.py:11(fib_seq)
{method 'extend' of 'list' objects}               <-      20    0.000    0.000  fib.py:11(fib_seq)
{method 'disable' of '_lsprof.Profiler' objects}  <-       1    0.000    0.000  /usr/lib/python2.7/cProfile.py:90(create_stats)
/usr/lib/python2.7/cProfile.py:90(create_stats)   <-
fib.py:4(fib)                                     <- 57270/38    0.016    0.016  fib.py:4(fib)
                                                          21    0.000    0.016  fib.py:11(fib_seq)

这个结果表示左边的函数是被右边的函数调用的

line_profiler

这个性能分析器和 cProfile 不同,它可以一行一行地分析函数性能。安装 line_profiler 时也会安装 kernprof 。kernprof 会创建一个性能分析器实例,并把名字动态添加到 __builtins__ 命名空间的 profile 中,所以只要在需要测试的函数加上@profile,不用import任何东西, 这里笔者使用 pip 安装一直出现NameError: name 'profile' is not defined, 通过github安装则未出现这种问题

fib.py

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


fib(10)

运行

kernprof -l -v fib.py

-l表示动态插入profile -v 当程序运行结束后,将结果输出到标准输出,而不是到文件中 输出

Wrote profile results to fib.py.lprof
Timer unit: 1e-06 s

Total time: 0.000158 s
File: fib.py
Function: fib at line 2

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     2                                           @profile
     3                                           def fib(n):
     4       177           51      0.3     32.3      if n <= 1:
     5        89           24      0.3     15.2          return n
     6                                               else:
     7        88           83      0.9     52.5          return fib(n - 1) + fib(n - 2)

Line # :表示文件中的行号。
Hits :性能分析时一行代码的执行次数。
Time :一行代码执行的总时间,由计时器的单位决定。在分析结果的最开始有一行 Timer
unit ,该数值就是转换成秒的计时单位(要计算总时间,需要用 Time 数值乘以计时单位)。不同系统的计时单位可能不同。
Per hit :执行一行代码的平均消耗时间,依然由系统的计时单位决定。
% Time :执行一行代码的时间消耗占程序总消耗时间的比例。

memory_profiler

pip install memory_profiler  
pip install psutil  

运行

python -m memory_profiler fib.py

输出

Filename: fib.py

Line #    Mem usage    Increment   Line Contents
================================================
     2   20.738 MiB    0.000 MiB   @profile
     3                             def fib(n):
     4   20.738 MiB    0.000 MiB       if n <= 1:
     5   20.738 MiB    0.000 MiB           return n
     6                                 else:
     7   20.738 MiB    0.000 MiB           return fib(n - 1) + fib(n - 2)

优化

这里以fib.py为例进行优化,性能分析结果如下

         57355 function calls (65 primitive calls) in 0.012 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.012    0.012 fib.py:2(<module>)
 57291/21    0.012    0.000    0.012    0.001 fib.py:2(fib)
     21/1    0.000    0.000    0.012    0.012 fib.py:9(fib_seq)
       21    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}
       20    0.000    0.000    0.000    0.000 {method 'extend' of 'list' objects}

在0.012秒内, 共有57355个函数调用,其中只有65个原生调用
fib()有57291次递归调用,fib_seq()有21次递归调用 由图中可见,大部分时间都消耗在fib()的递归中了

step1

现在, 让我们给fib()加一个可以缓存之前计算的值的装饰器,缓存之前计算的值,这样我们可以省去不必要的重复计算。

from functools import wraps


def memoize(function):
    memo = {}

    @wraps(function)
    def wrapper(*args):
        if args in memo:
            return memo[args]
        else:
            rv = function(*args)
            memo[args] = rv
            return rv
    return wrapper


@memoize
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

fib_seq(20)

性能分析结果

         156 function calls (98 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 fib.py:1(<module>)
       21    0.000    0.000    0.000    0.000 fib.py:18(fib)
     21/1    0.000    0.000    0.000    0.000 fib.py:26(fib_seq)
        1    0.000    0.000    0.000    0.000 fib.py:4(memoize)
    59/21    0.000    0.000    0.000    0.000 fib.py:7(wrapper)
        1    0.000    0.000    0.000    0.000 functools.py:17(update_wrapper)
        1    0.000    0.000    0.000    0.000 functools.py:39(wraps)
        5    0.000    0.000    0.000    0.000 {getattr}
       21    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}
       20    0.000    0.000    0.000    0.000 {method 'extend' of 'list' objects}
        1    0.000    0.000    0.000    0.000 {method 'update' of 'dict' objects}
        3    0.000    0.000    0.000    0.000 {setattr}

程序的函数调用次数下降到了156,递归调用明显减少了。运行时间也下降到了0.000秒(应该是低于测量精度了)。但当我们尝试求更多位的斐波那契数列时,会发生栈溢出。

step2

由于 cPython 为了防止栈溢出,设置了递归保护措施,笔者执行fib_seq(989)时就会发生RuntimeError: maximum recursion depth exceeded
理论上如果我们使用尾递归的话就不会出现这种问题了(尾递归等价迭代),但是 cPython 并不会对尾递归进行优化 这里有一种Hack的做法 参考自谁说Python不能尾递归优化

from functools import wraps
import types


def memoize(function):
    memo = {}

    @wraps(function)
    def wrapper(*args):
        if args in memo:
            return memo[args]
        else:
            rv = function(*args)
            memo[args] = rv
            return rv
    return wrapper


def fib(count, cur=0, next_=1):
    if count < 1:
        yield cur
    else:
        yield fib(count - 1, next_, cur + next_)


def tramp(gen, *args, **kwargs):
    g = gen(*args, **kwargs)
    while isinstance(g, types.GeneratorType):
        g = g.next()
    return g


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

fib_seq(988)

性能分析结果

         1471636 function calls in 0.478 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.478    0.478 fib.py:1(<module>)
   979110    0.141    0.000    0.141    0.000 fib.py:19(fib)
      989    0.305    0.000    0.477    0.000 fib.py:26(tramp)
        1    0.000    0.000    0.478    0.478 fib.py:33(fib_seq)
   490544    0.030    0.000    0.030    0.000 {isinstance}
      989    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 {range}

但是我们未进行step2优化的 fib_seq(988) 结果为

         6932 function calls (3970 primitive calls) in 0.005 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.005    0.005 fib.py:1(<module>)
      989    0.001    0.000    0.001    0.000 fib.py:18(fib)
    989/1    0.003    0.000    0.005    0.005 fib.py:26(fib_seq)
        1    0.000    0.000    0.000    0.000 fib.py:4(memoize)
 2963/989    0.001    0.000    0.001    0.000 fib.py:7(wrapper)
        1    0.000    0.000    0.000    0.000 functools.py:17(update_wrapper)
        1    0.000    0.000    0.000    0.000 functools.py:39(wraps)
        5    0.000    0.000    0.000    0.000 {getattr}
      989    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}
      988    0.001    0.000    0.001    0.000 {method 'extend' of 'list' objects}
        1    0.000    0.000    0.000    0.000 {method 'update' of 'dict' objects}
        3    0.000    0.000    0.000    0.000 {setattr}

经过对比我们发现虽然我们避免了栈溢出,但是反而变慢了几个数量级

step3

既然尾递归等价于迭代,我们直接使用迭代就好了

from functools import wraps


def memoize(function):
    memo = {}

    @wraps(function)
    def wrapper(*args):
        if args in memo:
            return memo[args]
        else:
            rv = function(*args)
            memo[args] = rv
            return rv
    return wrapper


@memoize
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

fib_seq(988)

性能分析结果

         3972 function calls in 0.037 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.037    0.037 fib.py:1(<module>)
      989    0.034    0.000    0.036    0.000 fib.py:18(fib)
        1    0.000    0.000    0.037    0.037 fib.py:26(fib_seq)
        1    0.000    0.000    0.000    0.000 fib.py:4(memoize)
      989    0.001    0.000    0.037    0.000 fib.py:7(wrapper)
        1    0.000    0.000    0.000    0.000 functools.py:17(update_wrapper)
        1    0.000    0.000    0.000    0.000 functools.py:39(wraps)
        5    0.000    0.000    0.000    0.000 {getattr}
      989    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 'update' of 'dict' objects}
      990    0.002    0.000    0.002    0.000 {range}
        3    0.000    0.000    0.000    0.000 {setattr}

现在我们的代码比之前快了不少,是较理想的选择