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

def main():
    for i in xrange(10**8):
        pass
main()
real    0m1.841s
user    0m1.828s
sys     0m0.012s
for i in xrange(10**8):
    pass
real    0m4.543s
user    0m4.524s
sys     0m0.012s

答案

在函数内部,字节码为

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 Builtin

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

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

将此与全局查找( LOAD_GLOBAL )进行对比,这是一个真正的dict搜索,涉及哈希等等。顺便说一句,这就是为什么如果要使其成为全局变量,则需要指定global i变量global i原因:如果您在作用域内分配了变量,则编译器将发出STORE_FAST进行访问,除非您告知不STORE_FAST

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

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

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

正如其他答案所解释的,该函数在循环中使用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 虚拟机的 “引擎”)的ceval.c

一些操作码往往成对出现,因此可以在运行第一个代码时预测第二个代码。例如, GET_ITER之后通常是FOR_ITER 。并且FOR_ITER通常后面跟着STORE_FASTUNPACK_SEQUENCE

验证预测需要对寄存器变量进行一个针对常数的高速测试。如果配对良好,则处理器自身的内部分支谓词成功的可能性很高,从而导致到下一个操作码的开销几乎为零。成功的预测可以节省通过评估循环的旅程,该评估循环包括其两个不可预测的分支, HAS_ARG测试和开关情况。结合处理器的内部分支预测,成功的PREDICT可以使两个操作码像合并了主体的单个新操作码一样运行。

我们可以在FOR_ITER操作码的源代码中看到准确的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 Wiki 页面包含有关 CPython 虚拟机如何工作的更多信息。