Python性能调优方法

一 、背景

在开发中,执行某程序时遇见一种现象,随着时间执行越久,执行某个方法的时间在不断变长。为了有效定位是哪个方法,特此去学习了下Python性能分析的方法。

二 、cProfile模块

cProfile是Python的默认的性能分析器,主要用于测量CPU的耗时,不关注内存的消耗。通过cProfile可以分析每一个函数的执行时间和执行次数,进而对函数进行时间上的优化。话不多说,先上代码,如下是基于cProfile写的性能分析装饰器。

定义分析结果的存储路径:

1
2
file_dir = os.path.dirname(os.path.abspath(__file__))
file_base = os.path.join(file_dir, 'analysis')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def time_analysis(filename: t.Optional[str] = None) -> t.Any:
"""
代码执行过程性能分析
将分析结果保存到指定文件
如未指定文件名,则使用原始函数名
"""

sort_by = 'tottime'
time_base = os.path.join(file_base, 'time_analysis')

def wrapper(func):
create_dir(time_base)

@functools.wraps(func)
def inner(*args, **kwargs):
open_profile = os.environ.get('PROFILE')
true_filename = filename or func.__name__
file_path = os.path.join(time_base, true_filename)
if open_profile:
profile = cProfile.Profile()
# 启动分析
profile.enable()
res = func(*args, **kwargs)
# 关闭分析
profile.disable()
# 保存二进制格式到文件中
ps = pstats.Stats(profile).sort_stats(sort_by)
ps.dump_stats(file_path)
else:
res = func(*args, **kwargs)
return res

return inner

return wrapper

函数说明:

​ 上述程序中使用的profile.print_stats()将分析结果打印到控制台,ps.dump_stats(file_path)将分析结果以二进制的形式持久化到文件中。这里我是将每个函数的分析结果都保存对应的文件夹下,当装饰器工厂函数不带参数,则保存的文件名默认为函数名,反之保存的为文件名为传进来的参数filename。

测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
@performance_analysis()
def test():
return 'my name is syz'


try:
if not os.environ.get('PROFILE'):
os.environ['PROFILE'] = 'True'
res = test()
except Exception as e:
print(e)
finally:
os.environ.pop('PROFILE', None)

结果:

1
2
3
4
5
6
7
      2 function 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 performance_analysis.py:64(test)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}

说明:

1.ncalls: 函数调用的次数
2.tottime: 函数的总的运行时间,除掉函数中调用子函数的运行时间
3.percall: (第一个 percall)等于tottime/ncalls
4.cumtime: 函数及其所有子函数的调用运行的时间,即函数开始调用到返回的时间
5.percall: (第二个 percall)即函数运行一次的平均时间,等于 cumtime/ncalls
6.filename: lineno(function):每个函数调用的具体信息

因此要分析哪一个函数耗时,应该关注tottime产生的时间。这样便可以容易地定位到耗时的函数,进行优化。

三 、 memory_profiler

memory_profiler模块用于按行分析python程序执行过程中所增减的内存量,适用于分析出导致内存溢出的源头。

安装模块:pip install memory_profile

1
2
3
4
5
6
7
@profile
def test_func():
a = ['a'] * 1024 * 1024 * 100
b = list(['nihao'])
a = 'a' * 1024 * 1024
del a
test_func()

分析结果:

1
2
3
4
5
6
7
8
Line #    Mem usage    Increment  Occurences   Line Contents
============================================================
33 65.8 MiB 65.8 MiB 1 @profile
34 def test_func():
35 865.8 MiB 800.0 MiB 1 a = ['a'] * 1024 * 1024* 100
36 865.8 MiB 0.0 MiB 1 b = list(['nihao'])
37 66.8 MiB -799.0 MiB 1 a = 'a' * 1024*1024
38 65.8 MiB -1.0 MiB 1 del a

通过结果可以看到,对于同名变量的赋值,不会产生新的内存,而新值会替代旧值,在a指针指向的原内存块上进行覆盖,但是对不同变量,则会在内存中开辟其他内存块。

接下来,对profile装饰器再封装下,提供性能分析开启开关,通过设置环境变量开启,以及分析结果文件归档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def memory_analysis(filename: t.Optional[str] = None) -> t.Any:
"""
每行代码的内存消耗分析
将分析结果保存到指定文件
如未指定文件名,则使用原始函数名
"""
memory_base = os.path.join(file_base, 'memory_analysis')

def wrapper(func):
create_dir(memory_base)

@functools.wraps(func)
def inner(*args, **kwargs):
open_profile = os.environ.get('PROFILE')
true_filename = filename or func.__name__
file_path = os.path.join(memory_base, true_filename)
if open_profile:
# 将分析结果写入文件
with open(file_path, 'w', encoding='utf-8') as f:
res = memory_profiler.profile(func, stream=f)(*args, **kwargs)
else:
res = func(*args, **kwargs)
return res

return inner

return wrapper

四 、读取分析结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def read_analysis_res(filename: str, mode: str) -> None:
"""
读取分析结果
mode: time or memory
"""
assert mode in ['time', 'memory'], "请在time|memory中选择模式"

file_path = os.path.join(os.path.join(file_base, f'{mode}_analysis', filename))
if mode == 'time':
ps = pstats.Stats(file_path)
ps.strip_dirs().sort_stats(-1).print_stats()
else:
with open(file_path, 'r', encoding='utf-8') as f:
sys.stdout.write(f.read())

说明:传入文件名以及分析模式(时间性能or内存开销), 将结果显示在控制台。