性能分析工具的使用

cProfile

  1. 介绍
它是一种确定性的性能分析器,提供了一组API帮助开 发者收集Python程序运行的信息,更确切地说,是统计每个函数消耗的 CPU时间。同时它还提供了其他细节,比如函数被调用的次数。 cProfile只测量CPU时间,并不关心内存消耗和其他与内存相关的信 息统计。尽管如此,它是代码优化过程中一个很不错的起点,因为大多 数时候,这个分析工具都会快速地为我们提供一组优化方案。 cProfile不需要安装,因为它是语言自带的一部分。要使用它,直接 导入cProfile包即可。
  1. 例子
  • 简单调用
import cProfile
import re

cProfile.run('re.compile("foo|bar")')
  • 调用函数
def test():
	pass
cProfile.run('test()')
  • 命令行使用
# 直接把分析结果打印到控制台
python -m cProfile test.py
# 把分析结果保存到文件中
python -m cProfile -o result.out test.py
# 增加排序方式
python -m cProfile -o result.out -s cumulative test.py
  • 当命令被调用时,会执行下面这个函数:
exec(command, __main__.__dict__, __main__.__dict__)

故这种写法会报错

import cProfile
def runRe():
    import re
    cProfile.run('re.compile("foo|bar")') #改为它即可正常运行cProfile.runctx('re.compile("foo|bar")', None, locals())
runRe()
  1. Stats
  • 利用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_callers()
  • 用来分析cProfile输出文件xxx.out
import pstats

# 创建Stats对象
p = pstats.Stats("result.out")

# strip_dirs(): 去掉无关的路径信息
# sort_stats(): 排序,支持的方式和上述的一致
# print_stats(): 打印分析结果,可以指定打印前几行

# 和直接运行cProfile.run("test()")的结果是一样的
p.strip_dirs().sort_stats(-1).print_stats()

# 按照函数名排序,只打印前3行函数的信息, 参数还可为小数,表示前百分之几的函数信息 
p.strip_dirs().sort_stats("name").print_stats(3)

# 按照运行时间和函数名进行排序
p.strip_dirs().sort_stats("cumulative", "name").print_stats(0.5)

# 如果想知道有哪些函数调用了sum_num
p.print_callers(0.5, "sum_num")

# 查看test()函数中调用了哪些函数
p.print_callees("test")
  1. cProfile其它的方法
  • enable():表示开始收集性能分析数据。
  • disable():表示停止收集性能分析数据。
  • create_stats():表示停止收集数据,并为已收集的数据创建

stats对象。

  • print_stats(sort=-1):创建一个stats对象,打印分析结果。
  • dump_stats(filename):把当前性能分析的内容写进一个文件。
  • run(cmd):和之前介绍过的run函数相同。
  • runctx(cmd, globals, locals):和之前介绍过的runctx函数

相同。

  • runcall(func, *args, **kwargs):收集被调用函数func的性

能分析信息

  1. 运行结果如下(为以上简单调用的结果)
