本文已参与「新人创作礼」活动,一起开启掘金创作之路。
简介
本文发现处理一个普通循环时,封装成python函数的代码其运行速度要快于直接在文件中运行的现象,并查找多方资料,从底层视角分析出了原因。
第一节描述了实验代码与实验现象,第二节从字节码的角度探究了原因,第三节则从全局变量与局部变量的区别入手,解释字节码中差别的原因。
1. 一段python循环语句,封装前后的执行时间却不相同?
实验配置信息:Python 3.6.10,Unbuntu 18.04,
def main():
for i in xrange(10**8):
pass
main()
笔者在linux下运行了上述代码,并利用time命令计算程序的运行时间,得到的结果如下所示:
real 0m1.606s
user 0m1.221s
sys 0m0.004s
但是,如果我们不进行封装,直接运行如下所示的代码:
for i in xrange(10**8):
pass
其运行时间为:
real 0m2.137s
user 0m2.080s
sys 0m0.004s
可以发现,当不将这段循环代码封装成函数再运行时,其运行时间大幅度升高了!为什么会出现这个现象呢?
2.从字节码上找原因!STORE_NAME与STORE_FAST
python的dis模块可以反汇编CPython字节码,从而让我们查看一段python代码对应的底层语句,通过查看封装前后的字节码,我们可以发现不同之处。
封装成main()函数的循环语句,其字节码如下所示:
2 0 SETUP_LOOP 20 (to 23)
3 LOAD_GLOBAL 0 (xrange)
6 LOAD_CONST 3 (100000000)
9 CALL_FUNCTION 1
12 GET_ITER
>> 13 FOR_ITER 6 (to 22)
16 STORE_FAST 0 (i)
3 19 JUMP_ABSOLUTE 13
>> 22 POP_BLOCK
>> 23 LOAD_CONST 0 (None)
26 RETURN_VALUE
而未被封装的循环语句,其字节码如下所示:
1 0 SETUP_LOOP 20 (to 23)
3 LOAD_NAME 0 (xrange)
6 LOAD_CONST 3 (100000000)
9 CALL_FUNCTION 1
12 GET_ITER
>> 13 FOR_ITER 6 (to 22)
16 STORE_NAME 1 (i)
2 19 JUMP_ABSOLUTE 13
>> 22 POP_BLOCK
>> 23 LOAD_CONST 2 (None)
26 RETURN_VALUE
可以看到,这两段字节码的区别分别是第二行的LOAD_GLOBAL和LOAD_NAME以及第七行的STORE_NAME与STORE_FAST,STORE_FAST的运行速度是要快于STORE_NAME的!因此才导致同样的循环语句,封装成函数后的执行速度要远快于不封装。
3. 刨根问底找原因:全局变量与局部变量
那么是什么导致了两种实现在字节码上的区别呢?问题出在了变量i上!在函数中,变量i是一个局部变量,而不进行封装时,变量i是一个全局变量。由于变量性质的不同,CPython才需要使用不同的访问方法。但是为什么从局部变量变成全局变量,就导致了运行速度的下降呢?答案需要从CPython讲起。
CPython会将python程序编译成字节码,然后解释器再去运行,当一个函数被编译时,函数中的局部变量被存储于一个定长数组中,而变量名则作为数组索引。这就使得对于局部变量的查找和访问仅仅需要常数级别的时间复杂度。而全局变量常常存储在堆中,CPython在进行全局查找的时候,需要在一个类似于dict的数据结构中进行搜索,需要进行一些hash运算等操作,因此其访问速度要慢于对于全局变量的访问速度。
4. 总结
CPython在处理全局变量时,其访问速度要慢于访问局部变量,如果不将语句封装进函数而是直接写在文件中时,语句中的变量就会默认为全局变量,导致了较慢的访问速度与处理速度,因此才使得相同的python代码,封装后比不封装的运行速度快了接近一倍。