Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 709 – 内联推导式

作者:
Carl Meyer <carl at oddbird.net>
赞助商:
Guido van Rossum <guido at python.org>
讨论地址:
Discourse 帖子
状态:
最终版
类型:
标准跟踪
创建日期:
2023 年 2 月 24 日
Python 版本:
3.12
历史记录:
2023 年 2 月 25 日
决议:
Discourse 消息

目录

摘要

推导式目前被编译为嵌套函数,这提供了推导式迭代变量的隔离,但在运行时效率低下。本 PEP 提出将列表、字典和集合推导式内联到它们定义的代码中,并通过在堆栈上压入/弹出冲突的局部变量来提供预期的隔离。此更改使推导式速度显著提高:对于仅包含推导式的微基准测试,速度提高了 2 倍,对于从实际代码中提取的示例基准测试,速度提高了 11%,该示例基准测试在执行实际工作时大量使用推导式。

动机

推导式是 Python 语言中一个流行且广泛使用的功能。推导式的嵌套函数编译优化了编译器的简单性,但牺牲了用户代码的性能。可以提供几乎相同的语义(参见 向后兼容性),并为所有推导式用户提供更好的运行时性能,只需要略微增加编译器复杂性。

基本原理

内联是许多语言中常见的编译器优化。在 Python 中,在编译时对函数调用进行泛化内联几乎是不可能的,因为调用目标可能在运行时被修补。推导式是一个特殊情况,我们有一个在编译器中静态知道的调用目标,它既不能被修补(除非直接对字节码进行未公开且不支持的修改),也不能转义。

内联还允许对字节码进行其他编译器优化,因为它们现在可以“看到”推导式字节码,而不是将其视为一个不透明的调用。

通常,性能改进不需要 PEP。在这种情况下,最简单、最有效的实现会导致一些用户可见的效果,因此这不仅仅是性能改进,而是对语言的(小)更改。

规范

给定一个简单的推导式

def f(lst):
    return [x for x in lst]

编译器目前为函数 f 发出以下字节码

1           0 RESUME                   0

2           2 LOAD_CONST               1 (<code object <listcomp> at 0x...)
            4 MAKE_FUNCTION            0
            6 LOAD_FAST                0 (lst)
            8 GET_ITER
           10 CALL                     0
           20 RETURN_VALUE

Disassembly of <code object <listcomp> at 0x...>:
2           0 RESUME                   0
            2 BUILD_LIST               0
            4 LOAD_FAST                0 (.0)
      >>    6 FOR_ITER                 4 (to 18)
           10 STORE_FAST               1 (x)
           12 LOAD_FAST                1 (x)
           14 LIST_APPEND              2
           16 JUMP_BACKWARD            6 (to 6)
      >>   18 END_FOR
           20 RETURN_VALUE

推导式的字节码在单独的代码对象中。每次调用 f() 时,都会分配一个新的单次使用函数对象(通过 MAKE_FUNCTION),调用它(在 Python 堆栈上分配然后销毁一个新帧),然后立即丢弃。

根据本 PEP,编译器将改为为 f() 发出以下字节码

1           0 RESUME                   0

2           2 LOAD_FAST                0 (lst)
            4 GET_ITER
            6 LOAD_FAST_AND_CLEAR      1 (x)
            8 SWAP                     2
           10 BUILD_LIST               0
           12 SWAP                     2
      >>   14 FOR_ITER                 4 (to 26)
           18 STORE_FAST               1 (x)
           20 LOAD_FAST                1 (x)
           22 LIST_APPEND              2
           24 JUMP_BACKWARD            6 (to 14)
      >>   26 END_FOR
           28 SWAP                     2
           30 STORE_FAST               1 (x)
           32 RETURN_VALUE

不再有单独的代码对象,也不再创建单次使用函数对象,也不需要创建和销毁 Python 帧。

x 迭代变量的隔离是通过新的 LOAD_FAST_AND_CLEAR 操作码(偏移量为 6)和 30 STORE_FAST 的组合来实现的,该操作码在运行推导式之前将 x 的任何外部值保存在堆栈上,并在运行推导式之后恢复 x 的外部值(如果有)。

如果推导式访问外部作用域中的变量,则内联避免了将这些变量放入单元格的需要,允许推导式(以及外部函数中的所有其他代码)以正常的快速局部变量的形式访问它们。这提供了进一步的性能提升。

在某些情况下,推导式迭代变量可能是在外部作用域中全局变量、单元变量或自由变量,而不是简单的函数局部变量。在这些情况下,编译器在进入/离开推导式时也会在内部压入和弹出变量的作用域信息,以维护语义。例如,如果变量在推导式外部是全局变量,则在推导式外部引用它时仍然会使用 LOAD_GLOBAL,但在推导式内部会使用 LOAD_FAST / STORE_FAST。如果它是在推导式外部的单元变量/自由变量,用于保存/恢复它的 LOAD_FAST_AND_CLEAR / STORE_FAST 不会改变(没有 LOAD_DEREF_AND_CLEAR),这意味着整个单元格(而不仅仅是其中的值)将被保存/恢复,因此推导式不会写入外部单元格。

出现在模块或类作用域中的推导式也会被内联。在这种情况下,推导式将只在推导式内部引入快速局部变量的使用(LOAD_FAST / STORE_FAST),在一个作用域中,否则将仅使用 LOAD_NAME / STORE_NAME,从而保持隔离。