214 function calls (207 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>)
        2    0.000    0.000    0.000    0.000 enum.py:283(__call__)
        2    0.000    0.000    0.000    0.000 enum.py:525(__new__)
        1    0.000    0.000    0.000    0.000 enum.py:815(__and__)
        1    0.000    0.000    0.000    0.000 re.py:232(compile)
        1    0.000    0.000    0.000    0.000 re.py:271(_compile)
        1    0.000    0.000    0.000    0.000 sre_compile.py:249(_compile_charset)
        1    0.000    0.000    0.000    0.000 sre_compile.py:276(_optimize_charset)
        2    0.000    0.000    0.000    0.000 sre_compile.py:453(_get_iscased)
        1    0.000    0.000    0.000    0.000 sre_compile.py:461(_get_literal_prefix)
        1    0.000    0.000    0.000    0.000 sre_compile.py:492(_get_charset_prefix)
        1    0.000    0.000    0.000    0.000 sre_compile.py:536(_compile_info)
        2    0.000    0.000    0.000    0.000 sre_compile.py:595(isstring)
        1    0.000    0.000    0.000    0.000 sre_compile.py:598(_code)
      3/1    0.000    0.000    0.000    0.000 sre_compile.py:71(_compile)
        1    0.000    0.000    0.000    0.000 sre_compile.py:759(compile)
        3    0.000    0.000    0.000    0.000 sre_parse.py:111(__init__)
        7    0.000    0.000    0.000    0.000 sre_parse.py:160(__len__)
       18    0.000    0.000    0.000    0.000 sre_parse.py:164(__getitem__)
        7    0.000    0.000    0.000    0.000 sre_parse.py:172(append)
      3/1    0.000    0.000    0.000    0.000 sre_parse.py:174(getwidth)
        1    0.000    0.000    0.000    0.000 sre_parse.py:224(__init__)
        8    0.000    0.000    0.000    0.000 sre_parse.py:233(__next)
        2    0.000    0.000    0.000    0.000 sre_parse.py:249(match)
        6    0.000    0.000    0.000    0.000 sre_parse.py:254(get)
        1    0.000    0.000    0.000    0.000 sre_parse.py:286(tell)
        1    0.000    0.000    0.000    0.000 sre_parse.py:417(_parse_sub)
        2    0.000    0.000    0.000    0.000 sre_parse.py:475(_parse)
        1    0.000    0.000    0.000    0.000 sre_parse.py:76(__init__)
        2    0.000    0.000    0.000    0.000 sre_parse.py:81(groups)
        1    0.000    0.000    0.000    0.000 sre_parse.py:903(fix_flags)
        1    0.000    0.000    0.000    0.000 sre_parse.py:919(parse)
        1    0.000    0.000    0.000    0.000 {built-in method _sre.compile}
        1    0.000    0.000    0.000    0.000 {built-in method builtins.exec}
       25    0.000    0.000    0.000    0.000 {built-in method builtins.isinstance}
    29/26    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 builtins.max}
        9    0.000    0.000    0.000    0.000 {built-in method builtins.min}
        6    0.000    0.000    0.000    0.000 {built-in method builtins.ord}
       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}
        5    0.000    0.000    0.000    0.000 {method 'find' of 'bytearray' objects}
        1    0.000    0.000    0.000    0.000 {method 'items' of 'dict' objects}
  • 第一行告诉我们一共有214个函数调用被监控,其中207个是原生 (primitive)调用,表明这些调用不涉及递归。
  • ncalls表示函数调用的次数。如果在这一列中有两个数值,就表 示有递归调用。第二个数值是原生调用的次数,第一个数值是总调 用次数。这个数值可以帮助识别潜在的bug(当数值大得异乎寻常 时可能就是bug),或者可能需要进行内联函数扩展(inline expansion)的位置。
  • tottime是函数内部消耗的总时间(不包括调用其他函数的时 间)。这列信息可以帮助开发者找到可以进行优化的、运行时间较 长的循环。
  • percall是tottime除以ncalls,表示一个函数每次调用的平均消 耗时间。
  • cumtime是之前所有子函数消耗时间的累计和(也包含递归调用时 间)。这个数值可以帮助开发者从整体视角识别性能问题,比如算 法选择错误。
  • 另一个percall是用cumtime除以原生调用的数量,表示到该函数 调用时,每个原生调用的平均消耗时间。
  • filename:lineno(function)显示了被分析函数所在的文件名、 行号、函数名。
  1. cProfile工具的局限
不存在透明的性能分析器。也就是说,即使像cProfile这样消耗极小 的性能分析器,仍然会对代码实际的性能造成影响。当一个事件被触发 时,事件实际发生的时间相比性能分析器查询到的系统内部时钟的时间,还是会有一些延迟。另外,当程序计数器离开性能分析器代码,回 到用户代码中继续执行时,程序也会出现滞后。 除了这些之外,作为计算机内部的任何一段代码,内部时钟都有一个精 度范围,任何小于这个精度的测量值都会被忽略。也就是说,如果进行 性能分析的代码含有许多递归调用,或者一个函数需要调用许多函数, 开发者就应该对这个函数做特殊处理,因为测量误差不断地累计最终会 变得非常显著。

Line Profiler

  1. 不为python sdk自带,需要手动下载
