为什么在 Python 3.12 中执行简单的 for 循环比在 Python 3.11 中花费更长的时间?

Bab*_*bak 3 python performance

Python 3.12 两天前发布,包含多项新功能和改进。它声称它比以往任何时候都快,所以我决定尝试一下。我用新版本运行了一些脚本,但速度比以前慢。我尝试了各种方法,每次都简化我的代码,试图找出导致其运行缓慢的瓶颈。然而,到目前为止我还没有成功。最后,我决定测试一个简单的 for 循环,如下所示:

import time

def calc():
    for i in range(100_000_000):
        x = i * 2

t = time.time()
calc()
print(time.time() - t)
Run Code Online (Sandbox Code Playgroud)

在我的机器上,Python 3.11.5 上需要 4.7 秒,Python 3.12.0 上需要 5.7 秒。在其他机器上尝试也得到了类似的结果。

那么为什么在最新版本的Python中它会变慢呢?

Jér*_*ard 6

我能够使用 Intel i5-9600KF CPU 在 Debian Linux 6.1.0-6 上重现 CPython 3.11.2 和 CPython 3.12.0rc2 之间观察到的行为。我尝试使用低级分析方法来找出差异。简而言之:您的基准测试非常具体,而 CPython 3.12 对于这种具体情况的优化较少。CPython 3.12 似乎管理对象分配,更具体地说range有点不同。与 CPython 3.11 不同,CPython 3.12 似乎为循环的每次迭代从常量 2 创建一个新对象。此外,主求值函数执行间接函数指针调用,在这种情况下速度特别慢。无论如何,您不应该在这样的用例中使用 (C)Python(CPython 文档中有说明)。

\n
\n

在引擎盖下

\n

这是我得到的结果(多次启动之间相当稳定):

\n
3.11: 2.026395082473755\n3.12: 2.4122846126556396\n
Run Code Online (Sandbox Code Playgroud)\n

因此,在我的机器上,CPython 3.12 比 CPython 3.11 慢大约 20%。

\n

分析结果表明,一半的开销来自 CPython 3.12 主评估函数中的间接函数指针调用,而这在 CPython 3.11 中是不存在的。这个函数调用在大多数现代处理器上都很昂贵。下面是热点部分的汇编代码:

\n
3.11: 2.026395082473755\n3.12: 2.4122846126556396\n
Run Code Online (Sandbox Code Playgroud)\n

虽然 CPython 3.11 中同一函数的汇编代码类似,但没有如此昂贵的调用。尽管如此,CPython 3.11 中已经存在许多类似的间接函数调用。我的假设是,这样的调用在 CPython 3.12 中成本更高,因为硬件预测单元不太可预测它(可能是因为同一条指令调用多个函数)。有关这方面的更多信息,请阅读这篇精彩的文章。关于这部分我不能说太多,因为汇编代码确实很大(而且事实证明 C 代码也很大)。

\n

其余的开销似乎来自 CPython 3.12 中管理对象(更具体地说是常量)的方式。事实上,在 CPython 3.11 中,PyObject_Free调用很慢(因为您花费了所有时间创建/删除对象),而在 CPython 3.12 中,这样的调用甚至在分析器中不可见,但有一个相当PyLong_FromLong慢的调用(在 CPython 3.12 中不可见) CPython 3.11)。其余(许多其他)功能仅占用不到 25~30%,并且在两个版本中看起来相似。基于此,我们可以得出结论,与CPython 3.11 不同,CPython 3.12 会为循环的每次迭代根据常量创建一个新对象。2在这种情况下,这显然效率不高(应该记住,CPython 是一种解释器,而不是编译器,因此它对此不执行优化也就不足为奇了)。有一种简单的方法可以检查:在循环之前存储2在变量中并在循环中使用该变量。这是更正后的代码:

\n
       \xe2\x94\x82      \xe2\x86\x93 je         4b8\n  1,28 \xe2\x94\x82        mov        (%rdi),%rax\n  0,33 \xe2\x94\x82        test       %eax,%eax\n  0,61 \xe2\x94\x82      \xe2\x86\x93 js         4b8\n  0,02 \xe2\x94\x82        sub        $0x1,%rax\n  2,80 \xe2\x94\x82        mov        %rax,(%rdi)\n       \xe2\x94\x82      \xe2\x86\x93 jne        4b8\n  0,08 \xe2\x94\x82        mov        0x8(%rdi),%rax\n 16,28 \xe2\x94\x82      \xe2\x86\x92 call       *0x30(%rax)            <---------------- HERE\n       \xe2\x94\x82        nop\n  1,53 \xe2\x94\x82 4b8:   mov        (%rsp),%rax\n       \xe2\x94\x82        lea        0x4(%rax),%rcx\n       \xe2\x94\x82        movzbl     0x3(%rax),%eax\n  0,06 \xe2\x94\x82        mov        0x48(%r13,%rax,8),%rdx\n  1,82 \xe2\x94\x82        mov        (%rdx),%eax\n  0,04 \xe2\x94\x82        add        $0x1,%eax\n       \xe2\x94\x82      \xe2\x86\x93 je         8800\n
Run Code Online (Sandbox Code Playgroud)\n

以下是更正代码的时间:

\n
import time\n\ndef calc():\n    const = 2\n    for i in range(100_000_000):\n        x = i * const\n\nt = time.time()\ncalc()\nprint(time.time() - t)\n
Run Code Online (Sandbox Code Playgroud)\n

CPython 3.12 版本现在比以前快得多,而其他版本几乎不受代码修改的影响。乍一看,它倾向于证实最后一个假设。话虽这么说,探查器仍然报告PyLong_FromLong修改后的代码中的许多调用!事实证明,这一更改消除了本节开头讨论的与间接函数指针调用相关的问题!

\n

我的假设是,PyLong_FromLong调用来自不同的方式来管理从range(ie i) 生成的对象。以下代码倾向于确认这一点(请注意,由于列表的原因,该代码需要约 4 GiB 的 RAM,因此不应在生产中使用,而只能用于测试目的):

\n
3.11: 2.045902967453003\n3.12: 2.2230796813964844\n
Run Code Online (Sandbox Code Playgroud)\n

以下是我机器上的结果:

\n
import time\n\ndef calc():\n    const = 2\n    TMP = list(range(100_000_000))\n    t = time.time()\n    for i in TMP:\n        x = i * const\n    print(time.time() - t)\n\ncalc()\n
Run Code Online (Sandbox Code Playgroud)\n

间隙比以前更近,循环的时间也更短,因为对象都是之前在列表中预先计算的。分析结果确认PyLong_FromLong不会在定时循环中调用。因此,range在这种情况下,CPython 3.12 的速度较慢

\n

其余的开销很小(<4%)。这种性能差距可能来自编译器优化,甚至是 CPython 源代码中非常微小的更改。例如,条件跳转地址等简单的事情可能会显着影响许多 Intel CPU 的性能结果(请参阅:JCC 勘误表)。像这样的小细节很重要,而且编译器并不完美。这就是为什么这种性能变化很常见并且是预料之中的,因此不值得调查。

\n

顺便说一句,如果您关心性能,那么请使用Cython 或 PyPy来执行此类计算代码。

\n