为什么同一段 Python 代码,PyPy 能快十倍?
如果你用纯 Python 写过一个计算密集的循环,比如计算斐波那契数列或处理大量整数运算,可能会发现:在 PyPy 上运行比在 CPython 上快很多,有时接近十倍。这不是玄学,而是 JIT(即时编译)在背后做了大量工作。
CPython 的执行方式:逐条解释字节码
CPython 是 Python 的参考实现。它先把源码编译成字节码,然后在一个巨大的 switch 循环中逐条解释执行。每条字节码都要经过:取指令、分派、操作数栈操作、类型检查……对于循环中的 a + b,每次都要重新判断类型、查找加法函数、创建新对象。
这种方式的优点是简单、稳定、C 扩展生态丰富;缺点是重复工作多,没有跨指令的优化。
PyPy 的 JIT:先解释,再编译热点
PyPy 也解释字节码,但它内置了一个跟踪 JIT(tracing JIT)。它的工作流程大致是:
- 先解释执行,同时记录循环回边次数。
- 当某个循环足够“热”(执行次数达到阈值),JIT 开始记录这个循环中实际执行的指令序列,形成一条“轨迹”。
- 把这条轨迹编译成机器码,并做一系列优化。
- 之后再次进入这个循环时,直接运行机器码;如果运行中类型与记录时不一致,就回退到解释器(称为“守卫失败”)。
关键点:JIT 编译的是运行时实际走过的路径,而不是静态代码。
JIT 到底优化了什么?
以一段简单的整数循环为例:
def sum_squares(n):
total = 0
for i in range(n):
total += i * i
return total
在 CPython 中,每次 i * i 和 total += ... 都要:
- 检查
i是不是 int - 调用整数乘法
- 创建新的 PyLong 对象
- 类似地处理加法
PyPy 的 JIT 在记录轨迹后,会做这些优化:
- 类型特化:发现
i和total始终是整数,直接生成整数乘加指令,省去类型判断。 - 去虚拟化:如果中间产生了临时对象且没逃逸出循环,可能直接在寄存器中计算,不分配堆内存。
- 循环不变量外提:把循环内不变的计算移到循环外。
- 内联:把频繁调用的小函数直接展开,减少调用开销。
- 消除冗余守卫:合并或删除重复的类型检查。
这些优化叠加起来,就是十倍差距的主要来源。
什么情况下 PyPy 不一定快?
PyPy 并非万能:
- 短脚本:JIT 还没热身就结束了,可能比 CPython 还慢。
- 大量 C 扩展:CPython 的 C 扩展在 PyPy 上兼容性有限,性能优势可能被抵消。
- I/O 密集型:瓶颈在磁盘或网络,JIT 帮不上忙。
- 类型频繁变化:守卫不断失败,JIT 退化为解释执行。
实践建议
- 纯 Python 计算密集型任务,可以试试 PyPy,用真实负载做基准测试。
- 不要只看微基准,要测完整程序。
- 如果依赖 NumPy、Pandas 等 C 扩展,优先考虑 CPython 或寻找替代方案。
- 理解 JIT 的“热路径”思想,也有助于写出对 CPython 更友好的代码(比如把不变计算移出循环)。
JIT 的十倍差距,本质是用运行时信息换取了静态语言才有的优化机会。它不是魔法,而是对重复工作的系统性消除。