我在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 += b
或a = 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)性能。