《Python高级编程》3.3 实验性 JIT 与自适应解释器

先实测本机 3.14.6 是否启用 JIT(sys._jit.is_available() 为 False),再拆开 CPython 的三层执行体系:自适应专门化字节码、Tier 2 微操作 IR 与 copy-and-patch 机器码,并梳理 3.13 引入、3.14 进入官方二进制的 JIT 演进与 tail-call 解释器。

本节目标:说清 CPython 自适应解释器与实验性 JIT 的分层设计,并能用 sys._jit 判断当前解释器到底跑在哪一层。
适用版本:Python 3.12+(实测 3.14.6)

3.3 实验性 JIT 与自适应解释器

前两节讲的都是解释执行:字节码被逐条派发,只是热代码会被就地改写成专门化指令。这一节要问一个更激进的问题:CPython 会不会干脆把热代码编译成机器码?答案是「会,但还在实验阶段」。这里最容易踩的坑是把「有 JIT 代码」当成「JIT 已启用」——本节第一件事就是拿本机实测把这件事查清楚,绝不凭印象下结论。

站内专题 Python 元编程与动态特性深度解析 从未涉及执行引擎,本节与它没有交集;与前两节的区别是:3.1 讲字节码长什么样,3.2 讲怎么改字节码,本节讲字节码之后发生了什么。

3.3.1 先查本机:JIT 到底开没开

Python 3.14 新增了 sys._jit 命名空间,专门用来内省 JIT 状态。三个函数语义不同,别混用:

import sys, sysconfig
jit = sys._jit
print("is_available:", jit.is_available())
print("is_enabled  :", jit.is_enabled())
print("is_active   :", jit.is_active())
print("Py_JIT      :", sysconfig.get_config_var("Py_JIT"))

真实输出(本机 Homebrew 标准构建,3.14.6):

is_available: False
is_enabled  : False
is_active   : False
Py_JIT      : None

三个函数的官方语义(__doc__ 实测)分别是:

函数含义
is_available()当前可执行文件是否支持 JIT 编译
is_enabled()JIT 是否已为本进程启用(蕴含 is_available())
is_active()当前最顶层的 Python 帧是否正在跑 JIT 代码(蕴含 is_enabled())

本机三者全为 False,Py_JIT 是 None——本机的标准构建根本没有编译进 JIT。进一步验证:环境变量与命令行开关都不起作用:

PYTHON_JIT=1 python3 -c "import sys; print(sys._jit.is_enabled())"
python3 -X jit=1 -c "import sys; print(sys._jit.is_enabled())"

真实输出:

False
False

原因在构建参数里。sysconfig.get_config_var("CONFIG_ARGS") 显示本机构建带了 --enable-optimizations、--with-lto,但没有 --enable-experimental-jit:

... '--enable-optimizations' ... '--with-lto' ...

官方文档明确说明:JIT 需要显式开启构建(--enable-experimental-jit=yes-off),官方发布的 macOS / Windows 二进制才默认包含它,Homebrew 这类下游源码构建默认不带。所以本节不提供任何本机 JIT 性能数字——本机压根跑不到 JIT,编出来的数字都是假的。下面讲的是官方设计。

3.3.2 自适应解释器:专门化与去优化

要理解 JIT,先要理解它的前置——自适应专门化解释器(PEP 659,3.11 引入)。3.1 节已经实测过:热代码的 LOAD_ATTR 会被就地改写成 LOAD_ATTR_INSTANCE_VALUE。这里补上「自适应」的另一半:指令会随类型变化重新专门化,也会在类型不匹配时去优化。

看一个 add 函数在三种输入类型下的 adaptive=True 视图:

import dis

def add(a, b):
    return a + b

for _ in range(5000):
    add(1, 2)
print("=== 纯 int 热态 ===")
for ins in dis.get_instructions(add, adaptive=True):
    print("  ", ins.opname)

for _ in range(5000):
    add(1.5, 2.5)
print("=== 混入 float 后 ===")
for ins in dis.get_instructions(add, adaptive=True):
    print("  ", ins.opname)

for _ in range(5000):
    add("x", "y")
print("=== 混入 str 后 ===")
for ins in dis.get_instructions(add, adaptive=True):
    print("  ", ins.opname)

真实输出(节选核心指令):

=== 纯 int 热态 ===
   RESUME_CHECK
   LOAD_FAST_BORROW_LOAD_FAST_BORROW
   BINARY_OP_ADD_INT
   RETURN_VALUE
=== 混入 float 后 ===
   RESUME_CHECK
   LOAD_FAST_BORROW_LOAD_FAST_BORROW
   BINARY_OP_ADD_FLOAT
   RETURN_VALUE
=== 混入 str 后 ===
   RESUME_CHECK
   LOAD_FAST_BORROW_LOAD_FAST_BORROW
   BINARY_OP_ADD_UNICODE
   RETURN_VALUE

同一个 BINARY_OP 位置,随观察到的类型在 ADD_INT / ADD_FLOAT / ADD_UNICODE 之间切换——这就是重新专门化。每个专门化指令后面挂着 CACHE 槽记录类型与版本;一旦类型不再匹配,就触发去优化,退回通用指令、清零计数、重新观察。

