教程·阅读约 2 分钟·
Python 3.15 的新魔法:超低开销解释器性能分析模式

Python 3.15 的新魔法:超低开销解释器性能分析模式

Python 3.15 引入了一种双调度表(Dual Dispatch)的 JIT 追踪技术,将性能分析的开销控制在仅 4.5 倍以内——比 PyPy 的 900 倍好了两个数量级

原文来源:Ken Jin 的博客 - "Python 3.15's Ultra-Low Overhead Interpreter Profiling Mode" — CPython 核心开发者 Ken Jin 详细解释了 Python 3.15 中全新 JIT 编译器的追踪性能分析机制,以及一种精巧的双调度表技术如何把开销降到最低。

Python 3.15 的 JIT 编译器带来了比解释器有一定的速度提升,但这背后有一个不为人知的技术挑战:JIT 编译依赖追踪记录(trace recording),而追踪记录需要先对解释器进行性能分析。性能分析本身是有开销的。

核心开发者 Ken Jin 在他的博客中披露了一个精巧的方案:通过双调度表(Dual Dispatch)技术,把性能分析的开降到了一个令人惊讶的低水平——仅 4.5 倍减速,而 PyPy 的元追踪技术在这个指标上是 900 到 1000 倍。

这不是一个学术玩具。它背后的技术思路,值得每一个对编程语言实现感兴趣的人好好琢磨。

传统方案为什么不行?

在解释器里做性能分析,传统的做法有两种:

方案一:两套解释器

维护两个独立的解释器:一个普通执行用的,一个带分析功能的。当需要追踪时,从普通解释器切换到分析解释器。

问题是什么? 代码量翻倍。对于使用 computed-goto(计算跳转)的解释器来说,这意味着二进制体积翻倍,导致约 6% 的 pyperformance 性能下降。这就像为了给一辆车装个油耗表,你又造了一辆车。

方案二:单解释器中加分支判断

用一个 bool profile 标志位控制:如果需要分析就走分析分支,否则走正常分支。

问题是什么? 即使分支预测很准(现代 CPU 的分支预测正确率 >95%),每次判断本身还是会干扰正常执行。你给解释器加了一个无论什么情况下都要支付的额外成本。

CPython 3.15 的创作者们显然对这两种方案都不满意。

—— 广告 ——

他们的方案:双调度表(Dual Dispatch)

这个方案的思路出奇地简单,但巧妙程度相当高。

先解释一下"调度表"

解释器的核心是一个大循环(event loop)和一个调度表(dispatch table)。调度表本质上是一个函数指针数组或跳转标签数组,把字节码的操作码(opcode)映射到对应的执行代码。

比如,LOAD_FAST 操作码 → 加载本地变量的函数。BINARY_ADD → 执行加法。解释器拿到下一条指令后,用操作码查表,跳转到对应代码执行。

双调度表的做法

CPython 3.15 维护两个调度表:

  • 普通调度表:正常执行用的,每个操作码对应各自的执行函数
  • 追踪调度表:分析用的

关键巧妙之处在这里:追踪调度表里所有操作码都映射到同一个记录函数(recording instruction)。

也就是说,不管下一个操作码是什么,只要你处于分析模式,它会先跳到同一个函数去记录执行轨迹,然后这个函数再用普通调度表dispatch 到真实的执行函数。

这可以看作一个"扇入-扇出"(fan-in, fan-out)模型:所有指令扇入到一个记录指令,记录完后通过普通表扇出到实际执行。

切换调度的代价有多低

code
#define ENTER_TRACING() \
    DISPATCH_TABLE_VAR = TRACING_DISPATCH_TABLE;
#define LEAVE_TRACING() \
    DISPATCH_TABLE_VAR = DISPATCH_TABLE;

就是换个指针。没有分支判断、没有条件跳转、没有额外的代码膨胀。 这就是为什么这个方案的开销如此之低。

在 computed-goto 解释器里,分发表是一个 static void* 标签数组,用 goto * 跳转。在尾调用解释器里,分发表是一个函数指针数组。两种实现都通过两个宏兼容了。

