代码的编写方式应不损害 Python 的其他实现(PyPy、Jython、IronPython、Cython、Psyco 等)。
例如,不要依赖 CPython 对 a += b 或 a = a + b 形式的语句的就地字符串连接的高效实现.这种优化即使在 CPython 中也是脆弱的(它只适用于某些类型)并且在不使用引用计数的实现中根本不存在。在库的性能敏感部分,应改用''.join() 表格.这将确保串联发生在线性时间跨各种实现。
事实上,PyPy 等其他实现并没有执行高效的就地字符串连接。每次迭代都会创建一个更大的新字符串(因为字符串是不可变的,所以可能会引用前一个字符串并且 PyPy 不使用引用计数而是使用 garbage collector)。这导致二次运行时间与 CPython 中的线性运行时间相反(至少在过去的实现中)。
深入分析
我可以在 Windows 10 的嵌入式(64 位 x86-64)版本的 CPython 3.10.8 和 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 调用,它本身由 PyUnicode_Concat 的 _PyUnicode_FastCopyCharacters 调用(所有函数仍属于同一个文件)。但是,这些 CPython 函数在 CPython 3.10.8 和 3.11.0 之间仍未修改。在 malloc/free 中花费的不可忽略的时间(大约 0.3 秒)似乎表明创建了很多新的字符串对象——当然每次迭代至少 1 个——与 @ 的代码中对 PyUnicode_New 的调用相匹配987654349@。所有这些都表明一个新的更大的字符串被创建并按照上面指定的方式被复制。
调用 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)
此更改似乎来自this issue。主解释器循环的代码(参见 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 调用 PyUnicode_Concat 是慢速的(异地)。对我来说,这显然是一种倒退。
cmets 中的人员报告说在 Linux 上没有性能问题。然而,实验测试显示在 Linux 上也会生成 BINARY_OP 指令,到目前为止我找不到任何关于字符串连接的特定于 Linux 的优化。因此,平台之间的差异非常令人惊讶。
更新:修复
我已经打开了一个关于这个可用的问题here。不应该那样将代码放在函数中要快得多由于变量是本地的(正如@Dennis 在 cmets 中指出的那样)。
相关文章: