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

the*_*tar 809 python performance benchmarking profiling cpython

def main():
    for i in xrange(10**8):
        pass
main()
Run Code Online (Sandbox Code Playgroud)

Python中的这段代码运行(注意:时序是在Linux中的BASH中使用时间函数完成的.)

real    0m1.841s
user    0m1.828s
sys     0m0.012s
Run Code Online (Sandbox Code Playgroud)

但是,如果for循环没有放在函数中,

for i in xrange(10**8):
    pass
Run Code Online (Sandbox Code Playgroud)

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

real    0m4.543s
user    0m4.524s
sys     0m0.012s
Run Code Online (Sandbox Code Playgroud)

为什么是这样?

eca*_*mur 651

在函数内部,字节码是

  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        
Run Code Online (Sandbox Code Playgroud)

在顶层,字节码是

  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        
Run Code Online (Sandbox Code Playgroud)

区别在于STORE_FAST比(.)更快(!)STORE_NAME.这是因为在一个函数中,它i是一个局部但是在顶层它是一个全局的.

要检查字节码,请使用该dis模块.我能够直接反汇编函数,但是要反汇编顶层代码我必须使用compile内置函数.

  • 通过实验证实.将`global i`插入`main`函数会使运行时间相等. (166认同)
  • 这回答了问题而没有回答问题:)在本地函数变量的情况下,CPython实际上将它们存储在元组中(可以从C代码中变化)直到请求字典(例如通过`locals()`或者` inspect.getframe()`等).通过常量整数查找数组元素比搜索dict快得多. (44认同)
  • @gkimsey我同意.只是想分享两件事情i)这种行为在其他编程语言中被注意到ii)因果代理更多的是建筑方面而不是真正意义上的语言本身 (4认同)
  • 它与C/C++也是一样的,使用全局变量会导致显着减速 (3认同)
  • 这是我见过的第一个字节码..如何看待它,重要的是要知道? (3认同)
  • @Pratik查看`dis`模块的文档:http://docs.python.org/library/dis.html (2认同)
  • @manty写`dis.dis(编译('for x in xrange(10**8):\n pass','main.py','exec'))` (2认同)

Kat*_*iel 508

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

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

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

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

这是关于局部变量效率的小例子.

  • 这也适用于PyPy,直到当前版本(撰写本文时为1.8).与函数内部相比,OP的测试代码在全局范围内运行速度慢约四倍. (6认同)
  • @Walkerneo他们不是,除非你说它倒退.根据katrielalex和ecatmur所说的,由于存储方法,全局变量查找比局部变量查找慢. (4认同)
  • @Walkerneo foo.bar不是本地访问.它是对象的属性.(原谅缺少格式化)`def foo_func:x = 5`,`x`是函数的本地.访问`x`是本地的.`foo = SomeClass()`,`foo.bar`是属性访问.`val = 5`全球是全球性的.至于速度本地>全局>属性根据我在这里读到的.因此,访问`foo_func`中的`x`是最快的,接着是`val`,然后是`foo.bar`.`foo.attr`不是本地查找,因为在此convo的上下文中,我们讨论的是本地查找是查找属于函数的变量. (3认同)
  • @Walkerneo这里进行的主要对话是在函数内进行局部变量查找与在模块级别定义的全局变量查找之间的比较。如果您在原始评论中注意到对此回答的答复,您会说:“我不会认为全局变量查找比本地变量属性查找要快。” 而事实并非如此。katrielalex说,尽管局部变量查找要比全局查找快,但是即使全局变量查找也比属性查找(它们是不同的)进行了优化和速度更快。我没有足够的空间发表评论。 (2认同)
  • @thedoctar看看`globals()`函数.如果您想要更多信息,可能必须开始查看Python的源代码.而CPython只是Python常用实现的名称 - 所以你可能已经在使用它了! (2认同)
  • 是的,因为在大多数情况下,稍微缓慢对于程序的运行速度实际上并不重要。 (2认同)
  • @Quelklef:修改“locals”返回的“dict”[明确指出不支持](https://docs.python.org/3/library/functions.html#locals);它实际上不能添加“真正的”局部变量,但实现是这样的,您可以动态检查所述“局部变量”的唯一方法有时会在 CPython 解释器上起作用;当您第一次在函数中调用“locals()”时,它会将实际局部变量作为“dict”进行镜像并将其缓存;如果你在同一个函数中再次调用“locals()”,你会得到相同的“dict”。但它实际上并没有调整“真实”本地数组的大小。 (2认同)

Ale*_*ley 38

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

正如其他答案所解释的那样,该函数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
Run Code Online (Sandbox Code Playgroud)

通常,当程序运行时,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.

验证预测会花费一个寄存器变量对常量的高速测试.如果配对良好,则处理器自己的内部分支预测具有很高的成功可能性,导致到下一个操作码的开销几乎为零.成功的预测可以节省通过eval-loop的行程,包括其两个不可预测的分支,HAS_ARG测试和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                                     
Run Code Online (Sandbox Code Playgroud)

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;
Run Code Online (Sandbox Code Playgroud)

现在设置了局部变量,并且下一个操作码已启动执行.Python继续通过迭代直到它到达终点,每次都成功进行预测.

Python的wiki页面有大约CPython中的虚拟机是如何工作的更多信息.