实际性能数据

在禁用动态频率缩放的系统上,用作者提供的基准测试(一个重复 100 次加法的小循环)进行了 40 次运行,取中位数:

模式每次迭代时间
纯解释器(无分析)1.72e-06 秒
解释器 + 分析 + JIT7.47e-06 秒

分析开销:约 4.5 倍。

对比 PyPy 的元追踪方案:它的追踪慢 900-1000 倍(虽然不完全可比,因为它追踪解释器本身)。

Ken Jin 在博客中写道:"这基本上意味着 CPython 3.15 中分析解释器的开销在玩具基准上最多只有 4.5 倍。"

为什么这个方案不只是个"玩具"

这个技术有两个让我觉得很重要的地方:

第一,它的通用性。 双调度表的技术不只限于 JIT 追踪记录。Ken Jin 自己就提到了:它可以用在任何需要对解释器进行运行时分析的场景——比如运行时类型分析(type profiles),而不需要对解释器做大规模重写。

第二,它的克制。 这篇文章最打动我的不是技术本身,而是 Ken Jin 在文章结尾写的反思:

"我有时会问自己:我们在 CPython 中想出的这个魔法系统,真的值得这么复杂吗?"

作为一个 CPython 核心开发者,他没有沉浸在"我们做了个很酷的东西"的沾沾自喜中,而是在认真思考复杂度与收益的平衡。这对于一个已经有 30 多年历史、数百万行代码的项目来说,是一种非常难得的清醒。

实践一下:如何体验 JIT 追踪

如果你想亲眼看看这个系统怎么工作,Ken Jin 也给出了一个脚本来触发 JIT 编译:

code
def foo(x):
    x + x
    # ... 复制 100 次 x + x ...
    x + x
 
foo(1)
foo(1)
foo(1)
import sys
import time
start = time.time()
foo(1)
end = time.time()
print(end - start)
# sys._dump_tracelets("hello.gvz")

运行方式:

code
PYTHON_JIT_RESUME_INITIAL_VALUE=1 python3.15 benchmark.py

前三次调用 foo(1) 触发 JIT 编译(热循环检测),第四次调用用的是编译后的代码。用 sys._dump_tracelets("hello.gvz") 还可以导出追踪信息,在浏览器中可视化分析。

为什么需要 PYTHON_JIT_RESUME_INITIAL_VALUE=1

这是一个调试环境变量。CPython 的 JIT 编译器默认的追踪触发阈值是按生产环境配置的,用这个环境变量可以强制第一次执行就开启追踪,方便你在开发中看到完整的追踪流水线。

我的看法

Python 3.15 的 JIT 一直备受关注,但大多数人的目光集中在最终的加速效果上。

这篇博客让我看到了另一面:一个好的设计不是为了酷而酷,而是在无数个约束条件下找到一个优雅极简的解。 CPython 团队面临的选择是——要么接受 6% 的恒定性能损失(两套解释器方案),要么找一个巧办法把这个损失降到几乎可以忽略的程度。

他们找到了。4.5 倍的追踪开销虽然看起来不小,但你要知道,这个开销只发生在执行热代码路径上极短的时间里——只有需要记录追踪线索的那几毫秒。在正常执行中,分析模式根本不会被进入,而双调度表方案的最大好处是它不会对非追踪模式造成任何性能损失。

这跟 Go 语言的 pprof 或 Java 的 JFR 这类通用分析工具的思路完全不同——CPython 的追踪分析是为 JIT 编译量身定做的,它的"高开销"只在必要的时候才支付,而且只支付短短一瞬间。

如果你对 CPython 内部实现感兴趣,这篇文章是个非常好的切入点。它展示了一个世界级 Python 团队如何在一个存在了三十年的代码库中引入一项根本性的新技术,同时又保持了向后兼容性和精益的设计哲学。

分享到
微博Twitter

© 2026 四月

原文链接:https://www.aprilzz.com/tutorials/python-315-ultra-low-overhead-profiling