本节目标:能从字节码层面看到自适应特化「确实发生了」,并准确判断本机解释器是否启用了 JIT。
适用版本:Python 3.12+(实测 3.14.6)
10.3 自适应特化与 JIT 现状
3.3 实验性 JIT 与自适应解释器
已经讲过执行引擎的分层设计(Tier 1 专门化字节码 → Tier 2 微操作 → copy-and-patch 机器码)与 JIT 的版本演进。本节不重复那套架构,而是把镜头拉近:专门化到底把哪条指令改成了什么、缓存在哪里、为什么会来回抖动——这些都是可以在本机用 dis 和 opcode 直接看到的事实。
10.3.1 先查本机状态:JIT 与 GIL
动手前先确认解释器底细,避免凭版本号猜测:
import sys, sysconfig
print("Py_JIT :", sysconfig.get_config_var("Py_JIT"))
print("is_available :", sys._jit.is_available())
print("is_enabled :", sys._jit.is_enabled())
print("is_active :", sys._jit.is_active())
print("sys._is_gil_enabled:", sys._is_gil_enabled())
args = sysconfig.get_config_var("CONFIG_ARGS") or ""
print("experimental-jit:", "experimental-jit" in args)
print("tail-call :", "tail-call" in args)
print("optimizations :", "enable-optimizations" in args)
print("lto :", "with-lto" in args)
真实输出(本机 Homebrew 标准构建,3.14.6):
Py_JIT : None
is_available : False
is_enabled : False
is_active : False
sys._is_gil_enabled: True
experimental-jit: False
tail-call : False
optimizations : True
lto : True
读法:Py_JIT 为 None、三个 sys._jit 函数全为 False——本机没有编译进 JIT;sys._is_gil_enabled() 为 True——是标准(带 GIL)构建,不是自由线程构建。CONFIG_ARGS 里只有 --enable-optimizations 和 --with-lto,没有 --enable-experimental-jit,也没有 --with-tail-call-interp。所以本节不提供任何 JIT 性能数字——跑不到的东西编不出可信数据。下面的实测全部针对「自适应特化」,它在本机是默认开启的。
10.3.2 dis(adaptive=True):冷热字节码对比
dis.dis() 默认展示未专门化的通用指令。加 adaptive=True 才会显示运行时被改写成什么。定义一个有属性访问的函数,先冷启动看一次:
import dis
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def norm(p):
return p.x * p.x + p.y * p.y
dis.dis(norm, adaptive=True) # 冷启动
真实输出(冷):
8 RESUME 0
9 LOAD_FAST_BORROW 0 (p)
LOAD_ATTR 0 (x)
LOAD_FAST_BORROW 0 (p)
LOAD_ATTR 0 (x)
BINARY_OP 5 (*)
LOAD_FAST_BORROW 0 (p)
LOAD_ATTR 2 (y)
LOAD_FAST_BORROW 0 (p)
LOAD_ATTR 2 (y)
BINARY_OP 5 (*)
BINARY_OP 0 (+)
RETURN_VALUE
然后预热 10 万次(始终传 Point),再反汇编同一个函数:
for _ in range(100_000):
norm(Point(1, 2))
dis.dis(norm, adaptive=True)
真实输出(热):
8 RESUME_CHECK 0
9 LOAD_FAST_BORROW 0 (p)
LOAD_ATTR_INSTANCE_VALUE 0 (x)
LOAD_FAST_BORROW 0 (p)
LOAD_ATTR_INSTANCE_VALUE 0 (x)
BINARY_OP_MULTIPLY_INT 5 (*)
LOAD_FAST_BORROW 0 (p)
LOAD_ATTR_INSTANCE_VALUE 2 (y)
LOAD_FAST_BORROW 0 (p)
LOAD_ATTR_INSTANCE_VALUE 2 (y)
BINARY_OP_MULTIPLY_INT 5 (*)
BINARY_OP_ADD_INT 0 (+)
RETURN_VALUE
四处改写一目了然:
| 冷指令 | 热指令 | 省掉了什么 |
|---|---|---|
RESUME | RESUME_CHECK | 每帧一次的重入检查,热路径只需查一次标志 |
LOAD_ATTR | LOAD_ATTR_INSTANCE_VALUE | 通用属性查找(含描述符协议)→ 直接读实例的 __dict__ 槽位 |
BINARY_OP (*) | BINARY_OP_MULTIPLY_INT | 通用运算符分发 → 直取整数乘法 |
BINARY_OP (+) | BINARY_OP_ADD_INT | 同上 |
这就是 PEP 659「自适应专门化」的全部效果——同一份字节码,在运行时被就地改写成更特化的版本,不需要任何源码改动。
10.3.3 专门化族注册表:opcode._specializations
opcode 模块暴露了完整的专门化清单(3.14 里键是字符串,不是 opcode 数值):
import opcode
sp = opcode._specializations
print("专门化族数量:", len(sp))
print("专门化指令总数:", sum(len(v) for v in sp.values()))
print("LOAD_ATTR:", sp["LOAD_ATTR"])
print("CALL :", sp["CALL"])
真实输出:
专门化族数量: 17
专门化指令总数: 84
LOAD_ATTR: ['LOAD_ATTR_INSTANCE_VALUE', 'LOAD_ATTR_MODULE', 'LOAD_ATTR_WITH_HINT',
'LOAD_ATTR_SLOT', 'LOAD_ATTR_CLASS', 'LOAD_ATTR_CLASS_WITH_METACLASS_CHECK',
'LOAD_ATTR_PROPERTY', 'LOAD_ATTR_GETATTRIBUTE_OVERRIDDEN',
'LOAD_ATTR_METHOD_WITH_VALUES', 'LOAD_ATTR_METHOD_NO_DICT',
'LOAD_ATTR_METHOD_LAZY_DICT', 'LOAD_ATTR_NONDESCRIPTOR_WITH_VALUES',
'LOAD_ATTR_NONDESCRIPTOR_NO_DICT']
3.14.6 里共 17 个专门化族、84 条专门化指令。几个族的规模值得记住:
| 基指令 | 专门化变体数 |
|---|---|
CALL | 20 |
BINARY_OP | 15 |
LOAD_ATTR | 13 |
STORE_ATTR | 3 |
COMPARE_OP | 3 |
LOAD_GLOBAL | 2 |
CALL 族最多(20 种),因为它要区分「Python 函数」「内建函数」「方法描述符」「类构造」等一整套调用形态;LOAD_ATTR 的 13 种则对应不同的对象布局。这些名字本身就是一张「CPython 认为哪些模式值得优化」的清单。
10.3.4 CACHE 槽:专门化把什么缓存了下来
专门化不是只改个指令名,它后面还跟着若干 CACHE 槽存运行时信息。用 show_caches=True 展开看:
dis.dis(f, show_caches=True, adaptive=True)
真实输出(节选,f 返回 p.x):
5 LOAD_FAST_BORROW 0 (p)
LOAD_ATTR_INSTANCE_VALUE 0 (x)
CACHE 0 (counter: 832)
CACHE 0 (version: 131253)
CACHE 0
CACHE 0 (keys_version: 24)
CACHE 0
CACHE 0 (descr: 0)
CACHE 0
CACHE 0
CACHE 0
RETURN_VALUE
关键三格:counter: 832 是这条指令的自适应计数器——它数到阈值就把通用指令换成专门化版本;version: 131253 缓存的是类型的版本号,一旦类型被修改(比如动态加了个方法),版本号变化就触发去优化;keys_version 对应实例 __dict__ 的键布局版本。所以专门化缓存的不只是「这是哪种类型」,还有「这个类型的形状有没有变过」。
这解释了 10.3.2 里为什么「预热后就稳定」——只要类型和布局不变,version/keys_version 一直命中,专门化就保持。一旦某个位置反复出现不同类型,版本号来回变,指令就会在「专门化 → 去优化 → 重新专门化」之间抖动,这时自适应反而可能比纯解释更慢。
10.3.5 同一段源码,不同对象布局 → 不同专门化
专门化的分支由对象的实际布局决定。同一句 o.x,喂不同形态的对象,会得到不同的专门化指令:
import dis, types
class DictCls:
def __init__(self): self.x = 1
class SlotCls:
__slots__ = ("x",)
def __init__(self): self.x = 1
def getx(o): return o.x
for _ in range(100_000): getx(DictCls())
print([i.opname for i in dis.get_instructions(getx, adaptive=True)
if i.opname.startswith("LOAD_ATTR")])
def getx2(o): return o.x
mod = types.ModuleType("m"); mod.x = 42
for _ in range(100_000): getx2(mod)
print([i.opname for i in dis.get_instructions(getx2, adaptive=True)
if i.opname.startswith("LOAD_ATTR")])
def getx3(o): return o.x
for _ in range(100_000): getx3(SlotCls())
print([i.opname for i in dis.get_instructions(getx3, adaptive=True)
if i.opname.startswith("LOAD_ATTR")])
真实输出:
['LOAD_ATTR_INSTANCE_VALUE']
['LOAD_ATTR_MODULE']
['LOAD_ATTR_SLOT']
三种对象布局,三条不同的专门化指令:
- 普通实例(有
__dict__)→LOAD_ATTR_INSTANCE_VALUE:直接从实例的__dict__取值。 - 模块对象 →
LOAD_ATTR_MODULE:从模块字典按已知键版本取值。 __slots__实例 →LOAD_ATTR_SLOT:从固定偏移的槽位取值,连__dict__都不用查。
这也是本书 4.2 对象布局、__slots__ 与内存占用测量
那条「__slots__ 省内存」结论的字节码层解释:__slots__ 不只为每个实例省掉一整块 __dict__,还让属性访问走上最直接的 LOAD_ATTR_SLOT 路径。
10.3.6 3.13/3.14 的 JIT 现状与边界
专门化是 JIT 的采样前端:它收集的「这个位置总是什么类型」正是 JIT 决定能否编译成机器码的依据。关于 JIT 的分层架构、copy-and-patch、tail-call 解释器,3.3 实验性 JIT 与自适应解释器 已详述,这里只补充判断口径与边界:
- 判断是否启用 JIT,永远看运行时状态,不看版本号:
sysconfig.get_config_var("Py_JIT")与sys._jit.is_available()/is_enabled()才是事实。3.14 的官方二进制默认包含 JIT,但源码构建(如本机 Homebrew)默认不带,PYTHON_JIT=1与-X jit在这种构建上都不起作用。 - 本机实测确认:
Py_JIT=None、is_available=False、_is_gil_enabled()=True——既无 JIT、也是带 GIL 的标准构建。因此本节不给出任何 JIT 加速比。 - 自适应特化是「默认开启」的真实优化:3.11+ 无需任何配置,热代码自动专门化。代价是它依赖类型稳定——同一位置混用多种类型会导致去优化抖动,这为「热点函数保持类型稳定」提供了字节码层的依据。
- 性能归因要分层:一段代码慢,可能在 Tier 1 通用派发、可能在专门化/去优化抖动、也可能(若启用 JIT)在 JIT 编译本身。
sys._jit.is_active()能判断当前帧是否在跑 JIT 代码,是排障的第一手信息。
一句话:自适应特化今天就在你身边、默认生效且可观测;JIT 还在路上,且是否启用完全取决于解释器是怎么构建的。
小结
- 本机 3.14.6 实测:
Py_JIT=None、sys._jit三个函数全False、_is_gil_enabled()=True——无 JIT、标准 GIL 构建,故本节不含 JIT 加速数据。 dis.dis(func, adaptive=True)能看到冷热差异:RESUME→RESUME_CHECK、LOAD_ATTR→LOAD_ATTR_INSTANCE_VALUE、BINARY_OP→BINARY_OP_MULTIPLY_INT/ADD_INT。opcode._specializations(3.14 键为字符串)共 17 个专门化族、84 条专门化指令;CALL20 种、BINARY_OP15 种、LOAD_ATTR13 种。show_caches=True揭示CACHE槽缓存了counter(自适应计数)、version(类型版本)、keys_version(__dict__布局版本);版本变化即触发去优化。- 同一句
o.x按对象布局分别专门化为LOAD_ATTR_INSTANCE_VALUE/LOAD_ATTR_MODULE/LOAD_ATTR_SLOT——__slots__的收益在字节码层可见。 - 自适应特化默认生效但依赖类型稳定;JIT 是否启用只看构建与运行时状态,不看版本号。
性能工程这条线到此闭环:剖析(10.1)→ 内存(10.2)→ 执行层优化(10.3)。下一章进入本书最后一块——分发与运行时生态。
阅读导航:上一节:10.2 内存优化与数据结构选型 · 下一节:11.1 打包分发内部机制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。