实际上,推导式引入了一个子作用域,其中局部变量是完全隔离的,但没有调用带来的性能成本或堆栈帧条目。

在本 PEP 的参考实现中,生成器表达式目前没有被内联。将来,一些生成器表达式可能会被内联,其中返回的生成器对象不会泄漏。

异步推导式与同步推导式一样被内联;不需要特殊处理。

向后兼容性

推导式内联将导致以下可见的行为变化。标准库或测试套件中没有必要进行更改以适应实现中的这些更改,这表明用户代码中的影响可能很小。

依赖于编译器字节码输出未公开细节的专用工具可能会受到超出以下内容的影响,但这些工具必须适应每个 Python 版本中的字节码更改。

locals() 包含外部变量

在推导式中调用 locals() 将包括包含推导式的函数的所有局部变量。例如,给定以下函数

def f(lst):
    return [locals() for x in lst]

在当前 Python 中调用 f([1]) 将返回

[{'.0': <list_iterator object at 0x7f8d37170460>, 'x': 1}]

其中 .0 是内部实现细节:推导式“函数”的合成唯一参数。

根据本 PEP,它将改为返回

[{'lst': [1], 'x': 1}]

这现在包括外部 lst 变量作为局部变量,并消除了合成 .0

跟踪堆栈中没有推导式帧

根据本 PEP,推导式将不再在跟踪堆栈中拥有自己的专用帧。例如,给定此函数

def g():
    raise RuntimeError("boom")

def f():
    return [g() for x in [1]]

目前,调用 f() 会导致以下跟踪堆栈

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in f
  File "<stdin>", line 5, in <listcomp>
  File "<stdin>", line 2, in g
RuntimeError: boom

请注意 <listcomp> 的专用帧。

根据本 PEP,跟踪堆栈将改为如下所示

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in f
  File "<stdin>", line 2, in g
RuntimeError: boom

不再有推导式的额外帧。f 函数的帧具有推导式的正确行号,因此这只是使跟踪堆栈更紧凑,而不会丢失任何有用的信息。

理论上,使用 stacklevel 参数的代码可能会观察到由于帧堆栈更改而导致的行为更改。然而,在实践中,这似乎不太可能。这将需要在库代码中引发警告,该代码始终通过同一库中的推导式调用,其中警告使用 3+ 的 stacklevel 来绕过推导式及其包含函数,并指向库外部的调用帧。在这种情况下,通常在更靠近调用代码的地方引发警告并绕过更少的帧会更简单、更可靠。

跟踪/性能分析将不再显示推导式的调用/返回

当然,由于列表/字典/集合推导式将不再实现为对嵌套函数的调用,因此使用 sys.settracesys.setprofile 的跟踪/性能分析也将不再反映已发生调用和返回。

对其他 Python 实现的影响

根据来自 GraalPythonPyPy 代表的意见,他们很可能觉得有必要适应这里观察到的行为变化,因为有人在某个时候可能会依赖它们。因此,在所有条件相同的情况下,较少的观察到的变化将意味着较少的工作。但这些变化(至少在 GraalPython 的情况下)应该在“没有太多麻烦”的情况下进行管理。

如何教学

推导式语法会导致或应该导致创建和调用嵌套函数,这并不直观。对于尚未习惯先前行为的新用户,我认为本 PEP 中的新行为将更直观,需要更少的解释。(“为什么当我未定义任何此类函数时,我的跟踪堆栈中会出现 <listcomp> 行?我在 locals() 中看到的 .0 变量是什么?")

安全影响

目前没有。

参考实现

本 PEP 的参考实现采用 针对 CPython 主分支的 PR 的形式,该 PR 通过了所有测试。

参考实现执行微基准测试 ./python -m pyperf timeit -s 'l = [1]' '[x for x in l]' 的速度比 main 分支快 1.96 倍(在使用 --enable-optimizations 编译的构建中)。

参考实现使用 comprehensions 基准测试在 pyperformance 基准测试套件中(它不是仅针对列表推导的微基准测试,而是测试使用列表推导进行现实世界工作的真实代码)比 main 分支快 11%(同样是在优化构建中)。pyperformance 中的其他基准测试(没有一个大量使用列表推导)在噪音之外没有显示任何影响。

该实现对非列表推导代码没有影响。

被拒绝的想法

更有效的推导式调用,不进行内联

一个 替代方法 引入了一个新的操作码用于以简化方式“调用”列表推导,无需创建临时函数对象,但仍然会创建一个新的 Python 帧。这避免了在 向后兼容性 下列出的所有可见影响,并且提供了大约一半的性能提升(微基准测试提高了 1.5 倍,pyperformance 中 comprehensions 基准测试提高了 4%)。它还要求向 _PyInterpreterFrame 结构体添加一个新的指针,并在每个帧构造时添加一个新的 Py_INCREF,这意味着(与本 PEP 不同)它对所有代码都有一定的(非常小)性能成本。它还提供了较少的未来优化空间。

本 PEP 认为完全内联提供的额外性能足以证明行为更改是合理的。


来源: https://github.com/python/peps/blob/main/peps/pep-0709.rst

最后修改: 2023-12-15 15:06:12 GMT