在Python 3.12中,循环中局部变量的缓存为何在使用exec() 与通过局部作用域赋值时表现不同?
我在研究Python 3.12的一些字节码优化,涉及循环性能和局部变量查找(LOAD_FAST vs. LOAD_DEREF)。我注意到在本地函数作用域内动态求值的代码与在标准循环中执行时,出现了一个离奇的性能差异。
请看这两种设置。两者都在尝试执行一个紧凑的循环,更新一个局部变量,但设置B 使用 exec() 在同一个本地上下文中动态运行内部逻辑。在Python 3.12.2上,设置B 的运行大约慢于设置A 3到 4倍。
import timeit
# Setup A: Standard local loop
def test_standard():
x = 0
for _ in range(10_000_000):
x += 1
return x
# Setup B: Executing loop logic dynamically
def test_dynamic():
x = 0
local_vars = {'x': x}
# Running the exact same loop structure inside exec
exec("""
for _ in range(10_000_000):
x += 1
""", globals(), local_vars)
return local_vars['x']
print("Standard:", timeit.timeit(test_standard, number=1))
print("Dynamic (exec):", timeit.timeit(test_dynamic, number=1))
我对两者都运行了 dis.dis,看看编译器在幕后到底在做什么。对于 test_standard,Python使用 LOAD_FAST 和 STORE_FAST 指令对循环进行了完全优化,因为它把 x 严格映射到本地命名空间数组:
6 22 LOAD_FAST 0 (x)
24 LOAD_CONST 2 (1)
26 BINARY_OP 0 (+)
30 STORE_FAST 0 (x)
然而,在检查在 exec() 内生成的代码对象时,尽管它被传入了一个专用的 local_vars 字典,它仍然默认使用 LOAD_NAME 和 STORE_NAME。
问题:
- 自从Python 3.11+引入了专门化的自适应解释器,为什么在检测到类型/字典结构不再改变时,自适应解释器没有把
LOAD_NAME优化成位于exec()代码块中的本地快速路径? - 是否存在严格的体系结构原因,导致
exec()代码对象即使提供了明确、隔离的locals字典,也根本被禁止使用LOAD_FAST优化机制?
解决方案
为什么不使用 LOAD_FAST
就问题2 的回答而言,根级执行的代码无法使用 LOAD_FAST 和 STORE_FAST 指令。这是因为它的执行方式像一个模块对象。因此,它总是使用 LOAD_NAME 和 STORE_NAME 指令。LOAD_FAST 和 STORE_FAST 不能作为自适应指令来适配 *_NAME 指令,因为它们处理的是不同的数据结构。*_FAST 处理的是数组,*_NAME 处理的是映射。如果你想实现这种专门化,需要把底层数据存储从映射改为数组。你还需要修改任何使用模块命名空间来作为全局变量的函数。否则它们会把数组当成映射来访问。最好的情况会崩溃,最坏可能出现隐藏的错误。不过,由于模块可能再也没有这些函数的句柄,这种情况不可能实现。因此代码必须继续使用 *_NAME 指令。
你所期望的加速也可以通过把代码包装在一个函数中来轻松实现。这样,解释器会自动使用 *_FAST 指令,而不需要复杂的逻辑去改造 *_NAME 指令。例如:
def test_dynamic_fast():
locals_ = {}
exec("""
def f(): # <-- code wrapped by function f
x = 0
for _ in range(10_000_000):
x += 1
return x
""", locals=locals_)
# extract function from namespace and execute it
return locals_['f']()
为什么你没有看到自适应指令
就问题1 的回答,自适应指令是对给定指令的专门化版本,它们不能改变指令的类型。例如,LOAD_GLOBAL 可以专门化为 LOAD_GLOBAL_BUILTIN,但不能专门化为 LOAD_FAST(这里用 LOAD_GLOBAL,因为没有适用于 LOAD_NAME 的自适应指令)。这是因为 LOAD_GLOBAL_BUILTIN 针对的是 LOAD_GLOBAL 处理的一部分输入(也就是说,它跳过模块的全局变量,直接针对内置模块)。然而,LOAD_FAST 处理的是另一组输入(即它处理的是数组而非映射)。
如果你想看到正在使用的自适应指令,需要做两件事。第一,需要一个长期存在的代码对象。指令在至少执行几次之前不能被专门化。当你 exec 一个字符串时,exec 会把字符串编译成代码对象,执行该对象,然后丢弃它。因此你无法检查该代码对象以了解进行了哪些优化。第二,需要让 dis 给你返回自适应指令。默认情况下,它给出的是标准的、非专门化的指令。示例:
x = 0
src = 'x += 1'
# create a long lived code object, so we can see what adaptions are made
code = compile(src, '<string>', mode='exec')
# code not yet run, so no adaptions have been made
dis.dis(code, adaptive=True)
# 0 RESUME 0 <-- standard
#
# 1 LOAD_NAME 0 (x)
# LOAD_SMALL_INT 1
# BINARY_OP 13 (+=) <-- standard
# STORE_NAME 0 (x)
# LOAD_CONST 1 (None) <-- standard
# RETURN_VALUE
# run code a few times
for _ in range(10):
exec(code)
assert x == 10 # check code really did run ten times
# see what adaptions have been made
dis.dis(code, adaptive=True)
# 0 RESUME_CHECK 0 <-- adaptive
#
# 1 LOAD_NAME 0 (x)
# LOAD_SMALL_INT 1
# BINARY_OP_ADD_INT 13 (+=) <-- adaptive
# STORE_NAME 0 (x)
# LOAD_CONST_IMMORTAL 1 (None) <-- adaptive
# RETURN_VALUE
在这个例子中,你可以看到 x += 1(BINARY_OP)已被专门化为 BINARY_OP_ADD_INT,因为它仅仅做这件事——把整数加在一起。