去优化不是免费的。如果同一个位置在两种类型间反复横跳,专门化会不断失效重建。实测对比「类型稳定」与「int/float 交替」两种写法:

import timeit

def add(a, b):
    return a + b

def stable(n):
    s = 0
    for i in range(n):
        s = add(s, 1)
    return s

def alternating(n):
    s = 0
    for i in range(n):
        s = add(s, 1.0) if i % 2 else add(s, 1)
    return s

def measure(fn, number=200, repeat=7):
    return min(timeit.repeat(lambda: fn(2000), number=number, repeat=repeat)) / number * 1e6

print("stable int :", round(measure(stable), 1), "us/call")
print("alternating:", round(measure(alternating), 1), "us/call")

真实输出(3.14.6,Apple silicon,多轮取最小值,数值有几 % 波动):

stable int : 74.0 us/call
alternating: 110.0 us/call

类型稳定时约 74 µs,int/float 交替时约 110 µs,慢约 1.5 倍。差别几乎全来自专门化/去优化的抖动。这给了一条可操作的优化原则:热点函数尽量保持参数类型稳定,不是为了讨好静态类型检查,而是为了让自适应解释器少做去优化。

一个版本细节:3.14 起,自适应专门化在自由线程(free-threaded)构建里也启用了。官方 What’s New 记录,配合其它优化,自由线程模式的单线程性能损耗收窄到约 5–10%(取决于平台与编译器)。自由线程本身是 PEP 703 的实验性方向,想先了解 GIL 与并发边界可以看 GIL 对并发编程的影响 。

3.3.3 JIT 的官方设计:tier 1 → tier 2 → 机器码

CPython 的 JIT(PEP 744 ,作者 Brandt Bucher)不是「把源码编译成机器码」那种传统 JIT,而是一条分层流水线。官方文档给出的内部架构大致是:

  1. Tier 1:就是 3.3.2 的自适应专门化字节码。这是默认执行层。
  2. Tier 2 IR:当 Tier 1 字节码「足够热」,会被翻译成一种纯内部的中间表示,叫 Tier 2 IR,也叫微操作(micro-ops,简称 uops)。它仍是栈式虚拟机,但指令格式更适合翻译成机器码。Tier 2 上会跑若干优化 pass。
  3. Tier 2 解释器:用于调试优化流水线的早期阶段,可用 --enable-experimental-jit=interpreter 单独构建,但不是给生产用的。
  4. 机器码:JIT 启用时,优化后的 Tier 2 IR 被翻译成机器码执行。翻译技术叫 copy-and-patch——把预先编译好的机器码片段「复制并打补丁」拼起来。它没有运行时依赖,但构建期依赖 LLVM。

可以用一句话概括三层的关系:

层输入输出何时触发
Tier 1通用字节码专门化字节码冷启动即用,热了就专门化
Tier 2专门化字节码优化后的 uops代码「足够热」时翻译
JIT优化后的 uops机器码JIT 启用时

注意 is_active() 检查的是「最顶层的帧是否在跑 JIT 代码」——因为只有热路径才会进 JIT,冷代码始终在 Tier 1。这也解释了为什么 JIT 的收益高度依赖工作负载:循环密集、长期运行的纯 Python 代码受益最大,一次性脚本几乎无感。

3.3.4 版本演进:从默认关闭到进入官方二进制

JIT 不是一步到位的,两个版本的变化要分清:

版本JIT 状态关键事实
3.13引入(PEP 744),默认关闭官方称「性能改进有限,会在后续版本继续打磨」
3.14官方 macOS / Windows 二进制默认包含(仍实验性)用 PYTHON_JIT=1 启用;源码构建用 --enable-experimental-jit=yes-off

3.14 官方对性能的表述很克制,原话大意是:JIT 仍处于早期、活跃开发中,典型性能影响从慢 10% 到快 20% 不等,取决于工作负载。这句「可能更慢」很关键——它说明 JIT 目前不是一个可以无脑打开的加速开关,而是为未来铺路的基础设施。同时 3.14 新增了 sys._jit 命名空间(就是 3.3.1 用的那三个函数),专门服务于测试与评估。

还有一个容易被忽略的限制(官方明确记录):原生调试器与剖析器(如 gdb、perf)目前无法穿越 JIT 帧展开调用栈。纯 Python 的调试器与剖析器(pdb、profile)不受影响。这意味着在启用 JIT 的环境里做底层性能分析,工具链可能给不出完整栈。

3.3.5 tail-call 解释器:另一条提速路线

3.14 还有一条独立于 JIT 的提速路线:tail-call 解释器。传统 CPython 的求值循环是一个巨大的 C switch 语句;新解释器改用小 C 函数之间的尾调用来实现每条 Python 指令。官方称,在某些较新的编译器上,这能带来显著更好的性能,在 pyperformance 基准上几何平均快 3–5%。

