Python 3.11 的优化比 3.10 差?

Mic*_*ael 22 python optimization performance python-3.10 python-3.11

我在 Windows 10 上使用 Python 3.10.7 和 3.11.0 运行这个简单的循环。

import time
a = 'a'

start = time.time()
for _ in range(1000000):
    a += 'a'
end = time.time()

print(a[:5], (end-start) * 1000)
Run Code Online (Sandbox Code Playgroud)

旧版本执行时间为 187ms,Python 3.11 需要大约 17000ms。3.10 是否意识到只需要 的前 5 个字符a,而 3.11 执行整个循环?我在 godbolt 上证实了这种性能差异。

Jér*_*ard 22

TL;DR:您不应该在任何性能关键代码中使用这样的循环,但是\'\'.join。执行效率低下似乎与 CPython 3.11 中字节码生成期间的回归有关(以及在对 Unicode 字符串进行二进制加法运算时缺少优化)。

\n
\n

一般准则

\n

这是一种反模式。如果您希望速度更快,则不应编写这样的代码。PEP-8中对此进行了描述

\n
\n

代码的编写方式不应损害 Python 的其他实现(PyPy、Jython、IronPython、Cython、Psyco 等)。
\n例如,不要依赖 CPython\xe2\x80\x99s 对形式的语句进行就地字符串连接的高效实现a += ba = a + b。即使在 CPython 中,这种优化也是脆弱的(它仅适用于某些类型),并且在不使用引用计数的实现中根本不存在 xe2x80x99。在库的性能敏感部分,\'\'.join()应该使用表单。这将确保在各种实现中串联在线性时间内发生。

\n
\n

事实上,例如 PyPy 等其他实现并不能执行有效的就地字符串连接。每次迭代都会创建一个更大的新字符串(由于字符串是不可变的,因此可以引用前一个字符串,并且 PyPy 不使用引用计数而是使用垃圾收集器)。这会导致二次运行时间,而不是 CPython 中的线性运行时间(至少在过去的实现中)。

\n
\n

深度分析

\n

我可以在 Windows 10 的嵌入式(64 位 x86-64)版本的 CPython 3.10.8和3.11.0版本之间重现该问题:

\n
Timings:\n - CPython 3.10.8:    146.4 ms\n - CPython 3.11.0:  15186.8 ms\n
Run Code Online (Sandbox Code Playgroud)\n

事实证明,在 Unicode 字符串附加方面,CPython 3.10 和 3.11 之间的代码没有特别变化。例如参见PyUnicode_Append3.103.11

\n

低级分析分析表明,几乎所有时间都花费在由另一个未命名函数调用的一个未命名函数上PyUnicode_Concat(该函数在 CPython 3.10.8 和 3.11.0 之间也未进行修改)。这个缓慢的未命名函数包含一小部分汇编指令,几乎所有时间都花在一个独特的 x86-64 汇编指令上:rep movsb byte ptr [rdi], byte ptr [rsi]。该指令基本上意味着将寄存器指向的缓冲区复制rsi到寄存器指向的缓冲区rdi(处理器rcx将源缓冲区的字节复制到目标缓冲区,并rcx为每个字节递减寄存器,直到达到 0)。此信息表明,未命名的函数实际上是memcpy标准 MSVC C 运行时(即 CRT)的函数,它似乎是由of_copy_characters调用的(所有函数仍然属于同一文件)。但是,这些 CPython 函数在 CPython 3.10.8 和 3.11.0 之间仍然保持不变。在 malloc/free 中花费的不可忽略的时间(大约 0.3 秒)似乎表明创建了很多新的字符串对象——当然每次迭代至少有 1 个——与. 所有这些都表明创建并复制了一个新的更大的字符串,如上面指定的。_PyUnicode_FastCopyCharactersPyUnicode_ConcatPyUnicode_NewPyUnicode_Concat

\n

调用的东西PyUnicode_Concat肯定是这里性能问题的根源,我认为 CPython 3.10.8 更快,因为它肯定调用PyUnicode_Append。这两个调用都直接由主大解释器评估循环执行,并且该循环由生成的字节码驱动。

\n

事实证明,两个版本生成的字节码不同,这是性能问题的根源。事实上,CPython 3.10 生成INPLACE_ADD字节码指令,而 CPython 3.11 生成字节码指令 BINARY_OP字节码指令。这是两个版本中循环的字节码:

\n
CPython 3.10 loop:\n\n        >>   28 FOR_ITER                 6 (to 42)\n             30 STORE_NAME               4 (_)\n  6          32 LOAD_NAME                1 (a)\n             34 LOAD_CONST               2 (\'a\')\n             36 INPLACE_ADD                             <----------\n             38 STORE_NAME               1 (a)\n             40 JUMP_ABSOLUTE           14 (to 28)\n\nCPython 3.11 loop:\n\n        >>   66 FOR_ITER                 7 (to 82)\n             68 STORE_NAME               4 (_)\n  6          70 LOAD_NAME                1 (a)\n             72 LOAD_CONST               2 (\'a\')\n             74 BINARY_OP               13 (+=)         <----------\n             78 STORE_NAME               1 (a)\n             80 JUMP_BACKWARD            8 (to 66)\n
Run Code Online (Sandbox Code Playgroud)\n

此更改似乎来自此问题。两个 CPython 版本之间的主解释器循环代码(请参阅 ceval.c)是不同的。以下是两个版本执行的代码:

\n
Timings:\n - CPython 3.10.8:    146.4 ms\n - CPython 3.11.0:  15186.8 ms\n
Run Code Online (Sandbox Code Playgroud)\n

请注意unicode_concatenate调用PyUnicode_Append(并之前进行一些引用计数检查)。最后,CPython 3.10.8 调用PyUnicode_Append速度快(就地),CPython 3.11.0 调用速度快PyUnicode_Concat速度慢(异地)。对我来说,这显然像是一种回归。

\n

评论中的人表示 Linux 上没有性能问题。然而,实验测试表明,BINARY_OP在 Linux 上也生成了一条指令,而且到目前为止我还找不到任何 Linux 特定的关于字符串连接的优化。因此,平台之间的差异非常令人惊讶。

\n
\n

更新:寻求修复

\n

我已经在这里提出了一个关于此问题的问题。由于变量是本地的(正如 @Dennis 在评论中所指出的),人们不应该将代码放入函数中明显更快。

\n
\n

相关文章:

\n\n