本节目标:说清 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,而是一条分层流水线。官方文档给出的内部架构大致是:
- Tier 1:就是 3.3.2 的自适应专门化字节码。这是默认执行层。
- Tier 2 IR:当 Tier 1 字节码「足够热」,会被翻译成一种纯内部的中间表示,叫 Tier 2 IR,也叫微操作(micro-ops,简称 uops)。它仍是栈式虚拟机,但指令格式更适合翻译成机器码。Tier 2 上会跑若干优化 pass。
- Tier 2 解释器:用于调试优化流水线的早期阶段,可用
--enable-experimental-jit=interpreter单独构建,但不是给生产用的。 - 机器码: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 与分代回收 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。