
Python JIT 要被砍了,提速才 8%?别等了,这几行代码现在就快 10 倍
2026 年 6 月 5 号,Python 指导委员会在 Discourse 上发了一则公告,大意是:CPython 里那个实验性的 JIT 编译器,6 个月内拿不出一份正式的 Standards Track PEP,就从主分支移除。
消息一出,社区炸了。
JIT 核心开发者 Mark Shannon 直接开怼,说这个决定让团队"进退两难"——不给继续开发的时间,又要求仓促交提案。他原本计划今年晚些时候等性能提升更显著了再推 PEP,现在被逼着提前交卷。
但指导委员会的态度很明确:一个跑了两年多、经历多次重构的功能,到现在连个正式 PEP 都没有,维护者是谁不清楚,安全审查没做过,调试工具不支持——这种状态待在主分支里,说不过去。
8% 的提速,够看吗?
先看数据。
JIT 目前在 x86-64 Linux 上的几何平均提速是 8-9%,在 AArch64 macOS 上是 12-13%。听着还行?跟 PyPy 比一下:PyPy 在 macOS 上比 CPython 3.15 快约 50%,在 x86-64 Linux 上快 80-90%。
JIT 负责人 Ken Jin 自己给的目标也才 20%——原话是"PyPy 的四分之一到一半"。
更尴尬的是,JIT 一旦检测到你起了线程,直接把自己关掉。没错,多线程场景下 JIT 自动失效。而 Python 3.13+ 力推的 free-threading(去 GIL),目前跟 JIT 还没法共存。两 flagship 项目互相打架,指导委员会能不急?
7 月 3 号,社区提交了 PEP 836(标题起的很皮:"JIT Go Brrr"),给出了路线图:Python 3.16 beta 阶段 JIT+GIL 要至少提速 5%,3.17 beta 阶段 JIT+free-threading 要至少提速 20%。
能不能达标?不好说。但有一件事是确定的:你不用等它。
先找到慢在哪:py-spy 火焰图
很多人优化代码的方式是"凭感觉"——觉得这个循环慢就改这个,觉得那个函数有问题就动那个。改完一跑,发现根本没快多少,因为真正的瓶颈在别处。
py-spy 解决的就是这个问题。Rust 写的,在进程外部采样,不改你一行代码,不重启进程,开销极低。
装一行搞定:
用法也简单。假设你有个脚本 process_data.py 跑了 40 秒不知道慢在哪:
py-spy record -o flamegraph.svg -- python process_data.py
跑完会在当前目录生成一个 flamegraph.svg,浏览器打开,长这样:横轴是采样比例,纵轴是调用栈深度。最宽的那一块就是你的瓶颈。
我之前处理过一个真实案例:2.3 万条数据的清洗脚本,跑了 40 多秒。火焰图一看,80% 的时间花在 pandas.DataFrame.iterrows() 上。这个方法逐行遍历,每行都做类型推断和索引对齐,慢得令人发指。
换成 itertuples() 之后,同一份数据 6 秒跑完。一行代码的改动,快了 7 倍。
如果不想生成 SVG 文件,py-spy 还有个 top 模式,类似 Linux 的 top 命令,实时看哪个函数在吃 CPU:
py-spy top -- python process_data.py
attach 到已经在跑的进程也行,知道 PID 就够了:
py-spy record -o flamegraph.svg --pid 12345 --duration 30
这里有个坑:在 Docker 容器里用 py-spy 需要 --cap-add SYS_PTRACE 权限,不然没法读进程内存。我第一次在容器里跑直接报 PermissionError,查了半天才发现是这个问题。
Numba:数值循环的核武器
py-spy 告诉你慢在哪,Numba 帮你把它变快。
如果你有大量数值计算——矩阵运算、信号处理、科学计算——Numba 的 @jit 装饰器能让纯 Python 循环逼近 C 的速度。
看个真实场景。计算两个向量的欧氏距离,纯 Python 写法:
import math
import time
def euclidean_distance(a, b):
total = 0.0
for i in range(len(a)):
diff = a[i] - b[i]
total += diff * diff
return math.sqrt(total)
# 两个长度 100000 的向量
a = [float(i) for i in range(100000)]
b = [float(i * 2 + 1) for i in range(100000)]
start = time.perf_counter()
for _ in range(1000):
euclidean_distance(a, b)
print(f"纯 Python: {time.perf_counter() - start:.3f}s")
跑下来大概 2.8 秒。
加一行装饰器:
from numba import jit
import math
import time
@jit(nopython=True)
def euclidean_distance_fast(a, b):
total = 0.0
for i in range(len(a)):
diff = a[i] - b[i]
total += diff * diff
return math.sqrt(total)
a = [float(i) for i in range(100000)]
b = [float(i * 2 + 1) for i in range(100000)]
# 第一次调用会触发编译,预热一下
euclidean_distance_fast(a, b)
start = time.perf_counter()
for _ in range(1000):
euclidean_distance_fast(a, b)
print(f"Numba: {time.perf_counter() - start:.3f}s")
0.04 秒。快了 70 倍。
代码几乎没变,就加了个 @jit(nopython=True)。nopython=True 表示完全编译成机器码,不走 Python 解释器。如果 Numba 发现某些操作它搞不定,会 fallback 回解释器模式——但 nopython=True 会直接报错而不是静默降级,这样你至少知道哪里有问题。
但 Numba 不是银弹。 几个踩过的坑:
坑一:类型不兼容直接炸。 传一个 Python list 进去能跑,传一个包含混合类型的 list(
[1, 2.0, "3"])直接
TypingError。Numba 需要能在编译期推断出所有类型,动态类型的东西它处理不了。
坑二:第一次调用有编译开销。 上面那个例子如果不预热,第一次调用会花 0.5-2 秒编译。如果你的脚本只跑一次就退出,编译开销可能比省下来的时间还多。解决办法是提前 warm up,或者用
cache=True 把编译结果缓存到磁盘:
@jit(nopython=True, cache=True)
def my_function(x):
...
坑三:不支持 pandas。 Numba 只认 NumPy array 和基本数值类型。你想在 pandas DataFrame 上用
@jit,得先把数据转成
df.values(NumPy array)。但转完之后你就失去了 pandas 的标签索引能力,得不偿失的情况很多。
什么时候该用 Numba? 我的判断标准很简单:如果你的热点函数是个纯数值循环,输入输出都是数字或数组,上 Numba。如果涉及字符串操作、字典查找、对象方法调用,别折腾了,Numba 帮不了你。
__slots__:省内存的快捷键
这个不算性能优化,更像是内存优化,但很多时候内存省了速度也跟着快了——因为缓存命中率高了。
Python 对象默认用 __dict__ 存属性,每个实例都带一个字典,开销不小。加 __slots__ 就是告诉 Python:"这个类的属性就这些,别给我建字典了"。
# 不用 __slots__
class PointDict:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
# 用 __slots__
class PointSlots:
__slots__ = ('x', 'y', 'z')
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
创建 100 万个实例,内存差异很直观:
import sys
points_dict = [PointDict(1.0, 2.0, 3.0) for _ in range(1000000)]
points_slots = [PointSlots(1.0, 2.0, 3.0) for _ in range(1000000)]
# 粗略估算:每个 PointDict ~152 bytes,每个 PointSlots ~64 bytes
# 100 万个实例:~152MB vs ~64MB,省了将近 90MB
属性访问也更快,因为少了 dict 查找那一层。但差异不大,10-20% 左右,别指望它带来质变。
坑:__slots__ 的类不能被随便加属性了,
point.new_attr = 1 直接报
AttributeError。而且继承的时候,如果子类没声明
__slots__,子类实例还是会创建
__dict__,白省了。另外
__slots__ 跟
@dataclass 配合需要额外设置,坑不算少。
我的建议:数据量大的场景(百万级对象)才值得折腾。几十个实例的配置类、DTO 类,别搞这些花活。
向量化:一行代码替代一千行循环
如果 py-spy 告诉你瓶颈在一个数值循环上,而且你用了 NumPy,那大概率可以向量化。
还是拿前面的欧氏距离举例。纯 Python 循环 1000 次要 2.8 秒,Numba 0.04 秒。NumPy 向量化呢:
import numpy as np
import time
a_np = np.array(a, dtype=np.float64)
b_np = np.array(b, dtype=np.float64)
start = time.perf_counter()
for _ in range(1000):
np.sqrt(np.sum((a_np - b_np) ** 2))
print(f"NumPy: {time.perf_counter() - start:.3f}s")
0.02 秒。比 Numba 还快。
但这里有个反直觉的坑:数组很小的时候,NumPy 反而更慢。 因为 NumPy 的每次操作都有固定的开销(数组创建、内存分配、C 层调用),数据量太小的时候这个开销比循环本身还大。
我测过:100 个元素的数组,纯 Python 循环 0.003 秒,NumPy 向量化 0.008 秒。NumPy 慢了一倍多。分水岭大概在 1000-5000 个元素,过了这个量级 NumPy 的优势才开始碾压。
所以别无脑向量化。先用 py-spy 看数据量,再决定。
GIL 绕不开?multiprocessing 顶上
CPU 密集型任务,多线程在 CPython 里基本没用——GIL 摆在那。threading 模块对 I/O 密集型任务有效,但纯计算的话,多线程可能比单线程还慢(线程切换开销)。
multiprocessing 是正解,每个进程有独立的 GIL:
from multiprocessing import Pool
import time
def heavy_compute(x):
# 模拟 CPU 密集型计算
total = 0
for i in range(10000000):
total += i * x
return total
if __name__ == '__main__':
data = list(range(100))
# 单进程
start = time.perf_counter()
results = [heavy_compute(x) for x in data]
print(f"单进程: {time.perf_counter() - start:.2f}s")
# 多进程(用全部 CPU 核心)
start = time.perf_counter()
with Pool() as pool:
results = pool.map(heavy_compute, data)
print(f"多进程: {time.perf_counter() - start:.2f}s")
8 核机器上,单进程 23 秒,多进程 4 秒。但别高兴太早——进程间通信和数据序列化有成本。如果你的函数本身执行时间很短(毫秒级),Pool.map 的进程间通信开销可能比计算本身还大。
经验值:单个任务执行时间超过 100ms,上 multiprocessing 才划算。 短任务老老实实单进程跑。
别等 JIT,用对工具
回到开头那个问题:Python JIT 会不会被砍?
说实话,大概率不会。PEP 836 已经提交了,社区在积极推动,6 个月的窗口虽然紧但不是不可能。但就算 JIT 最终达标了——20% 提速,那也是 Python 3.17 的事了,最早 2027 年底。
你今天写的代码要等 2027 年才变快?
py-spy 找瓶颈,Numba 加速数值循环,NumPy 向量化,__slots__ 省内存,multiprocessing 绕 GIL——这些工具现在就能用,效果立竿见影。
JIT 是锦上添花,不是雪中送炭。真正让你的代码变快的,不是编译器,是你对瓶颈的判断。
觉得有用的话,点个赞、在看、转发三连,让更多 Python 开发者看到。
关注「几行代码」,每周一个能直接抄走的实战技巧。