pip install line_profiler
  1. 我们通过cProfile定位到了具体的耗时函数,下面就需要具体定位瓶颈出在哪行代码,这个时候就到了Line Profiler出场的时候了。与cProfile相比,Line Profiler的结果更加直观,它可以告诉你一个函数中每一行的耗时。
  2. 用法
  • 定义一个测试文件test.py
# demo.py
@profile
def foo():
    task = []
    for a in range(0, 101):
        for b in range(0, 101):
            if a + b == 100:
                task.append((a, b))
    return task
 
@profile
def run():
    for item in foo():
        pass
 
if __name__ == '__main__':
    run()
  • 运行
kernprof -l -v test.py #-l逐行分析
  • 结果
Wrote profile results to test.py.lprof
Timer unit: 1e-06 s

Total time: 0.0051146 s
File: test.py
Function: foo at line 1

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     1                                           @profile
     2                                           def foo():
     3         1          0.4      0.4      0.0      task = []
     4       102         22.6      0.2      0.4      for a in range(0, 101):
     5     10302       2317.9      0.2     45.3          for b in range(0, 101):
     6     10201       2738.2      0.3     53.5              if a + b == 100:
     7       101         35.2      0.3      0.7                  task.append((a, b))
     8         1          0.3      0.3      0.0      return task

Total time: 0.0089912 s
File: test.py
Function: run at line 11

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    11                                           @profile
    12                                           def run():
    13       102       8970.8     87.9     99.8      for item in foo():
    14       101         20.4      0.2      0.2          pass

Timer unit: 1e-06 s:时间单位;
Total time: 0.004891 s:总时间;
Hit:代码运行次数;
%Time:代码占了它所在函数的消耗的时间百分比,通常直接看这一列。

memory_profile

  • 下载
pip3 install memory_profiler
  • 测试代码
def sum_num(max_num):
    total = 0
    for i in range(max_num):
        total += i
    return total


@profile
def test():
    total = 0
    for i in range(40000):
        total += i

    t1 = sum_num(10000000)
    t2 = sum_num(200000)
    t3 = sum_num(300000)
    t4 = sum_num(400000)
    t5 = sum_num(500000)
    test2()

    return total


def test2():
    total = 0
    for i in range(40000):
        total += i

    t6 = sum_num(600000)
    t7 = sum_num(700000)

    return total


test()

python -m memory_profiler test.py

  • 运行结果
Filename: test.py

Line #    Mem usage    Increment  Occurences   Line Contents
============================================================
     8   39.660 MiB   39.660 MiB           1   @profile
     9                                         def test():
    10   39.660 MiB    0.000 MiB           1       total = 0
    11   39.660 MiB    0.000 MiB       40001       for i in range(40000):
    12   39.660 MiB    0.000 MiB       40000           total += i
    13
    14   39.660 MiB    0.000 MiB           1       t1 = sum_num(10000000)
    15   39.660 MiB    0.000 MiB           1       t2 = sum_num(200000)
    16   39.660 MiB    0.000 MiB           1       t3 = sum_num(300000)
    17   39.660 MiB    0.000 MiB           1       t4 = sum_num(400000)
    18   39.660 MiB    0.000 MiB           1       t5 = sum_num(500000)
    19   39.660 MiB    0.000 MiB           1       test2()
    20
    21   39.660 MiB    0.000 MiB           1       return total

Mem usage : 运行内存大小;
Increment : 运行当前代码后,增加的内存。

装饰器捕获函数运行时间

  • 模板
import time
from functools import wraps
def simple_profiling(fn):
    @wraps(fn)  # 对外暴露调用装饰器函数的函数名和docstring
    def wrapped(*args, **kwargs):
        t1 = time.time()
        result = fn(*args, **kwargs)
        t2 = time.time()
        print (
            "@simple_profiling:" + fn.func_name + " took " + str(t2 - t1) + " seconds")
        return result
    return wrapped
@simple_profiling
def foo(a, b, c)
    pass
  • 优缺点总结
    这个方法的优点是简单,额外开效非常低(大部分情况下可以忽略不计)。但缺点也很明显,除了总用时,没有任何其他信息。

timeit

  • 该模块为python自带
  • 此模块提供了一种简单的方法来计算一小段 Python 代码的耗时。 它有 命令行界面 以及一个 可调用 方法。 它避免了许多测量时间的常见陷阱。