892
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

为什么是这样?

4

3 回答 3

681

在函数内部,字节码是:

  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内置的.

于 2012-06-28T09:29:12.917 回答
573

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

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

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

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

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

于 2012-06-28T10:15:08.067 回答
46

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

正如其他答案所解释的,该函数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_FASTor之后UNPACK_SEQUENCE

验证预测需要对寄存器变量与常数进行单次高速测试。如果配对良好,那么处理器自己的内部分支预测成功的可能性很高,从而导致到下一个操作码的过渡开销几乎为零。一个成功的预测可以避免 eval 循环,包括它的两个不可预测的分支,HAS_ARGtest 和 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 继续遍历 iterable 直到它到达末尾,每次都进行成功的预测。

Python wiki 页面有更多关于 CPython 的虚拟机如何工作的信息。

于 2015-04-05T18:45:34.743 回答