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

时间:2012-06-28 09:18:35

标签: python performance profiling benchmarking cpython

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

为什么会这样?

3 个答案:

答案 0 :(得分:648)

在函数内部,字节码是

  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_FASTSTORE_NAME更快(!)。这是因为在函数中,i是本地的,但在顶层它是全局的。

要检查字节码,请使用dis module。我能够直接反汇编函数,但是要反汇编顶层代码,我必须使用compile builtin

答案 1 :(得分:490)

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

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

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

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

这里有关于局部变量效率的小illustration

答案 2 :(得分:37)

除了本地/全局变量存储时间之外,操作码预测使功能更快。

正如其他答案所解释的那样,该函数在循环中使用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在看到此操作码时会做 * not * 做类似的预测。相反,它必须回到评估循环的顶部,这对循环执行的速度有明显的影响。

要提供有关此优化的更多技术细节,请参阅ceval.c文件(Python虚拟机的“引擎”):

  

某些操作码倾向于成对出现,从而使其成为可能    在第一个代码运行时预测第二个代码。例如,    GET_ITER后面经常跟FOR_ITER FOR_ITER经常是    然后是STORE_FAST UNPACK_SEQUENCE

     

验证预测会对寄存器进行单次高速测试       变量对应常数。如果配对很好,那么       处理器自己的内部分支预测具有很高的可能性       成功,导致几乎零开销过渡到       下一个操作码。成功的预测通过eval-loop节省了一次旅行       包括它的两个不可预测的分支,HAS_ARG测试和       切换情况。结合处理器的内部分支预测,       成功的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 wiki page提供了有关CPython虚拟机如何工作的更多信息。