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


832
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

为什么是这样?


16
您实际上是如何安排时间的?
Andrew Jaffe 2012年

53
只是一种直觉,不确定是否正确:我想这是因为范围。在函数情况下,将创建一个新的作用域(即一种将变量名称绑定到其值的哈希值)。如果没有函数,那么当您可以找到很多东西时,变量就位于全局范围内,从而减慢了循环速度。
Scharron 2012年

4
@Scharron似乎并非如此。在范围内定义200k虚拟变量,而不会明显影响运行时间。
Deestan

2
亚历克斯·玛特利(Alex Martelli)对此stackoverflow.com/a/1813167/174728
John La Rooy 2012年

53
@Scharron,您说得对。它与范围有关,但是在本地变量中更快的原因是,本地范围实际上是作为数组而不是字典实现的(因为它们的大小在编译时是已知的)。
卡特里尔2012年

Answers:


531

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

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

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

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

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


6
这也适用于最新版本(在撰写本文时为1.8)的PyPy。与函数内部相比,OP的测试代码在全局范围内的运行速度约慢四倍。
GDorn 2012年

4
@Walkerneo并非如此,除非您向后说。按照katrielalex和ecatmur的说法,由于存储方法的原因,全局变量查找要比局部变量查找慢。
杰里米·普里德摩尔

2
@Walkerneo这里进行的主要对话是在函数内进行局部变量查找与在模块级别定义的全局变量查找之间的比较。如果您在原始评论中注意到对此回答的答复,您会说:“我不会认为全局变量查找比本地变量属性查找要快。” 而事实并非如此。katrielalex说,尽管局部变量查找要比全局查找快,但是即使全局变量查找也比属性查找(它们是不同的)进行了优化和速度更快。我没有足够的空间发表评论。
杰里米·普里德莫尔

3
@Walkerneo foo.bar不是本地访问。它是对象的属性。(请原谅缺少格式)def foo_func: x = 5x对于功能而言是本地的。访问x是本地的。foo = SomeClass()foo.bar是属性访问。val = 5全球就是全球。至于速度局部>全局>属性,根据我在这里阅读的内容。所以访问xfoo_func是最快的,其次val,其次是foo.barfoo.attr不是局部查找,因为在此convo上下文中,我们所谈论的是局部查找是对属于函数的变量的查找。
杰里米·普里德摩尔

3
@thedoctar看看globals()功能。如果您需要更多信息,则可能必须开始查看Python的源代码。CPython只是Python通常实现的名称-因此您可能已经在使用它了!
卡特里尔2012年

660

在函数内部,字节码为:

  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内置函数。


171
经实验确认。插入global i到该main功能使等效运行时间。
Deestan

44
这回答了问题而没有回答问题:)对于局部函数变量,CPython实际上将它们存储在元组(可从C代码更改)中,直到请求字典(例如,通过locals()inspect.getframe()等等)。用常数整数查找数组元素比搜索字典快得多。
dmw

3
C / C ++也是如此,使用全局变量会导致显着的下降
codejammer 2012年

3
这是我第一次见到字节码。.如何看待它,重要的是要知道?
扎克2012年

4
@gkimsey我同意。我只想分享两件事:i)其他编程语言中提到了这一行为; ii)因果代理更多是架构方面的,而不是语言本身的真实含义
codejammer 2012年

41

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

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

验证预测需要对寄存器变量进行一个针对常数的高速测试。如果配对良好,则处理器自己的内部分支谓词成功的可能性很高,从而导致到下一个操作码的开销几乎为零。成功的预测可以节省通过评估循环的旅程,该评估循环包括其两个不可预测的分支,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页面有大约CPython中的虚拟机是如何工作的更多信息。


较小的更新:从CPython 3.6开始,从预测中节省下来的费用有所减少。而不是两个不可预测的分支,只有一个。改变是由于从字节码到字码的转换 ; 现在所有“单词代码”都有一个自变量,当指令在逻辑上不接受自变量时,它只是零位。因此,HAS_ARG测试永远不会发生(除非在编译和运行时都启用了低级跟踪,而正常的构建都没有这样做),只剩下一个不可预测的跳转。
ShadowRanger

在大多数CPython版本中,即使是不可预测的跳转也不会发生,这是因为新的(从Python 3.1开始在3.2中默认启用)计算的goto行为。使用时,PREDICT宏被完全禁用;相反,大多数情况都以DISPATCH直接分支的结尾。但是在分支预测CPU上,效果类似于PREDICT,因为分支(和预测)是针对每个操作码进行的,因此增加了成功进行分支预测的几率。
ShadowRanger
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.