关于性能:为什么Python代码在函数中运行得更快?

Why does Python code run faster in a function?

1
2
3
4
def main():
    for i in xrange(10**8):
        pass
main()

python中的这段代码运行在中(注意:计时是通过Linux中bash中的time函数完成的)。

1
2
3
real    0m1.841s
user    0m1.828s
sys     0m0.012s

但是,如果for循环不在函数中,

1
2
for i in xrange(10**8):
    pass

然后它会运行更长的时间:

1
2
3
real    0m4.543s
user    0m4.524s
sys     0m0.012s

为什么会这样?


在函数内部,字节码是

1
2
3
4
5
6
7
8
9
10
11
12
  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
2
3
4
5
6
7
8
9
10
11
12
  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内建。


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

请记住,cpython被编译为字节码,解释器将运行它。编译函数时,局部变量存储在固定大小的数组中(不是dict),变量名分配给索引。这是可能的,因为您不能动态地向函数添加局部变量。然后,检索一个局部变量实际上是一个指向列表的指针查找,并且在PyObject上增加了refcount,这是很简单的。

与全局查找(LOAD_GLOBAL相比,这是一个真正的dict搜索,包含哈希等等。顺便说一句,这就是为什么如果您希望global i是全局的,那么您需要指定它:如果您曾经在一个作用域内分配一个变量,那么编译器将发出STORE_FAST来访问它,除非您告诉它不要这样做。

顺便说一下,全球搜索仍然相当优化。属性查询foo.bar是非常慢的查询!

下面是局部可变效率的小例子。


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

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

1
2
3
    >>   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看到EDOCX1(循环的顶部),它都会"预测"STORE_FAST是它必须执行的下一个操作码。然后,python偷看下一个操作码,如果预测正确,它直接跳到STORE_FAST。这会将两个操作码压缩为一个操作码。

另一方面,在全局级别的循环中使用STORE_NAME操作码。当python看到这个操作码时,它不会做出类似的预测。相反,它必须回到评估循环的顶部,这对循环执行的速度有明显的影响。

为了提供关于这种优化的更多技术细节,这里引用了ceval.c文件(python虚拟机的"引擎"):

Some opcodes tend to come in pairs thus making it possible to
predict the second code when the first is run. For example,
GET_ITER is often followed by FOR_ITER. And FOR_ITER is often
followed by STORE_FAST or UNPACK_SEQUENCE.

Verifying the prediction costs a single high-speed test of a register
variable against a constant. If the pairing was good, then the
processor's own internal branch predication has a high likelihood of
success, resulting in a nearly zero-overhead transition to the
next opcode. A successful prediction saves a trip through the eval-loop
including its two unpredictable branches, the HAS_ARG test and the
switch-case. Combined with the processor's internal branch prediction,
a successful PREDICT has the effect of making the two opcodes run as if
they were a single new opcode with the bodies combined.

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

1
2
3
4
5
6
7
8
9
10
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,即跳转到预测操作码的起始处。在这种情况下,我们跳到这里:

1
2
3
4
5
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的虚拟机如何工作的更多信息。