【问题标题】:Why does Python code run faster in a function?为什么 Python 代码在函数中运行得更快?
【发布时间】:2012-06-29 18:56:15
【问题描述】:
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

这是为什么?

【问题讨论】:

  • 你是如何实际计时的?
  • 只是一种直觉,不确定是否属实:我猜这是因为范围。在函数的情况下,创建了一个新的范围(即一种具有绑定到其值的变量名的哈希)。没有函数,变量在全局范围内,当你可以找到很多东西时,因此会减慢循环。
  • @Scharron 好像不是这样。在范围内定义了 200k 虚拟变量,而不会明显影响运行时间。
  • @Scharron 你说对了一半。它是关于作用域的,但它在本地更快的原因是本地作用域实际上是作为数组而不是字典实现的(因为它们的大小在编译时是已知的)。
  • @AndrewJaffe 输出会建议 linux' time 命令。

标签: python performance profiling benchmarking cpython


【解决方案1】:

在函数内部,字节码是:

  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

【讨论】:

  • 实验证实。将global i 插入main 函数使运行时间相等。
  • 这回答了问题而不回答问题:) 在局部函数变量的情况下,CPython 实际上将它们存储在一个元组中(从 C 代码中是可变的),直到请求一个字典(例如通过locals()inspect.getframe() 等)。通过常量整数查找数组元素比查找字典要快得多。
  • C/C++ 也一样,使用全局变量会导致显着变慢
  • 这是我第一次看到字节码。如何看待它,了解它很重要?
  • @gkimsey 我同意。只想分享两件事 i) 这种行为在其他编程语言中有所体现 ii) 因果代理更多的是架构方面,而不是真正意义上的语言本身
【解决方案2】:

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

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

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

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

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

【讨论】:

  • 这也适用于 PyPy,直到当前版本(撰写本文时为 1.8)。与函数内部相比,来自 OP 的测试代码在全局范围内的运行速度大约慢四倍。
  • @Walkerneo 他们不是,除非你倒过来说。按照 katrielalex 和 ecatmur 的说法,由于存储方式的原因,全局变量查找比局部变量查找要慢。
  • @Walkerneo 这里进行的主要讨论是函数内的局部变量查找与在模块级别定义的全局变量查找之间的比较。如果您在原始评论中注意到对此答案的回复,您会说“我不会认为全局变量查找比局部变量属性查找更快。”他们不是。 katrielalex 说,虽然局部变量查找比全局变量查找更快,但即使是全局变量查找也比属性查找(不同)进行了相当优化和更快。我没有足够的空间在此评论中提供更多信息。
  • @Walkerneo foo.bar 不是本地访问。它是对象的属性。 (请原谅缺少格式)def foo_func: x = 5x 是函数的局部变量。访问 x 是本地的。 foo = SomeClass()foo.bar 是属性访问。 val = 5 全球是全球性的。至于速度本地>全局>属性根据我在这里读到的。所以在foo_func中访问x是最快的,其次是val,其次是foo.barfoo.attr 不是本地查找,因为在这个 convo 的上下文中,我们所说的本地查找是对属于函数的变量的查找。
  • @thedoctar 看看globals() 函数。如果您想了解更多信息,您可能必须开始查看 Python 的源代码。 CPython 只是 Python 常用实现的名称——所以你可能已经在使用它了!
【解决方案3】:

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

正如其他答案所解释的,该函数在循环中使用 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_FASTUNPACK_SEQUENCE

验证预测需要对寄存器进行一次高速测试 变量对常数。如果配对很好,那么 处理器自己的内部分支预测很有可能 成功,导致几乎零开销过渡到 下一个操作码。成功的预测节省了通过 eval 循环的行程 包括它的两个不可预测的分支,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 的虚拟机如何工作的信息。

【讨论】:

  • 次要更新:从 CPython 3.6 开始,预测节省的费用有所下降;而不是两个不可预测的分支,只有一个。更改是由于the switch from bytecode to wordcode;现在所有“字代码”都有一个参数,当指令在逻辑上不带参数时,它只是归零。因此,HAS_ARG 测试永远不会发生(除非在编译和运行时都启用了低级跟踪,而正常构建不会这样做),只留下一个不可预知的跳转。
  • 由于新的 (as of Python 3.1, enabled by default in 3.2) 计算的 goto 行为,即使在大多数 CPython 版本中也不会发生不可预知的跳转;使用时,PREDICT 宏被完全禁用;相反,大多数情况以直接分支的DISPATCH 结尾。但是在分支预测 CPU 上,效果类似于 PREDICT,因为分支(和预测)是按操作码进行的,因此增加了分支预测成功的几率。
猜你喜欢
  • 2019-01-31
  • 1970-01-01
  • 1970-01-01
  • 2017-02-12
  • 1970-01-01
相关资源
最近更新 更多