为什么 Python 代码在函数中运行速度更快?
- 2024-12-27 08:47:00
- admin 原创
- 126
问题描述:
def main():
for i in xrange(10**8):
pass
main()
这段 Python 代码运行于(注:计时是用 Linux 中 BASH 中的时间函数完成的。)
real 0m1.841s
user 0m1.828s
sys 0m0.012s
但是,如果 for 循环没有放在函数中,
for i in xrange(10**8):
pass
然后它会运行更长的时间:
real 0m4.543s
user 0m4.524s
sys 0m0.012s
这是为什么呢?
解决方案 1:
在函数内部,字节码是:
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
不同之处在于STORE_FAST
比 更快 (!) STORE_NAME
。这是因为在函数中i
是本地的,但在顶层它是全局的。
要检查字节码,请使用dis
模块。我能够直接反汇编该函数,但要反汇编顶层代码,我必须使用compile
内置模块。
解决方案 2:
你可能会问为什么存储局部变量比存储全局变量更快。这是一个 CPython 实现细节。
请记住,CPython 被编译为字节码,由解释器运行。当函数被编译时,局部变量存储在一个固定大小的数组(而不是dict
)中,变量名被分配给索引。这是可能的,因为您无法动态地将局部变量添加到函数中。然后检索局部变量实际上是在列表中查找指针,并在其上增加引用计数,PyObject
这是微不足道的。
将其与全局查找 ( ) 进行对比,后者是涉及哈希等的LOAD_GLOBAL
真正搜索。顺便说一句,这就是为什么您需要指定是否希望它是全局的:如果您曾经在作用域内分配变量,则编译器将发出s 以进行访问,除非您告诉它不要这样做。dict
`global i`STORE_FAST
顺便说一句,全局查找仍然非常优化。属性查找foo.bar
真的很慢!
这是局部变量效率的小例子。
解决方案 3:
除了本地/全局变量存储时间之外,操作码预测使得函数更快。
正如其他答案所解释的那样,该函数STORE_FAST
在循环中使用操作码。这是该函数循环的字节码:
>> 13 FOR_ITER 6 (to 22) # get next value from iterator
16 STORE_FAST 0 (x) # set local variable
19 JUMP_ABSOLUTE 13 # back to FOR_ITER
通常,当程序运行时,Python 会逐个执行每个操作码,跟踪堆栈并在执行每个操作码后对堆栈帧执行其他检查。操作码预测意味着在某些情况下 Python 能够直接跳转到下一个操作码,从而避免一些开销。
在这种情况下,每次 Python 看到FOR_ITER
(循环顶部),它都会“预测”这STORE_FAST
是它必须执行的下一个操作码。然后 Python 会查看下一个操作码,如果预测正确,它会直接跳转到STORE_FAST
。这可以将两个操作码压缩为一个操作码。
另一方面,STORE_NAME
操作码在全局级别循环中使用。Python在看到此操作码时不会做出类似的预测。相反,它必须返回到求值循环的顶部,这对循环执行的速度有明显的影响。
为了提供有关此优化的更多技术细节,这里引用了ceval.c
文件(Python 虚拟机的“引擎”)中的一段话:
有些操作码往往成对出现,因此当第一个操作码运行时,就可以预测第二个操作码。例如,
GET_ITER
后面通常跟着FOR_ITER
。 并且FOR_ITER
后面通常跟着STORE_FAST
或UNPACK_SEQUENCE
。验证预测需要对寄存器变量和常量进行一次高速测试。如果配对良好,那么处理器自己的内部分支预测就很有可能成功,从而几乎零开销地转换到下一个操作码。成功的预测可以节省一次通过 eval 循环的行程,包括其两个不可预测的分支,即测试
HAS_ARG
和 switch-case。与处理器的内部分支预测相结合,成功的PREDICT
预测可以使两个操作码运行起来,就像它们是合并了主体的单个新操作码一样。
我们可以在操作码的源代码中看到FOR_ITER
预测的确切位置STORE_FAST
:
case FOR_ITER: // the FOR_ITER opcode case
v = TOP();
x = (*v->ob_type->tp_iternext)(v); // x is the next value from iterator
if (x != NULL) {
PUSH(x); // put x on top of the stack
PREDICT(STORE_FAST); // predict STORE_FAST will follow - success!
PREDICT(UNPACK_SEQUENCE); // this and everything below is skipped
continue;
}
// error-checking and more code for when the iterator ends normally
该PREDICT
函数扩展为if (*next_instr == op) goto PRED_##op
,即我们直接跳转到预测操作码的开头。在本例中,我们跳转到此处:
PREDICTED_WITH_ARG(STORE_FAST);
case STORE_FAST:
v = POP(); // pop x back off the stack
SETLOCAL(oparg, v); // set it as the new local variable
goto fast_next_opcode;
现在已设置局部变量,并且下一个操作码已准备好执行。Python 继续遍历可迭代对象,直到到达末尾,每次都成功做出预测。
Python 维基页面有更多有关 CPython 虚拟机如何工作的信息。