目录
时间复杂度
- 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 非常简单易用。但是要想每行进行性能分析而不是每个对象。当遇到循环时,不可以标识循环迭代次数,只显示所有迭代的最大值。
所以目前针对代码内存计算还没有好的方法,像 Heapy 和 PySizer 都不再适用于 Python3.x 了,objgraph 库可以解决内存泄漏问题,但是并不是想要的结果,tracemalloc 库已经是 Python 标准库的一部分,但是它所显示的是每个文件的内存消耗量。
总结
在代码优化这一方面,时间复杂度的优化通过上述几种方法都可以判断,空间的话根据个人经验来说,就是少开辟新的对象,加上 Python 作为动态解释性编程语言,对象的类型和内存都是在运行时确定的,所以少声明新的对象可以减少空间消耗。