《Python高级编程》10.3 自适应特化与 JIT 现状

用 dis(adaptive=True) 实测同一函数冷启动与预热后的字节码差异(LOAD_ATTR → LOAD_ATTR_INSTANCE_VALUE),拆开 opcode._specializations 的 17 个专门化族与 84 条专门化指令,并读 CACHE 槽里缓存的 counter/version。最后实测本机 3.14.6 的 JIT 状态并讲清 3.13/3.14 的边界。

本节目标:能从字节码层面看到自适应特化「确实发生了」,并准确判断本机解释器是否启用了 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

四处改写一目了然:

冷指令热指令省掉了什么
RESUMERESUME_CHECK每帧一次的重入检查,热路径只需查一次标志
LOAD_ATTRLOAD_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 条专门化指令。几个族的规模值得记住:

基指令专门化变体数
CALL20
BINARY_OP15
LOAD_ATTR13
STORE_ATTR3
COMPARE_OP3
LOAD_GLOBAL2

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 条专门化指令;CALL 20 种、BINARY_OP 15 种、LOAD_ATTR 13 种。
  • 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 打包分发内部机制 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时