Python 3.11比3.10优化得更差



我在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)

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

TL;DR:您不应该在任何性能关键代码中使用这样的循环,而应该使用''.join。低效的执行似乎与CPython 3.11中字节码生成过程中的回归有关(以及在评估Unicode字符串的二进制加法操作过程中缺少优化)。


一般指南

这是一个反模式。如果你想写得快,就不应该写这样的代码。这在PEP-8:中有描述

代码的编写方式应不影响Python的其他实现(PyPy、Jython、IronPython、Cython、Psyco等)
例如,不依赖CPython对形式为a += ba = a + b的语句的就地字符串串联的有效实现。这种优化即使在CPython中也是脆弱的(它只适用于某些类型),并且在不使用引用计数的实现中根本不存在。在库的性能敏感部分,应使用''.join()形式。这将确保在线性时间中跨各种实现进行串联。

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


深度分析

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

Timings:
- CPython 3.10.8:    146.4 ms
- CPython 3.11.0:  15186.8 ms

事实证明,当涉及到Unicode字符串附加时,代码在CPython 3.10和3.11之间并没有特别的变化。参见示例PyUnicode_Append:3.10和3.11。

低级概要分析显示,几乎所有的时间都花在PyUnicode_Concat调用的另一个未命名函数的一个未名称函数调用上(在CPython 3.10.8和3.11.0之间也未进行修改)。这个缓慢的未命名函数包含一组相当小的汇编指令,几乎所有时间都用在一个唯一的x86-64汇编指令上:rep movsb byte ptr [rdi], byte ptr [rsi]。该指令基本上是将rsi寄存器指向的缓冲器复制到rdi寄存器指向的缓冲区(处理器将源缓冲区的rcx字节复制到目标缓冲区,并为每个字节递减rcx寄存器,直到其达到0)。该信息表明,未命名的函数实际上是标准MSVC C运行时(即CRT)的memcpy,它似乎是由_copy_characters本身调用的,CCD_14由PyUnicode_Concat_PyUnicode_FastCopyCharacters调用(所有函数仍然属于同一文件)。然而,在CPython 3.10.8和3.11.0之间,这些CPython函数仍然没有修改。malloc/free中花费的不可忽略的时间(约0.3秒)似乎表明创建了许多新的字符串对象——当然每次迭代至少1个——与PyUnicode_Concat代码中对PyUnicode_New的调用相匹配。所有这些都表明一个新的更大的字符串被创建并复制,如上所述。

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

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

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

这些变化似乎来自于这个问题。主解释器循环的代码(参见ceval.c)在两个CPython版本之间有所不同。以下是两个版本执行的代码:

// In CPython 3.10.8
case TARGET(INPLACE_ADD): {
PyObject *right = POP();
PyObject *left = TOP();
PyObject *sum;
if (PyUnicode_CheckExact(left) && PyUnicode_CheckExact(right)) {
sum = unicode_concatenate(tstate, left, right, f, next_instr); // <-----
/* unicode_concatenate consumed the ref to left */
}
else {
sum = PyNumber_InPlaceAdd(left, right);
Py_DECREF(left);
}
Py_DECREF(right);
SET_TOP(sum);
if (sum == NULL)
goto error;
DISPATCH();
}
//----------------------------------------------------------------------------
// In CPython 3.11.0
TARGET(BINARY_OP_ADD_UNICODE) {
assert(cframe.use_tracing == 0);
PyObject *left = SECOND();
PyObject *right = TOP();
DEOPT_IF(!PyUnicode_CheckExact(left), BINARY_OP);
DEOPT_IF(Py_TYPE(right) != Py_TYPE(left), BINARY_OP);
STAT_INC(BINARY_OP, hit);
PyObject *res = PyUnicode_Concat(left, right); // <-----
STACK_SHRINK(1);
SET_TOP(res);
_Py_DECREF_SPECIALIZED(left, _PyUnicode_ExactDealloc);
_Py_DECREF_SPECIALIZED(right, _PyUnicode_ExactDealloc);
if (TOP() == NULL) {
goto error;
}
JUMPBY(INLINE_CACHE_ENTRIES_BINARY_OP);
DISPATCH();
}

请注意,unicode_concatenate调用PyUnicode_Append(并且之前进行了一些引用计数检查)。最后,CPython 3.10.8调用快速(到位)的PyUnicode_Append,而CPython 3.11.0调用慢速(错位)的CCD26。在我看来,这显然是一种倒退

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


更新:走向修复

我在这里打开了一个关于这个的问题。我们不应该认为将代码放入函数中明显更快,因为变量是局部的(正如@Dennis在评论中指出的)。


相关帖子:

  • Python有多慢';s字符串串联与str.join
  • Python字符串';加入';比'+';,但是什么';这里错了吗
  • Python字符串串联for循环到位吗

正如另一个答案中所提到的,这确实是一个回归,但在Python 3.12中不会修复,来自GitHub问题:

我们没有实现寄存器VM,因此3.12+中的性能将类似于3.11。将迭代移到函数中将恢复n-ln(n)性能。

相关内容

  • 没有找到相关文章

最新更新