Python 的 JIT 编译器终于来了
Python 3.15 引入基于 copy-and-patch 技术的 JIT 编译器。本文讲解它的工作原理、性能提升预期,以及 CPython 为何拖了这么久。

Python 从诞生起就一直被说“太慢”。Python 社区的标准回应是“热点循环用 C 扩展来写”,这实际上一直是在承认:语言默认的执行模型有根本性的局限。CPython 逐条解释执行字节码,每条指令都通过 switch 语句分发。这种设计简单、可移植、好调试,但在计算密集型任务上,它大约比编译后的 C 代码慢 100 倍。
Python 3.15 改变了这一点。经过多年的实验性开发,copy-and-patch JIT 编译器终于默认开启了。它不会让 Python 跑得和 C 一样快,除非做静态编译,否则什么方案都做不到。不过早期基准测试显示,真实代码能获得 15% 到 30% 的提速,某些特定模式的提升还要大得多。对于一门三十年来性能故事一直是“把热点路径改用 C 重写”的语言来说,一个能实质性加速纯 Python 代码的 JIT,确实是一个里程碑。
为什么 CPython 一直没有 JIT
这并不是因为没人尝试。PyPy 已经有十多年的 JIT 历史,运行 Python 代码经常比 CPython 快 5 到 10 倍。但 PyPy 是一个拥有独立运行时的独立实现,它始终没能取得 CPython 那样的市场份额,原因在于 C 扩展生态(NumPy、pandas、scikit-learn 等让 Python 成为数据科学首选语言的一切)都绑定在 CPython 的 C API 上。
把 JIT 直接集成进 CPython 本身,已经被讨论和尝试过好几次,困难也都有详细记录。CPython 的架构让 JIT 编译很难做:字节码是动态类型的,JIT 需要类型信息才能生成高效代码,而 Python 并不会静态提供这些信息;C API 允许 C 代码直接操作 Python 对象,这种操作方式会打破 JIT 的假设;另外,解释器基于引用计数的内存管理会带来记账开销,JIT 很难把它彻底消除。
之前的尝试包括 Unladen Swallow(Google,2009)和 Pyston(Dropbox,2014),它们都试图把基于 LLVM 的 JIT 编译硬塞进 CPython。两个项目都发现,LLVM 的编译开销对 Python 的典型负载来说太高了。LLVM 是为大型代码库的提前编译(AOT)设计的,用它来 JIT 编译短小的 Python 函数,会为几微秒的执行时间多花几毫秒的编译时间。编译成本超过了带来的加速。
Copy-and-Patch:另一种 JIT 思路
copy-and-patch 技术出自 2021 年的一篇研究论文,它在 JIT 编译上采取了完全不同的思路。它不是把字节码翻译成中间表示再跑一遍优化流程(LLVM 的做法),而是直接使用预先编译好的代码模板。
原理是这样的:对于每条字节码指令(LOAD_FAST、BINARY_ADD、CALL_FUNCTION 等),编译器预先把对应的 C 实现编译成机器码,并在其中留下占位的“洞”,用来填充与具体变量相关的数据,比如寄存器分配、常量值和内存偏移。运行时的 JIT 编译就只剩下:复制预编译的模板,再把这些洞填成当前函数的具体值。没有优化流程,没有寄存器分配算法,也没有指令选择。说白了就是 memcpy 加 patch。
Traditional JIT pipeline:
Python bytecode
→ Parse into IR
→ Type inference
→ Optimization passes (CSE, DCE, loop unrolling, inlining...)
→ Register allocation
→ Instruction selection
→ Machine code
Total: 1-100ms per function
Copy-and-patch pipeline:
Python bytecode
→ For each instruction, copy pre-compiled template
→ Fill in holes (constants, offsets)
→ Done — machine code ready
Total: 1-100μs per function (1000x faster compilation)
代价在于代码质量。LLVM 生成的机器码经过高度优化,而 copy-and-patch 生成的机器码本质上就是解释器循环的编译版本,每条字节码指令仍然是一个独立模板,指令之间几乎没有跨指令优化。生成的代码比纯解释执行要好(没有分发开销,没有 switch 语句,分支预测也更友好),但比完整的优化编译器产出的代码要差。
对 Python 来说,这笔交易很划算。Python 函数通常很短、被反复调用,单次执行只需几微秒。一个能在微秒级完成编译、带来 20% 到 30% 提速的 JIT,比一个要花毫秒级编译、却能带来 200% 提速的 JIT 更有价值,因为前者的编译开销几乎立刻就能摊销掉。
哪些部分会变快
JIT 并不会均匀地加速所有 Python 代码。要弄清楚哪些代码受益最多,得先搞清楚解释器的时间都花在了哪里。
字节码分发开销。在解释器中,每条字节码指令都要经历:取出下一个操作码、解码、通过 switch 语句跳转到对应的处理函数。在紧凑循环中,这部分开销可能占到总执行时间的 30% 到 50%。JIT 会把这部分开销完全消除,指令被编译成顺序排列的机器码,直接跳转。
类型特化操作。Python 3.11 引入了特化自适应解释器,它会在观察到实际使用的类型之后,把通用操作替换成类型专用的版本。当遇到两个整数相加时,BINARY_ADD 会变成 BINARY_ADD_INT。JIT 会把这些特化指令编译成高效的机器码,比如整数加法会变成一条 add 指令,而不再是一次函数调用。
分支预测。解释器中央的分发循环是一个包含数百个分支的 switch,对 CPU 的分支预测器来说简直是噩梦。JIT 用直接的控制流取代了它,CPU 可以准确地预测跳转目标。在现代 CPU 上,一次分支预测失败要损失 15 到 20 个周期,仅这一项就占了提速的很大一部分。
不会因此变快的部分包括:C 扩展调用(NumPy、pandas)、I/O 操作(网络、磁盘)、以及内存分配占主导的操作(比如创建数百万个小对象)。如果你的 Python 程序 95% 的时间花在 C 扩展里,只有 5% 的时间在纯 Python 代码上,那么 JIT 只能加速那 5%。这能测量出来,但谈不上颠覆性。
特化流水线
JIT 并不是单独工作的。它是一条性能流水线的最后一环,这条流水线从 Python 3.11 的特化解释器开始,并在 3.12 到 3.14 中逐步改进。
- Tier 0:解释器。所有代码都从这里开始。标准字节码解释执行,并带有自适应特化。函数被调用若干次之后,热点指令会被替换成类型特化版本。
- Tier 1:JIT 编译的字节码。copy-and-patch JIT 把特化后的字节码编译成机器码。这消除了分发开销,并在编译后的模板内部支持常量折叠、死代码消除等基础优化。
- Tier 2(未来):基于踪迹的优化。记录热点代码路径上的执行踪迹,并把整条踪迹(跨越函数边界)编译成优化后的机器码。这是规划中的功能,目前尚未发布。
这种分层思路与 Ruby 的 YJIT 以及其他现代语言运行时的做法很相似。先用快速解释执行,代码变热之后再进行快速编译,把昂贵的优化留给最热的路径。核心思想是相同的:大多数代码并不值得优化,所以应该把编译预算花在运行最频繁的代码上。
内存与启动开销
JIT 编译器需要内存来存放编译后的代码。copy-and-patch JIT 的内存开销不大:编译后的代码比字节码大,但比基于 LLVM 的 JIT 产物小,因为它没有优化带来的膨胀。目前的实现大约使用被替换字节码 1.5 到 3 倍的内存,并且只会编译那些被足够频繁调用、从而能受益的函数。
对于短生命周期的 Python 脚本,启动时间是个问题。JIT 需要加载模板库并搭建编译基础设施,这会带来额外开销。对于运行时间不到一秒的脚本,JIT 的开销可能会超过它带来的加速。CPython 的应对方式是:只有当函数被调用超过阈值次数后才进行 JIT 编译,短命脚本会留在解释器中,不必为 JIT 付出代价。
这一行为是可以配置的。-X jit 参数控制 JIT 的行为,环境变量则用于调整编译阈值。对于启动速度很重要的无服务器函数和命令行工具,你可以提高阈值,或者直接关闭 JIT。对于长期运行的服务器和数据处理脚本,稳定状态下的性能更重要,默认配置通常就已经很好用了。
这对 Python 生态意味着什么
JIT 并不会改变 Python 在性能排行中的位置。在计算密集型任务上,C、Rust、Go 和 Java 依然要快得多。它改变的是 Python 开发者需要转向这些替代方案的临界点。
纯 Python 代码 20% 到 30% 的提速意味着,一些以前必须依赖 C 扩展或者重写的任务,现在用纯 Python 就足够快了。原本要跑 10 分钟的数据处理脚本,现在只需 7 分钟。原本每秒处理 1000 个请求的 Web 服务器,现在能处理 1300 个。这些数字谈不上革命性,但它们正是“Python 已经够快了”和“我们得用 Go 重写”之间的分界线。
更重要的是,JIT 基础设施为未来的优化打下了基础。copy-and-patch 方案可以通过更好的模板、更多的特化,最终通过基于踪迹的编译来扩展。每个 Python 版本都可以在不改变 JIT 根本架构的前提下,发布更好的模板。3.15 中的 20% 到 30% 提速是一个下限,而不是上限。
Python 曾经三十年来既是世界上最流行的语言之一,也是最慢的语言之一。如今 CPython 终于认真投入到性能优化中。JIT 不会让“Python 太慢”的声音消失,因为对某些工作负载来说,Python 确实太慢了,无论有没有 JIT 都一样。但对于绝大多数 Python 代码而言,它们的执行速度原本“够用但不算好”,JIT 会让它们更接近“真正的好”。这件事的意义比听起来要大。


