2. 第一个@jit函数

上一节我们理解了Numba“在运行时把Python函数翻译成机器码”的基本定位。这一节要真正动手:写一个纯Python数值函数,给它加上@jit装饰器,观察它从“解释执行”变成“编译执行”的过程,并学会用nopython模式判断编译是否成功,用计时手段区分编译时间与运行时间。这是后续所有Numba优化的起点。

从纯Python函数开始

选一个足够简单、又能体现数值计算特征的函数作为起点:计算一维数组的平方和。纯Python版本如下:

import numpy as np

def sum_sq_py(a):
    total = 0.0
    for i in range(a.shape[0]):
        total += a[i] * a[i]
    return total

a = np.arange(1_000_000, dtype=np.float64)
print(sum_sq_py(a))   # 3.333328333335e+17

这个函数用显式循环逐元素累加,正是Numba最擅长加速的形态:循环次数多、每轮只做少量标量运算、没有复杂对象操作。如果改用np.sum(a*a),向量化本身已经很快,Numba的收益反而不明显。因此教学示例刻意保留循环写法。

加上@jit装饰器

Numba的核心入口是numba.jit。最简用法是把它作为装饰器套在函数上:

from numba import jit

@jit
def sum_sq_numba(a):
    total = 0.0
    for i in range(a.shape[0]):
        total += a[i] * a[i]
    return total

此时函数体一个字都没改,只是多了一行装饰器。第一次调用sum_sq_numba(a)时,Numba会检查传入参数的类型(这里是float64[:]一维数组),据此生成一份针对该类型的机器码并缓存,然后执行。第二次及以后用相同类型调用,就直接复用已编译版本,不再编译。

装饰器还可以带参数,最常用的是nopython=True和cache=True:

@jit(nopython=True, cache=True)
def sum_sq_numba(a):
    total = 0.0
    for i in range(a.shape[0]):
        total += a[i] * a[i]
    return total

cache=True会把编译产物写入__pycache__,下次启动进程时若函数与类型未变,可跳过编译直接加载,这对反复调试的脚本很实用。

nopython模式与object模式

这是本节最需要建立的概念区分。@jit默认采用“惰性回退”策略:Numba先尝试把整个函数编译成不依赖Python解释器的原生代码,这就是nopython模式;如果函数里出现了Numba不支持的Python特性(例如操作字典的某些方法、调用未支持的第三方库、使用try/except的复杂形式等),编译会失败,Numba就退回到object模式——把函数中能编译的部分编译,不能编译的对象仍以Python对象方式处理,运行时频繁进出解释器。

object模式虽然“能跑”,但性能往往接近甚至不如纯Python,因为它没有真正摆脱Python对象模型。因此工程上应尽量让函数停留在nopython模式。判断方法有两种:一是显式写@jit(nopython=True),此时若无法编译会直接抛出TypingError或NumbaError,问题立刻暴露;二是用.nopython_signatures或.signatures属性检查已编译签名,或调用numba.typeof辅助排查。推荐在开发阶段一律使用nopython=True,把回退变成显式报错,避免“看起来加速了、其实还在解释执行”的假象。

@jit 首次调用时的编译路径与两种模式 首次调用 sum_sq_numba(a) 推断参数类型 float64[:] 尝试 nopython 编译 生成原生机器码 编译成功? (类型可支持) nopython 模式 纯原生执行,性能最佳 签名缓存,后续调用免编译 object 模式(回退) 部分代码仍走解释器 性能接近纯 Python nopython=True 时 直接抛 TypingError 问题立即暴露,便于修复 是 否(默认回退) 显式 nopython=True 时改为报错 工程建议 开发阶段统一使用 @jit(nopython=True),让不支持的特性以异常形式暴露;确认稳定后再考虑 cache=True 减少重复编译。 object 模式只作为“能跑通”的过渡状态,不应作为性能优化的最终形态。

测量编译时间与运行时间

Numba的加速效果必须用数据说话,而计时最容易踩的坑,是把首次调用的编译开销算进了“运行时间”。正确做法是:先做一次“预热调用”触发编译,再对后续调用计时。下面用time.perf_counter演示:

import time

# 预热:触发编译,不计入运行时间
sum_sq_numba(a)

# 测量纯运行时间(多次取平均)
t0 = time.perf_counter()
for _ in range(20):
    sum_sq_numba(a)
t_run = (time.perf_counter() - t0) / 20

# 单独测量编译时间:换一个新类型触发重新编译
b = np.arange(1_000_000, dtype=np.float32)
t0 = time.perf_counter()
sum_sq_numba(b)          # 首次以 float32 调用,触发新编译
t_compile = time.perf_counter() - t0

print(f"运行时间: {t_run*1e3:.3f} ms")
print(f"编译时间: {t_compile*1e3:.1f} ms")

在一台普通笔记本上,这类示例的典型量级是:纯Python循环约几十毫秒,nopython模式约零点几毫秒,编译时间约几十到上百毫秒。也就是说,编译开销通常远大于单次运行时间,只有当函数被反复调用(例如在循环、仿真步进或大批量数据处理中调用成千上万次)时,摊销后的收益才显著。这正是Numba适合“热函数”而不适合“一次性脚本”的原因。

计时时务必关闭其他重负载程序,并多次重复取平均;单次perf_counter读数受系统调度影响较大。若想更严谨,可用timeit模块的repeat与number参数。

一个可复现的对比示例

把上面的片段组织成一个完整脚本,就能得到一张清晰的对比表(以下数值为示例量级,实际结果随机器与数组规模变化):

实现方式首次调用后续单次运行说明
纯 Python 循环约 40 ms约 40 ms无编译开销,但每轮都解释执行
@jit(nopython)约 120 ms(含编译)约 0.6 ms首次含编译,之后纯原生执行
@jit(object 回退)约 80 ms约 35 ms部分编译,性能接近纯 Python

读表时要抓住两点:第一,比较“后续单次运行”才能反映真实加速比;第二,object模式那一行说明“加了装饰器不等于加速”,必须确认落在nopython模式。若你的函数在nopython=True下报错,应优先检查是否用了Numba不支持的容器操作或第三方调用,而不是简单退回默认@jit了事。

常见问题与排查方向

  • 首次调用特别慢:这是编译开销,属正常现象;用预热调用把它排除在计时之外。
  • 加了@jit却没变快:很可能回退到了object模式。改用nopython=True让错误显式抛出,再逐项修复不支持的写法。
  • 换一种数组类型后又变慢:Numba按类型分别编译,float32与float64是两份机器码,各自都有首次编译成本。
  • 函数里用了全局变量或闭包:nopython模式对全局常量的支持有限,尽量把依赖作为参数传入。