为什么 Python 代码在函数中运行得更快?

def main():
for i in xrange(10**8):
pass
main()

Python 中运行的这段代码(注意: 计时是用 Linux 中 BASH 中的 time 函数完成的。)

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

为什么会这样?

78903 次浏览

在函数内部,字节码是:

  2           0 SETUP_LOOP              20 (to 23)3 LOAD_GLOBAL              0 (xrange)6 LOAD_CONST               3 (100000000)9 CALL_FUNCTION            112 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            112 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_FASTSTORE_NAME更快(!)。这是因为在函数中,i是局部的,但在顶层它是全局的。

要检查字节码,请使用#0模块。我可以直接反汇编函数,但要反汇编顶层代码,我必须使用#1内置

你可能会问为什么,存储局部变量比全局变量更快。这是一个CPython实现细节。

记住CPython是编译成解释器运行的字节码的。当一个函数被编译时,局部变量存储在一个固定大小的数组中(没有 adict),变量名被分配给索引。这是可能的,因为你不能动态地将局部变量添加到一个函数中。然后检索局部变量实际上是一个指针查找到列表中,并增加PyObject的引用计数,这是微不足道的。

将此与全局查找(LOAD_GLOBAL)形成对比,后者是真正的dict搜索,涉及哈希等。顺便说一句,这就是为什么如果您希望它是全局的,则需要指定global i:如果您在范围内分配给变量,编译器将发出STORE_FAST的访问权限,除非您告诉它不要这样做。

顺便说一句,全局查找仍然非常优化。属性查找foo.bar真的慢的!

这是关于局部变量效率的小插图

除了局部/全局变量存储时间,操作码预测使函数更快。

正如其他答案所解释的那样,该函数在循环中使用STORE_FAST操作码。这是函数循环的字节码:

    >>   13 FOR_ITER                 6 (to 22)   # get next value from iterator16 STORE_FAST               0 (x)       # set local variable19 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_FASTUNPACK_SEQUENCE

验证预测需要对寄存器进行一次高速测试变量对应常数。如果配对很好,那么处理器自己的内部分支预测很有可能成功,导致几乎零开销过渡到下一个操作码。成功的预测节省了一次通过val-loop的旅行包括它的两个不可预测的分支,HAS_ARG测试和切换案例。结合处理器的内部分支预测,成功的PREDICT具有使两个操作码运行的效果它们是一个新的操作码,身体结合在一起。

我们可以在FOR_ITER操作码的源代码中确切地看到STORE_FAST的预测位置:

case FOR_ITER:                         // the FOR_ITER opcode casev = TOP();x = (*v->ob_type->tp_iternext)(v); // x is the next value from iteratorif (x != NULL) {PUSH(x);                       // put x on top of the stackPREDICT(STORE_FAST);           // predict STORE_FAST will follow - success!PREDICT(UNPACK_SEQUENCE);      // this and everything below is skippedcontinue;}// 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 stackSETLOCAL(oparg, v);            // set it as the new local variablegoto fast_next_opcode;

局部变量现在已设置,下一个操作码即将执行。Python继续遍历可迭代对象,直到它到达最后,每次都做出成功的预测。

python wiki页面包含有关CPython虚拟机如何工作的更多信息。