Python技术迷

告别Python龟速!这个编译器能让你的代码瞬间提速百倍

CPU 已经跑满了,日志没报错,接口也没挂,就是慢。

这种慢我一般先不骂 Python。先看火焰图,再看是不是一堆小循环、条件判断、数组计算,老老实实在解释器里一行一行磨。很多业务代码不是慢在“架构不行”,就是慢在你拿 Python 去硬扛本该交给编译器的活。

这时候我第一个会想到的,不是重写成 C++,也不是先上分布式。先把热点代码拎出来,丢给 Numba 试一下。

Numba 这东西,很多人听过,但真在线上脚本、数据处理、风控计算、批量清洗里用起来的不多。原因也简单:一听“编译器”“JIT”,脑子里就自动切到很重、很复杂、要改一堆代码。实际没那么夸张。它最值钱的地方就是一句装饰器,先把最耗 CPU 的那坨循环编译掉。

先看一段很典型的慢代码。不是教学 demo,就是平时数据清洗、特征计算里最容易写出来的样子:

import math

defcalc_score(values):
    total = 0.0
for x in values:
if x > 0:
            total += math.sqrt(x) * 1.17
else:
            total += 0.0
return total

这段代码逻辑没毛病,业务里也常见。问题是它慢得很老实: 每次循环都在 Python 解释器里转,变量是 Python 对象,判断是对象操作,函数调用也有开销。数据量一大,CPU 时间基本都耗在解释器调度上,不在计算本身。

这种代码,最烦的是你肉眼看不出多慢。你会觉得“一个 for 而已,能慢到哪去”。结果一上百万、上千万条数据,马上开始做人。

Numba 的用法很直接:

from numba import njit
import math
import numpy as np

@njit
defcalc_score_fast(values):
    total = 0.0
for x in values:
if x > 0:
            total += math.sqrt(x) * 1.17
return total

arr = np.random.randn(2_000_000).astype(np.float64)

print(calc_score_fast(arr))

就这点改动。不是重构系统,不是换语言,就是把热点函数单独拿出来,喂给 Numba 编译。

它干的事,说粗一点,就是把这段原本靠解释器跑的 Python 数值循环,编成更接近机器能高效执行的代码。第一次调用会有编译开销,后面再跑,速度就上来了。

这里有个细节很多人第一次会误判:Numba 不是给所有 Python 代码都加速。它擅长的是数值计算、数组处理、循环密集型逻辑。你要是拿它去编译一堆字典嵌套、字符串拼装、网络请求、数据库操作,那基本别抱太大希望。

所以别一上来就给整个项目全贴 @njit。这路子我试过,除了把报错搞复杂,没别的好处。正确姿势是先找热点,再下刀。

我平时排这种性能问题,顺序很固定:

先用最笨的方法确认热点在哪。

import time

start = time.perf_counter()
result = calc_score(arr)
end = time.perf_counter()

print(f"cost: {end - start:.4f}s, result={result}")

嫌这个太粗,再上 cProfile:

import cProfile
import pstats

profiler = cProfile.Profile()
profiler.enable()

calc_score(arr)

profiler.disable()
stats = pstats.Stats(profiler)
stats.sort_stats("cumtime").print_stats(10)

先把最耗时的函数抓出来。别上来全局优化,八成白忙。

比如批量计算交易信号、风控阈值、轨迹特征时,经常会写出这种双层循环:

deffind_signals(prices, threshold):
    n = len(prices)
    signals = []
for i in range(1, n):
        diff = prices[i] - prices[i - 1]
if diff > threshold:
            signals.append((i, diff))
return signals

这代码看着也不大,但数据一多,照样拖垮。 如果只是为了要结果位置和数量,可以改成更适合 Numba 的写法,别在循环里频繁 append Python 对象:

from numba import njit
import numpy as np

@njit
deffind_signal_indexes(prices, threshold):
    n = len(prices)
    idx_buf = np.empty(n, dtype=np.int64)
    diff_buf = np.empty(n, dtype=np.float64)
    count = 0

for i in range(1, n):
        diff = prices[i] - prices[i - 1]
if diff > threshold:
            idx_buf[count] = i
            diff_buf[count] = diff
            count += 1

return idx_buf[:count], diff_buf[:count]

这就有点现场味了。 不是“为了优雅”,是为了让编译器能吃进去。你继续抱着 Python list、tuple、dict 那套动态玩法不放,Numba 也很难救你。

还有一个地方特别容易踩坑: 很多人测完说“怎么没提速”。我一看代码,第一轮调用时间拿去比了。Numba 首次运行包含编译过程,当然不漂亮。正确测法至少跑两次,后面那次才像样。

calc_score_fast(arr)  # 预热编译

start = time.perf_counter()
calc_score_fast(arr)
end = time.perf_counter()

print(f"numba cost: {end - start:.4f}s")

至于“百倍”这件事,别当保健品广告听。

我实话讲,不是所有代码都能百倍。 但在几类场景里,确实能非常夸张:

  1. 纯数值循环很多;
  2. 数据结构比较规整,最好是 numpy.ndarray;
  3. 热点足够集中,不是到处零碎耗时;
  4. 逻辑不依赖 Python 动态特性。

这种情况下,从几秒掉到几百毫秒,甚至再狠一点,我都见过。 但你要是业务主耗时在 I/O,像查库、调接口、读 Redis、发 MQ,那换十个编译器也救不了。因为瓶颈根本不在解释器。

再说一个很多人忽略的判断: 如果你只是为了提速去改代码,结果把原本能看懂的逻辑改成一坨硬邦邦的“伪 C 风格 Python”,那要算账。性能提升值不值这份维护成本,得看场景。

我自己的习惯是这样:

  • 业务流程代码照常写,保持可读;
  • 把最重的那 5% 热点函数抽出来;
  • 输入尽量换成 numpy 数组;
  • 在那一小块里按 Numba 的习惯写;
  • 只优化证明确实慢的地方。

别把整个项目搞成性能宗教。 真正线上能活下来的优化,大多都很克制。

最后给个很实用的判断标准。你手上如果有这几类代码,可以优先试 Numba:

  • 批量特征计算
  • 大数组循环处理
  • 风控评分
  • 仿真计算
  • 日志数值统计
  • 时间序列回测里的热点逻辑

如果你的代码主要长这样:

for row in data:
    score = calc(row["amount"], row["days"], row["rate"])

而 calc() 里面又是一堆数值判断和循环,那就别再让解释器硬扛了。先把这块单独拎出来编译,往往比你继续抠微优化值钱得多。

Python 慢,很多时候不是语言原罪,是你把该交给编译器的活,留给了解释器。 这口锅,解释器背得有点冤。