目录

时间复杂度

  • 1、使用装饰器
  • 2、使用 timeit 模块测试
  • 3、使用 cProfile 模块
  • 4、使用 line_profile

空间复杂度

  • 1、使用 memory_profile 计算内存使用量

在编程领域中对于性能这个词,有很多评估的角度,比如 CPU 时间、内存消耗、磁盘 I/O、网络带宽等,本文将从 CPU 时间和内存消耗两个方面来介绍如何对 Python 程序进行性能分析。

在很多情况上我们更关注时间复杂度问题,希望降低 CPU 时间消耗,但是在优化代码的过程中,可能会开辟新的内存空间,从而造成内存上的扩增,这也就是常见的以空间换时间的操作。

时间复杂度

1、使用装饰器

from functools import wraps
import time

def timeit(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        res = func(*args, **kwargs)
        end_time = time.time()
        print("{0} spend {1} senconds".format(func.__name__,(end_time-start_time)))
        return res
    return wrapper

@timeit
def search1():
    s_list = ['a', 'b', 'is', 'python', 'jason', 'hello', 'hill', 'with', 'phone', 'test',
            'dfdf', 'apple', 'pddf', 'ind', 'basic', 'none', 'baecr', 'var', 'bana', 'dd', 'wrd']

    # s_list = dict.fromkeys(s_list,True)
    # s_list = set(s_list)
    results = []

    for i in range(1000000):
        for s in ['is','hat','new','list','old','.']:
            if s not in s_list:
                results.append(s)
@timeit
def search2():
    s_list = ['a', 'b', 'is', 'python', 'jason', 'hello', 'hill', 'with', 'phone', 'test',
            'dfdf', 'apple', 'pddf', 'ind', 'basic', 'none', 'baecr', 'var', 'bana', 'dd', 'wrd']

    # s_list = dict.fromkeys(s_list,True)#将列表转换为字典,可以降低查找时耗
    s_list = set(s_list)#集合同理
    results = []

    for i in range(1000000):
        for s in ['is','hat','new','list','old','.']:
            if s not in s_list:
                results.append(s)

if __name__ == '__main__':
    search1()
    search2()

执行输出为:

search1 spend 1.630640983581543 senconds
search2 spend 0.5614957809448242 senconds

使用装饰器是比较方便的做法,不过装饰器也只能打印整个函数的执行时间,测试粒度较粗。在本方法中,为了测试元素在序列中做查找操作所消耗的时间,重复执行 1000000 次操作,为了让时耗显示更加明显。这样处理使得代码繁琐。

2、使用 timeit 模块测试

import timeit

def search1():
    s_list = ['a', 'b', 'is', 'python', 'jason', 'hello', 'hill', 'with', 'phone', 'test',
            'dfdf', 'apple', 'pddf', 'ind', 'basic', 'none', 'baecr', 'var', 'bana', 'dd', 'wrd']

    # s_list = dict.fromkeys(s_list,True)
    # s_list = set(s_list)
    results = []

    for s in ['is','hat','new','list','old','.']:
        if s not in s_list:
            results.append(s)

def search2():
    s_list = ['a', 'b', 'is', 'python', 'jason', 'hello', 'hill', 'with', 'phone', 'test',
            'dfdf', 'apple', 'pddf', 'ind', 'basic', 'none', 'baecr', 'var', 'bana', 'dd', 'wrd']

    # s_list = dict.fromkeys(s_list,True)#将列表转换为字典,可以降低查找时耗
    s_list = set(s_list)#集合同理
    results = []

    for s in ['is','hat','new','list','old','.']:
        if s not in s_list:
            results.append(s)

if __name__ == '__main__':
    tt1 = timeit.repeat("search1()", setup="from __main__ import search1", number=1000000)
    print(min(tt1))

    tt2 = timeit.repeat("search2()", setup="from __main__ import search2", number=1000000)
    print(min(tt2))

执行结果为:

1.6399062780000002
1.222408424000001

关于 timeit 模块的更多用法可以参考 timeit 模块讲解

使用 timeit 模块来测量一段代码能直观地获得执行这段代码的总耗时,还算是比较粗粒度的测量,不过比较 time 模块的使用,方法实现上更加方便,但是我们无法得知其中每行代码的耗时占比。

3、使用 cProfile 模块

from cProfile import Profile

def or_work():
    nums = range(2000)

    for i in range(10000):
        #优化前
        # new_nums = [x for x in nums if 10 < x < 20 or 1000 < x < 2000]
        # 优化后
        new_nums = [x for x in nums if 1000 < x < 2000 or 100 < x < 20]

def and_work():
    nums = range(2000)

    for i in range(10000):
        # 优化前
        # new_nums = [x for x in nums if x % 2 == 0 and x > 1900]
        # 优化后
        new_nums = [x for x in nums if x > 1900 and x % 2 == 0]

def work():
    or_work()
    and_work()

if __name__ == '__main__':
    prof = Profile()
    prof.runcall(work)
    prof.print_stats()

执行结果为:

20004 function calls in 2.076 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.057    0.057    1.377    1.377 and_or.py:12(or_work)
    10000    1.320    0.000    1.320    0.000 and_or.py:19(<listcomp>)
        1    0.006    0.006    0.699    0.699 and_or.py:21(and_work)
    10000    0.694    0.000    0.694    0.000 and_or.py:28(<listcomp>)
        1    0.000    0.000    2.076    2.076 and_or.py:30(work)
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

来看看上面输出结果中每列的含义:

  • 第一行:20004 个函数调用被监控
  • ncalls:函数被调用了总次数
  • tottime:函数执行的总时间(不包括其下的子函数)
  • percall:函数单次执行的时间(不包括其下的子函数),即 tottime/ncalls
  • cumtime:函数执行的总时间(包括其下的子函数)
  • percall:函数单次执行的时间(包括其下的子函数),即 cumtime/ncalls
  • filename:lineno(function):函数的基本信息

代码中的输出非常简单,事实上可以利用 pstat,让 profile 结果的输出多样化,具体可以参见官方文档 python profiler

4、使用 line_profile

首先我们需要安装 line_profile:

pip install line_profiler

如果在安装中遇到什么错误,可以参考 line_profiler模块安装,相信能够解决你所遇到的问题。

网上大部分都是说在所需要测的函数前面加一个@profile,如文档所说。但是加了@profile 后函数无法直接运行,只能优化的时候加上,调试的时候又得去掉。因此并不喜欢这样的测试过程,经查找后,发现有以下两种使用方法。

第一种使用案例:

from line_profiler import LineProfiler
import random

def cal_sum(numbers):
    return sum(numbers)

def do_stuff(numbers):
    cal_sum(numbers)
    length = len(numbers)
    l = [numbers[i] / 43 for i in range(length)]
    m = ['hello' + str(numbers[i]) for i in range(length)]


if __name__ == '__main__':
    numbers = [random.randint(1, 100) for i in range(1000)]

    lp = LineProfiler()
    lp_wrapper = lp(do_stuff)
    lp_wrapper(numbers)
    lp.print_stats()

执行结果为:

Timer unit: 5.14056e-07 s

Total time: 0.0055261 s
File: 
Function: do_stuff at line 16

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    16                                           def do_stuff(numbers):
    17         1        144.0    144.0      1.3      cal_sum(numbers)
    18         1          2.0      2.0      0.0      length = len(numbers)
    19         1       3453.0   3453.0     32.1      l = [numbers[i] / 43 for i in range(length)]
    20         1       7151.0   7151.0     66.5      m = ['hello' + str(numbers[i]) for i in range(length)]

来看看上面输出结果中每列的含义:

  • Total Time:测试代码的总运行时间
  • Line #:代码行号,这里的代码行号和源代码文件中的行号是完全一致的
  • Hits:代码行的执行次数
  • Time:该代码行的总执行时间
  • Per Hit:该代码行每次执行的时间
  • % Time:代码行执行时间占整个程序执行时间的比率
  • Line Contents:具体代码行

第二种使用案例:

from line_profiler import LineProfiler
import random

def cal_sum(numbers):
    return sum(numbers)

def do_stuff(numbers):
    cal_sum(numbers)
    length = len(numbers)
    l = [numbers[i] / 43 for i in range(length)]
    m = ['hello' + str(numbers[i]) for i in range(length)]


if __name__ == '__main__':
    numbers = [random.randint(1, 100) for i in range(10000)]
    prof = LineProfiler(do_stuff)
    prof.enable()
    do_stuff(numbers)
    prof.disable()
    prof.print_stats()

执行结果为:

Timer unit: 5.14056e-07 s

Total time: 0.00563919 s
File: 
Function: do_stuff at line 16

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    16                                           def do_stuff(numbers):
    17         1        200.0    200.0      1.8      cal_sum(numbers)
    18         1          2.0      2.0      0.0      length = len(numbers)
    19         1       4001.0   4001.0     36.5      l = [numbers[i] / 43 for i in range(length)]
    20         1       6767.0   6767.0     61.7      m = ['hello' + str(numbers[i]) for i in range(length)]

在上述两种使用案例中,关于子函数 cal_sum 只显示了执行的总时间,为了能够同时显示子函数中每一行代码的执行情况,加入 add_function 就能够解决。

if __name__ == '__main__':
    numbers = [random.randint(1, 100) for i in range(10000)]
    # prof = LineProfiler(do_stuff)
    # prof.enable()
    # do_stuff(numbers)
    # prof.add_function(cal_sum)
    # prof.disable()
    # prof.print_stats()

    lp = LineProfiler()
    lp_wrapper = lp(do_stuff)
    lp.add_function(cal_sum)
    lp_wrapper(numbers)
    lp.print_stats()

执行结果为:

Timer unit: 5.14056e-07 s

Total time: 6.78553e-05 s
File: 
Function: cal_sum at line 13

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    13                                           def cal_sum(numbers):
    14         1        132.0    132.0    100.0      return sum(numbers)

Total time: 0.00551273 s
File: 
Function: do_stuff at line 16

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    16                                           def do_stuff(numbers):
    17         1        149.0    149.0      1.4      cal_sum(numbers)
    18         1          1.0      1.0      0.0      length = len(numbers)
    19         1       3448.0   3448.0     32.2      l = [numbers[i] / 43 for i in range(length)]
    20         1       7126.0   7126.0     66.4      m = ['hello' + str(numbers[i]) for i in range(length)]

空间复杂度

1、使用 memory_profile 计算内存使用量

from memory_profiler import profile

@profile
def search():
    s_list = ['a', 'b', 'is', 'python', 'jason', 'hello', 'hill', 'with', 'phone', 'test',
            'dfdf', 'apple', 'pddf', 'ind', 'basic', 'none', 'baecr', 'var', 'bana', 'dd', 'wrd']

    # s_list = dict.fromkeys(s_list,True)#将列表转换为字典,可以降低查找时耗
    s_list = set(s_list)#集合同理
    results = []

    for i in range(10000):
        for s in ['is','hat','new','list','old','.']:
            if s not in s_list:
                results.append(s)

if __name__ == '__main__':
    search()

执行结果为:

Line #    Mem usage    Increment   Line Contents
================================================
    12     55.2 MiB     55.2 MiB   @profile
    13                             def search():
    14     55.2 MiB      0.0 MiB       s_list = ['a', 'b', 'is', 'python', 'jason', 'hello', 'hill', 'with', 'phone', 'test',
    15     55.2 MiB      0.0 MiB               'dfdf', 'apple', 'pddf', 'ind', 'basic', 'none', 'baecr', 'var', 'bana', 'dd', 'wrd']
    16                             
    17                                 # s_list = dict.fromkeys(s_list,True)#将列表转换为字典,可以降低查找时耗
    18     55.2 MiB      0.0 MiB       s_list = set(s_list)#集合同理
    19     55.2 MiB      0.0 MiB       results = []
    20                             
    21     56.2 MiB      0.0 MiB       for i in range(10000):
    22     56.2 MiB      0.1 MiB           for s in ['is','hat','new','list','old','.']:
    23     56.2 MiB      0.0 MiB               if s not in s_list:
    24     56.2 MiB      0.3 MiB                   results.append(s)

输出结果中每列的含义:

  • Line #:代码行号,这里的代码行号和源代码文件中的行号是完全一致的
  • Mem usage:当前程序使用的总内存大小
  • Increment:内存增长的大小,即和上一行代码相比,执行本行代码导致的内存增量
  • Line Contents:具体代码行

虽然 memory_profiler 非常简单易用。但是要想每行进行性能分析而不是每个对象。当遇到循环时,不可以标识循环迭代次数,只显示所有迭代的最大值。

所以目前针对代码内存计算还没有好的方法,像 HeapyPySizer 都不再适用于 Python3.x 了,objgraph 库可以解决内存泄漏问题,但是并不是想要的结果,tracemalloc 库已经是 Python 标准库的一部分,但是它所显示的是每个文件的内存消耗量。

总结

在代码优化这一方面,时间复杂度的优化通过上述几种方法都可以判断,空间的话根据个人经验来说,就是少开辟新的对象,加上 Python 作为动态解释性编程语言,对象的类型和内存都是在运行时确定的,所以少声明新的对象可以减少空间消耗。