【问题标题】:Python 3.11 worse optimized than 3.10?Python 3.11 比 3.10 优化得更差?
【发布时间】:2022-11-30 03:56: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)

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

【问题讨论】:

  • 在 Python 3.11.0 上测试,在 Linux 上运行,结果是144.66238021850586
  • 在我看来,与语言版本相比,它与操作系统及其 Python 实现更相关。
  • 它似乎是 Windows 特有的,奇怪的是只是将代码包装在一个函数中,它的运行时间与 python 3.10 大致相同
  • 这里可能有一些有趣的讨论:stackoverflow.com/questions/3055477和这里stackoverflow.com/questions/1349311

标签: python performance optimization python-3.10 python-3.11


【解决方案1】:

长话短说:你不应该在任何性能关键代码中使用这样的循环,而应该使用''.join。执行效率低下似乎与 CPython 3.11 中字节码生成期间的回归有关(以及在评估 Unicode 字符串上的二进制添加操作期间缺少优化)。


一般准则

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

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

事实上,PyPy 等其他实现并没有执行高效的就地字符串连接。每次迭代都会创建一个更大的新字符串(因为字符串是不可变的,所以可能会引用前一个字符串并且 PyPy 不使用引用计数而是使用 garbage collector)。这导致二次运行时间与 CPython 中的线性运行时间相反(至少在过去的实现中)。


深入分析

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

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

事实证明,当涉及到 Unicode 字符串附加时,代码在 CPython 3.10 和 3.11 之间没有特别变化。参见例如PyUnicode_Append3.103.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 中指出的那样)。


相关文章:

【讨论】:

  • 请注意,Py3.11 中有一个BINARY_OP_INPLACE_ADD_UNICODE,但它仅适用于局部变量,不适用于模块全局变量。
猜你喜欢
  • 2022-11-03
  • 2023-01-12
  • 2023-01-20
  • 1970-01-01
  • 2014-04-30
  • 2014-10-14
  • 1970-01-01
  • 1970-01-01
  • 2022-12-02
相关资源
最近更新 更多