本节书摘来自异步社区《Python高性能编程》一书中的第2章,第2.12节,作者[美] 戈雷利克 (Micha Gorelick),胡世杰,徐旭彬 译,更多章节内容可以访问云栖社区“异步社区”公众号查看。
2.12 用dis模块检查CPython字节码
到目前为止我们已经展示了很多测量Python代码开销的方法(包括CPU和RAM的开销)。不过,我们还没有看到在底层虚拟机的字节码层面发生的事情。了解“台面下”发生的事情有助于在脑海中对运行慢的函数建立一个模型,并能帮助你编译你的代码。所以现在让我们来看一些字节码。
dis模块让我们能够看到基于栈的CPython虚拟机中运行的字节码。在你的Python代码运行的时候,了解虚拟机中发生了什么可以帮助你了解为什么某些编码风格会比其他的更快。同时还能帮助你使用Cython这样的工具,它跳出了Python的范畴,能够生成C代码。
dis模块是内建的。你可以传给它一段代码或者一个模块,它会打印出分解的字节码。在例2-16中我们分解了函数的外层循环。
问题
你应该试着分解一个你自己的函数并将每一个分解的代码和分解的输出匹配起来。你能匹配下面的dis输出和原始函数吗?
例2-16 使用内建的dis模块来了解运行代码的虚拟机
In [1]: import dis
In [2]: import julia1_nopil
In [3]: dis.dis(julia1_nopil.calculate_z_serial_purepython)
11 0 LOAD_CONST 1 (0)
3 BUILD_LIST 1
6 LOAD_GLOBAL 0 (len)
9 LOAD_FAST 1 (zs)
12 CALL_FUNCTION 1
15 BINARY_MULTIPLY
16 STORE_FAST 3 (output)
12 19 SETUP_LOOP 123 (to 145)
22 LOAD_GLOBAL 1 (range)
25 LOAD_GLOBAL 0 (len)
28 LOAD_FAST 1 (zs)
31 CALL_FUNCTION 1
34 CALL_FUNCTION 1
37 GET_ITER
>> 38 FOR_ITER 103 (to 144)
41 STORE_FAST 4 (i)
13 44 LOAD_CONST 1 (0)
47 STORE_FAST 5 (n)
# ...
# We'll snip the rest of the inner loop for brevity!
# ...
19 >> 131 LOAD_FAST 5 (n)
134 LOAD_FAST 3 (output)
137 LOAD_FAST 4 (i)
140 STORE_SUBSCR
141 JUMP_ABSOLUTE 38
>> 144 POP_BLOCK
20 >> 145 LOAD_FAST 3 (output)
148 RETURN_VALUE
这个输出非常的直白简明。第一列包含了原始文件的行数。第二列包含了一些>>标志,它们是指向其他代码的跳转点。第三列是操作的地址和操作名。第四列包含了操作的参数。第五列的标记可用来帮助对照字节码和原始Python的参数。
对照字节码和例2-3中的Python代码。字节码首先将常数0放到栈上,然后创建了一个具有一个项目的列表。接下来,它搜索了名字空间来查询len函数,将它放到栈上,再次搜索名字空间找到zs,放到栈上。在第12行,它从栈上调用len函数,且弹出了栈上的zs作为参数;然后对最后两个参数调用二进制乘法(zs的长度和那个单项目列表)将结果保存在output中。这就是我们那个Python函数的第一行干的事情。你可以继续查看下一段字节码来了解Python代码第二行的行为(外层for循环)。
问题
跳转点(>>)匹配JUMP_ABSOLUTE以及POP_JUMP_IF_FALSE等指令。过一遍你自己的函数分解结果并对照跳转点和跳转指令。
介绍完字节码,我们现在要问:要完成同样的任务,显式编写的函数和使用内建函数在字节码和时间开销上的对比是什么。
不同的方法,不同的复杂度
应该有一种——而且最好只有唯一的一种——明显的方式去完成它。虽然这种方式可能一开始并不明显,除非你是荷兰人……
——Tim Peters
Python之禅
通过Python你有无数种方式表达你的意思。一般来说最优的那个选择十分明显,但是如果你的经验主要来自一个老版本的Python或另一门编程语言,那么在你的脑海里可能就是另外的选择。某些表达的方式可能就比别的要慢。
对于你大多数的代码,你可能更关心可读性而不是速度,这能让你的团队更有效地写代码,而不是被高效而难懂的代码所迷惑。但是,有些时候你会更追求性能(且不牺牲可读性),那么你需要的可能是一些速度测试。
看看例2-17的两段代码。它们都做了相同的工作,但是第一个会产生大量额外的Python字节码,带来更大的开销。
例2-17 一个单纯的和一个高效的手段解决同一个求和问题
def fn_expressive(upper = 1000000):
total = 0
for n in xrange(upper):
total += n
return total
def fn_terse(upper = 1000000):
return sum(xrange(upper))
print "Functions return the same result:", fn_expressive() == fn_terse()
Functions return the same result:
True
两个函数都对一批整数求和。一个简单的经验法则(但是你一定要进行性能分析!)是字节码越多执行的速度越慢。内建函数使用了更少的字节码行数来完成同样的工作。我们在例2-18中使用IPython的%timeit魔法函数测量它们的最快运行时间。
例2-18 用%timeit验证我们的假设:内建函数应该比自己写函数要快(译注:原著中未给出fn_terse()的结果)
%timeit fn_expressive()
10 loops, best of 3: 42 ms per loop
100 loops, best of 3: 12.3 ms per loop
%timeit fn_terse()
如果我们用dis模块调查每个函数的字节码,如例2-19所示,我们能看到虚拟机用了17行来执行更有表现力的函数,而仅用了6行来执行非常可读但更简洁的第二个函数。
例2-19 用dis查看两个函数的字节码指令行数
import dis
print fn_expressive.func_name
dis.dis(fn_expressive)
fn_expressive
2 0 LOAD_CONST 1 (0)
3 STORE_FAST 1 (total)
3 6 SETUP_LOOP 30 (to 39)
9 LOAD_GLOBAL 0 (xrange)
12 LOAD_FAST 0 (upper)
15 CALL_FUNCTION 1
18 GET_ITER
>> 19 FOR_ITER 16 (to 38)
22 STORE_FAST 2 (n)
4 25 LOAD_FAST 1 (total)
28 LOAD_FAST 2 (n)
31 INPLACE_ADD
32 STORE_FAST 1 (total)
35 JUMP_ABSOLUTE 19
>> 38 POP_BLOCK
5 >> 39 LOAD_FAST 1 (total)
42 RETURN_VALUE
print fn_terse.func_name
dis.dis(fn_terse)
fn_terse
8 0 LOAD_GLOBAL 0 (sum)
3 LOAD_GLOBAL 1 (xrange)
6 LOAD_FAST 0 (upper)
9 CALL_FUNCTION 1
12 CALL_FUNCTION 1
15 RETURN_VALUE
两段代码的区别很明显。在fn_expressive()内部,我们维护了两个本地变量并用for循环遍历了一个列表。for循环会在每次循环时检查StopIteration异常是否被引发。每次迭代都会调用total.__add__函数,这个函数会检查第二个变量(n)的类型。所有这些检查都会带来一些开销。
在fn_terse()内部,我们调用了一个用C编写的优化的列表操作函数,它知道如何生成最后的结果而无须创建中间的Python对象。这样会快很多,即使每次迭代仍然必须检查被求和对象的类型(我们会在第4章看到一些将类型固定的方法,这样就不需要在每次迭代时都检查它)。
之前提过,你一定要对你的代码进行性能分析——但如果你只依靠性能分析来试错,那你必然会在某些时候写出较慢的代码。学习Python是否已经存在一个内建的更短且依然可读的方式来解决你的问题是绝对值得的。如果已经存在,那么它可能更容易被另一个开发人员理解且可能运行得会更快。