它通过构建选项 --with-tail-call-interp 启用,且官方推荐配合 PGO 优化(这是唯一被验证过有性能收益的配置)。官方特别提醒:这里的「tail call」是解释器内部实现细节,与「Python 函数层面的尾调用优化」完全是两回事——CPython 没有实现后者,Python 层仍会因深递归而 RecursionError。

对本机而言,这条路线同样未启用:它是构建期选项,Homebrew 默认构建不带。所以本节同样不提供本机的 tail-call 性能数字。

3.3.6 CPython JIT 与 PyPy JIT 不是一回事

提到 Python JIT,很多人第一反应是 PyPy。两者路线差别很大,别混为一谈:

维度CPython JIT(PEP 744)PyPy JIT
触发对象热的函数/代码对象热的循环追踪(tracing)
中间表示Tier 2 微操作(uops)追踪记录(trace)
机器码生成copy-and-patch(构建期备好片段)运行时即时生成
C 扩展兼容原生兼容(同一个解释器)经 cpyext 兼容层,常更慢
成熟度实验性、默认关闭成熟、默认启用
主要收益场景逐步降低解释开销纯 Python 长循环大幅加速

关键差异在兼容性:CPython JIT 跑的是同一个解释器,C 扩展、ctypes、调试器都照常工作;PyPy 的 JIT 虽然对纯 Python 更快,但 C 扩展要穿过 cpyext 兼容层,性能往往不如原生。这解释了为什么 PyPy 至今没能取代 CPython——生态兼容性才是它的天花板,而不是 JIT 技术本身。

3.3.7 怎么拿到一个带 JIT 的解释器

如果确实想实测 JIT,本机这个构建做不到,需要换一个解释器(以下步骤本机未实测,因为要重新构建):

  • 最省事:装官方 python.org 的 3.14 macOS / Windows 安装包,它们默认包含 JIT;运行前设 PYTHON_JIT=1 启用。
  • 源码构建:
    ./configure --enable-experimental-jit=yes-off --with-lto --enable-optimizations
    make -j
    
    --enable-experimental-jit=yes-off 表示「编译进 JIT 但默认关闭」,运行时再用 PYTHON_JIT=1 打开;构建期需要 LLVM。
  • 验证:装好后先跑 python3 -c "import sys; print(sys._jit.is_available(), sys._jit.is_enabled())",看到 True True 才算真的启用,再谈性能。

务必先验证 is_available(),否则你可能对着一个没有 JIT 的解释器做「JIT 性能测试」,得出的全是噪声。

3.3.8 这对写代码的实际影响

既然本机跑不到 JIT,这一节的价值在哪?至少有三点:

  • 别信「Python 3.13 有 JIT 所以变快了」:JIT 默认关闭,且官方明说可能更慢。要判断,先跑 sys._jit.is_available() 和 is_enabled(),别靠版本号猜。
  • 自适应专门化是「默认开启」的真实优化:3.11+ 的热代码自动专门化不需要你做任何事,但它会去优化——实测类型稳定比 int/float 交替快约 1.5 倍。这为「热点函数保持类型稳定」提供了字节码层的解释。
  • 性能归因要分层:一段代码慢,可能在 Tier 1 派发、可能在专门化/去优化抖动、也可能(若启用 JIT)在 JIT 编译本身。用 sys._jit.is_active() 就能判断当前帧是否已进 JIT,是排障的第一手信息。

如果你在做长期运行的纯 Python 服务,值得关注 JIT 的后续版本;但现阶段把它当默认加速手段是不负责任的——先量化,再决定。

小结

  • 本机 3.14.6 是标准构建:sys._jit.is_available() / is_enabled() / is_active() 全为 False,Py_JIT 为 None,PYTHON_JIT=1 与 -X jit 均无效——JIT 未编译进本机,故本节不含本机 JIT 性能数据。
  • 自适应专门化解释器(PEP 659,3.11+)是 JIT 的采样前端:热代码就地专门化、类型不匹配则去优化;实测类型稳定的热点比 int/float 交替快约 1.5 倍,3.14 起在自由线程构建中同样启用。
  • CPython JIT(PEP 744)是分层流水线:Tier 1 专门化字节码 → Tier 2 微操作(uops)IR → copy-and-patch 机器码;构建期依赖 LLVM,无运行时依赖。
  • 3.13 引入 JIT(默认关闭),3.14 官方二进制默认包含(仍实验性),用 PYTHON_JIT=1 启用,官方称性能从慢 10% 到快 20% 不等。
  • 3.14 新增 sys._jit 内省命名空间;原生调试器/剖析器暂无法穿越 JIT 帧。
  • tail-call 解释器(--with-tail-call-interp)是 3.14 另一条路线,pyperformance 几何平均快 3–5%,与 Python 层的尾调用优化无关;CPython JIT 与 PyPy 的 tracing JIT 是两种路线。

到这里,执行引擎这条线就闭环了:字节码长什么样(3.1)、怎么改它(3.2)、以及它如何被优化(3.3)。下一章换到另一个底层话题——当对象被创建和销毁时,内存到底发生了什么。

阅读导航:上一节:3.2 dis / bytecode 与运行时代码改写 · 下一节:4.1 引用计数、循环 GC 与分代回收 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

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