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帧。
通过在偏移量 6 处的新操作码 LOAD_FAST_AND_CLEAR,在运行推导式之前将 x 的任何外部值保存在栈上,以及 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 参数的警告的代码可能会由于帧栈更改而观察到行为变化。然而,实际上,这似乎不太可能。它需要库代码中引发的警告,该警告始终通过同一库中的推导式调用,并且该警告使用 stacklevel 值为3+来绕过推导式及其包含函数,并指向库外部的调用帧。在这种情况下,通常更简单可靠的方法是在更接近调用代码的地方引发警告,并绕过更少的帧。
跟踪/性能分析将不再显示推导式的调用/返回
当然,由于列表/字典/集合推导式将不再作为嵌套函数的调用实现,使用 sys.settrace 或 sys.setprofile 进行跟踪/性能分析也将不再反映发生了调用和返回。
对其他 Python 实现的影响
根据 GraalPython 和 PyPy 代表的评论,他们可能需要适应这里可观察到的行为变化,因为在某些时候,有人会依赖它们。因此,在其他条件相同的情况下,可观察到的变化越少,工作量就越少。但是这些变化(至少在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 编译的构建中)。
参考实现在 pyperformance 基准测试套件中执行 comprehensions 基准测试(它并非仅仅是推导式的微基准测试,而是测试使用推导式执行实际工作的真实世界代码),比 main 分支快11%(同样是在优化构建中)。pyperformance 中的其他基准测试(没有大量使用推导式的)没有显示出噪音之外的任何影响。
该实现对非推导式代码没有影响。
被拒绝的想法
更高效的推导式调用,无需内联
一种替代方法引入了一个新的操作码,用于以流线型方式“调用”推导式,无需创建一次性函数对象,但仍会创建新的Python帧。这避免了向后兼容性下所列的所有可见效果,并提供了大约一半的性能优势(微基准测试提高了1.5倍,pyperformance中的comprehensions基准测试提高了4%)。它还要求向_PyInterpreterFrame结构添加一个新的指针,并在每次帧构造时进行新的Py_INCREF,这意味着(与本PEP不同)它对所有代码都有一个(非常小)的性能开销。它也为未来的优化提供了较小的空间。
本PEP认为,完全内联提供了足够的额外性能,足以证明行为更改是合理的。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0709.rst