预计阅读本页时间:-
10.2 性能分析
Python提供了一些工具对程序进行性能分析。标准的工具之一就是cProfile,而且它很容易使用,如示例10.1所示。
示例 10.1 使用cProfile模块
广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元
$ python -m cProfile myscript.py
343 function calls (342 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 :0(_getframe)
1 0.000 0.000 0.000 0.000 :0(len)
104 0.000 0.000 0.000 0.000 :0(setattr)
1 0.000 0.000 0.000 0.000 :0(setprofile)
1 0.000 0.000 0.000 0.000 :0(startswith)
2/1 0.000 0.000 0.000 0.000 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 StringIO.py:30(<module>)
1 0.000 0.000 0.000 0.000 StringIO.py:42(StringIO)
运行结果的列表显示了每个函数的调用次数,以及执行所花费的时间。可以使用-s选项按其他字段进行排序,例如,-s time可以按内部时间进行排序。
如果你像我一样使用C语言很多年,那你很可能已经知道Valgrind(http://valgrind.org/)这个优秀的工具,除了其他功能之外,它能够提供对C程序的性能分析数据。生成的数据能够被另一个不错的工具KCacheGrind(http://kcachegrind.sourceforge.net/html/Home.html)可视化地展示。
cProfile生成的性能分析数据很容易转换成一个可以被KCacheGrind读取的调用树。cProfile模块有一个-o选项允许保存性能分析数据,并且pyprof2calltree([https:// pypi.python.org/pypi/pyprof2calltree](https:// pypi.python.org/pypi/pyprof2calltree))可以进行格式转换,如示例10.2所示。
示例 10.2 用KCacheGrind可视化 Python 性能分析数据
$ python -m cProfile -o myscript.cprof myscript.py
$ pyprof2calltree -k -i myscript.cprof
这可以提供很多有用的信息,让你可以判断程序的哪个部分耗费了太多的资源,如图10-1所示。
图10-1 KCacheGrind示例
虽然从宏观角度看这么用没问题,它有时也可以对代码的某些部分提供一些微观角度的分析。但这样的上下文中,我发现用dis模块可以看到一些隐藏的东西。dis模块是Python字节码的反编译器,用起来也很简单。
>>> def x():
... return 42
...
>>> import dis
>>> dis.dis(x)
2 0 LOAD_CONST 1 (42)
3 RETURN_VALUE
dis.dis函数会反编译作为参数传入的函数,并打印出这个函数运行的字节码指令的清单。为了能适当地优化代码,这对于理解程序的每行代码非常有用。
下面的代码定义了两个函数,功能相同,都是拼接三个字母。
abc = ('a', 'b', 'c')
def concat_a_1():
for letter in abc:
abc[0] + letter
def concat_a_2():
a = abc[0]
for letter in abc:
a + letter
两者看上去作用一样,但如果反汇编它们的话,可以看到生成的字节码有点儿不同。
>>> dis.dis(concat_a_1)
2 0 SETUP_LOOP 26 (to 29)
3 LOAD_GLOBAL 0 (abc)
6 GET_ITER
>> 7 FOR_ITER 18 (to 28)
10 STORE_FAST 0 (letter)
3 13 LOAD_GLOBAL 0 (abc)
16 LOAD_CONST 1 (0)
19 BINARY_SUBSCR
20 LOAD_FAST 0 (letter)
23 BINARY_ADD
24 POP_TOP
25 JUMP_ABSOLUTE 7
>> 28 POP_BLOCK
>> 29 LOAD_CONST 0 (None)
32 RETURN_VALUE
>>> dis.dis(concat_a_2)
2 0 LOAD_GLOBAL 0 (abc)
3 LOAD_CONST 1 (0)
6 BINARY_SUBSCR
7 STORE_FAST 0 (a)
3 10 SETUP_LOOP 22 (to 35)
13 LOAD_GLOBAL 0 (abc)
16 GET_ITER
>> 17 FOR_ITER 14 (to 34)
20 STORE_FAST 1 (letter)
4 23 LOAD_FAST 0 (a)
26 LOAD_FAST 1 (letter)
29 BINARY_ADD
30 POP_TOP
31 JUMP_ABSOLUTE 17
>> 34 POP_BLOCK
>> 35 LOAD_CONST 0 (None)
38 RETURN_VALUE
如你所见,在函数的第二个版本中运行循环之前我们将abc[0]保存在了一个临时变量中。这使得循环内部执行的字节码稍微短一点,因为不需要每次迭代都去查找abc[0]。通过timeit测量,第二个版本的函数比第一个要快10%,少花了不到一微秒。显然,除非调用这个函数100万次,否则不值得优化,但这就是dis模块所能提供的洞察力。
是否应该依赖将值存储在循环外这样的“技巧”是有争议的,这类优化工作应该最终由编译器完成。但是,由于Python语言是高度动态的,因此编译器很难确保优化不会产生什么副作用。所以,编写代码一定要小心。
另一个我在评审代码时遇到的错误习惯是无理由地定义嵌套函数(分解嵌套函数见示例10.3)。这实际是有开销的,因为函数会无理由地被重复定义。
示例 10.3 分解嵌套函数
>> import dis
>>> def x():
... return 42
...
>>> dis.dis(x)
2 0 LOAD_CONST 1 (42)
3 RETURN_VALUE
>>> def x():
... def y():
... return 42
... return y()
...
>>> dis.dis(x)
2 0 LOAD_CONST 1 (<code object y at 0x100ce7e30, file
"<stdin>", line 2>)
3 MAKE_FUNCTION 0
6 STORE_FAST 0 (y)
4 9 LOAD_FAST 0 (y)
12 CALL_FUNCTION 0
15 RETURN_VALUE
可以看到函数被不必要地复杂化了,调用MAKE_FUNCTION、STORE_FAST、LOAD_FAST和CALL_FUNCTION,而不是直接调用LOAD_CONST,这无端造成了更多的操作码,而函数调用在Python中本身就是低效的。
唯一需要在函数内定义函数的场景是在构建函数闭包的时候,它可以完美地匹配Python的操作码中的一个用例。反汇编一个闭包如示例10.4所示。
示例 10.4 反汇编一个闭包
>>> def x():
... a = 42
... def y():
... return a
... return y()
...
>>> dis.dis(x)
2 0 LOAD_CONST 1 (42)
3 STORE_DEREF 0 (a)
3 6 LOAD_CLOSURE 0 (a)
9 BUILD_TUPLE 1
12 LOAD_CONST 2 (<code object y at 0x100d139b0, file
"<stdin>", line 3>)
15 MAKE_CLOSURE 0
18 STORE_FAST 0 (y)
5 21 LOAD_FAST 0 (y)
24 CALL_FUNCTION 0
27 RETURN_VALUE