← 返回模块
3.3.1.4beta 可读 · 未来付费内容校验中内容版本 2026-05-27

用 numba、Cython 与 cffi 加速热点循环

3.3.1 · 并发与性能 · 编程

Hook

周三晚上九点,深圳一家私募的波动率小组要在 T+1 风控窗口前更新沪深300 ETF(510300.SH)覆盖期权组合的隔夜 VaR 输入。研究员把上节课的 ProcessPoolExecutor 推到了 32 颗核,但每个标的 5,000 个交易日的 GARCH(1,1) 方差递推单跑仍要 0.8 秒——把 800 只 A 股一起标定就是 10 多分钟。瓶颈不在并行:内层递推「今天的方差依赖昨天的方差和昨天的收益平方」打破了 NumPy 向量化(vectorisation)的前提,asarray 之后那个纯 Python for 循环吃掉了 99% 的时间。该把工具栈从「向量化就停」往下再压一级,把那段 Python 解释循环换成原生码(native code)。

为什么向量化救不了 GARCH 递推

GARCH(1,1) 的方差递推(variance recursion)写成数学就是

σt2=ω+αrt12+βσt12\sigma_t^2 = \omega + \alpha\, r_{t-1}^2 + \beta\, \sigma_{t-1}^2

Formula Explorer

\sigma_t^2 = \omega + \alpha r_{t-1}^2 + \beta \sigma_{t-1}^2

每一步要的是上一步的 σt12\sigma_{t-1}^2——这就是串行数据依赖(serial data dependency)。NumPy 向量化能并列地对所有元素施加同一种运算,但无法在元素之间传递状态。3.2.1 学到的「写向量化就停」在 90% 的场景下成立;剩下的 10% 是单核数值热循环(hot loop),你要做的是把那个 Python 解释器循环干掉、换成原生码。

降到原生有三条路,按你日常会伸手的频率排:numba(即时编译,just-in-time compilation, JIT)、Cython(提前编译,ahead-of-time compilation, AOT)、cffi(包裹现成 C 库)。

numba:80% 的场景靠它

@numba.njit 等价于 @jit(nopython=True):首次调用时通过 LLVM 把函数编译成原生码,之后所有调用走编译产物(compile cache)。支持的类型是数值标量、np.ndarray(数值 dtype)以及它们的元组;不支持 pd.DataFrame、Python dict、绝大多数 str 操作——你的函数必须落在 nopython subset 内。

值得记住三个关键字参数。cache=True 把编译产物落盘,跨进程复用,去掉重启时的预热(warm-up)成本;parallel=True 配合 numba.prange(n) 替换 range(n),让 embarrassingly-parallel 循环吃满所有核(parallel loop via prange);nogil=True 让函数在执行期释放 GIL(release the GIL via nogil=True),可被 L2 的 ThreadPoolExecutor 真正并发调用。

回到 510300.SH 的 GARCH 例子,先看纯 Python 基线:

def garch_python(returns, omega, alpha, beta):
    n = len(returns)
    sigma2 = [0.0] * n
    sigma2[0] = returns.var()
    for t in range(1, n):
        sigma2[t] = omega + alpha * returns[t - 1] ** 2 + beta * sigma2[t - 1]
    return sigma2

加一行 @njit 装饰即可:

from numba import njit

@njit(cache=True, parallel=False, nogil=True)
def garch_njit(returns, omega, alpha, beta):
    n = len(returns)
    sigma2 = np.empty_like(returns)
    sigma2[0] = ((returns - returns.mean()) ** 2).mean()
    for t in range(1, n):
        sigma2[t] = omega + alpha * returns[t - 1] ** 2 + beta * sigma2[t - 1]
    return sigma2

变化只有两处:sigma2 从 Python list 换成 np.empty_like(returns)(list 在 nopython 模式下不被支持),sigma2[0] 的初始化展开为不依赖 ndarray.var() 的等价表达式,因为在 nopython 子集里这个方法的某些重载会被拒签。递推那行字面相同——正是这一行吃掉了 99% 的时间。

用上节课的 timeit 量一次。r 取 5,000 个交易日(约 20 年 A 股交易日)的对数收益数组,参数 (omega=1e-6, alpha=0.08, beta=0.91) 是 510300.SH 日序列上合理的 fit 值(alpha + beta < 1 保证平稳性):

from timeit import timeit
t_py = timeit(lambda: garch_python(r, 1e-6, 0.08, 0.91), number=10)
_ = garch_njit(r, 1e-6, 0.08, 0.91)  # warm up the JIT
t_njit = timeit(lambda: garch_njit(r, 1e-6, 0.08, 0.91), number=10)
print(f'pure python: {t_py:.3f} s; njit: {t_njit:.3f} s; speed-up: {t_py / t_njit:.1f}x')

一台现代 CPU 上 pure python 约 0.5–0.8 秒、njit 约 3–6 毫秒,加速比稳在 100x 以上。预热那行不可省:第一次 garch_njit 调用要花 0.3–1 秒编译,若计进 timeit 里读数会被压平到 5–10x。需要同时跑 4 个标的?把 garch_njit 提交给 L2 的 ThreadPoolExecutor(max_workers=4) 即可——因为 nogil=True,4 条线程真正并行,不再卡在 GIL 上。

Cython:长寿模块走 AOT

Cython 走另一条路:你写 .pyx 文件、加 cdef 类型注释(type inference 的人工版),运行 python setup.py build_ext --inplace,得到 .so(Linux/Mac)或 .pyd(Windows),像普通模块那样 import。同一个 GARCH 递推用 Cython 长这样:

cimport numpy as np

def garch_cython(double[:] returns, double omega, double alpha, double beta):
    cdef Py_ssize_t n = returns.shape[0]
    cdef double[:] sigma2 = np.empty(n, dtype=np.float64)
    cdef Py_ssize_t t
    cdef double mean = 0.0, var = 0.0
    for t in range(n):
        mean += returns[t]
    mean /= n
    for t in range(n):
        var += (returns[t] - mean) ** 2
    sigma2[0] = var / n
    for t in range(1, n):
        sigma2[t] = omega + alpha * returns[t - 1] ** 2 + beta * sigma2[t - 1]
    return np.asarray(sigma2)

什么时候 Cython 比 numba 更合适?三种场景:

  1. 长寿模块:早盘前预启动的低延迟定价服务(low-latency pricing service),不能接受 JIT 在第一笔订单上付预热成本。
  2. 打包分发:要把模块以 wheel 形式交付给一个没有 numba 的运行时(某些受限私募部署环境)。
  3. 类型精度:numba 的类型推断够不到的 C 级别细控(指针运算、固定大小数组)。

python setup.py build_ext --inplace 这一行命令在做的事情,就是把这份 .pyx 翻译成 C、再调系统编译器产出一个 .so,于是你以后 import 它的代价等同于 import 一个普通 Python 模块——零预热。如果你要的不是「加上类型注释的 Python」而是「真正的 C++ 抽象」,跨章节走 3.4.2(Templates & Modern C++),那已超出本模块范围。

cffi:把现成 C 库包成函数

cffi 解决另一类问题:手里有一份现成的 C 共享库——某券商交付的信用衍生品定价 DLL、私募内部 libpricer.so、Bloomberg / Refinitiv 的 C SDK——你只想从 Python 调用,​​不写 C 源码​​(C source authoring 留给 3.4,pybind11 / PyO3 同样是 3.4 / 3.5 的话题)。最小模板:

from cffi import FFI
ffi = FFI()
ffi.cdef('double do_thing(double x);')
lib = ffi.dlopen('./libthing.so')
print(lib.do_thing(1.5))

ffi.cdef 用 C 语法做函数签名声明(function signature declaration via ffi.cdef),告诉 cffi 参数与返回值的类型布局;ffi.dlopen 加载原生 C 库(native C library),返回一个可像普通对象那样取属性的句柄;lib.do_thing(1.5) 像普通函数调用一样过 ABI。三行加在一起就把一份对手方交付的不透明二进制接入了你的 Python 流水线。

决策树

把整模块的四节课串成一条决策链:

  1. ​I/O 密集​​:上节课的 asyncio(L3),或库无异步版时退回 ThreadPoolExecutor(L2)。
  2. ​CPU 密集且任务级并行​​:ProcessPoolExecutor(L2),适合互不依赖的批任务(蒙特卡洛切片、跨标的回测)。
  3. ​单核数值热循环​​:先向量化(3.2.1 NumPy)。
  4. ​向量化救不了​​:@numba.njit(parallel=True, nogil=True)——本课主力。
  5. ​numba 编译不了或预热不可接受​​:Cython。
  6. ​包现成 C 库​​:cffi。

口诀一句:向量化 -> numba -> Cython -> cffi -> 写 C/C++ (跨章节)。问题再大再密——单 GPU 上 GARCH-MIDAS 全市场标定、千资产相关矩阵特征分解——下一步是 GPU 计算(CuPy、JAX、Numba CUDA),本模块只点到为止。

衔接下一模块

到这里你已经具备一个 2026 年工作量化开发者日常需要的全部并发与性能工具:测量先行、线程吃 I/O、进程吃多核、协程吃高并发 I/O、JIT 吃单核数值热循环。下一模块 3.3.2(Patterns & Tooling)换一种关切:当一个性能优化过的模块被几十位同事和几个生产服务共用时,你怎么用 @dataclassProtocol、依赖注入与配置驱动让它演化而不腐烂——把「跑得快」升级到「跑得久」。

练习

Exercise

给定本课展示的纯 Python garch_python(returns, omega, alpha, beta) 函数与一段 5,000 元素的 NumPy returns 日对数收益数组:

(a) 用 timeit(..., number=10) 测量一次 garch_python 的墙钟耗时。

(b) 写出 @numba.njit(cache=True, nogil=True) 版本的 garch_njit(签名 (returns, omega, alpha, beta) 严格匹配),并在计时前先做一次预热调用。

(c) 用同样的 timeit(..., number=10)garch_njit

(d) 以 3 位小数的秒数与 1 位小数的加速因子报告两个时间,并说明加速比是否大于 50x。

提示
预热那一行 _ = garch_njit(r, 1e-6, 0.08, 0.91) 必须放在第二次 timeit 之前,否则编译时间会被计入第一次调用,加速比读数会被压平到 5–10x。
提示
函数体只把 sigma2 = [0.0] * n 换成 np.empty_like(returns),把 returns.var() 展开为不依赖 ndarray 方法的等价表达式,递推那行 sigma2[t] = omega + alpha * returns[t - 1] ** 2 + beta * sigma2[t - 1] 原样保留。

延伸阅读:numba 用户指南(英文;中文社区翻译有一定滞后,建议直接看英文);Cython 教程(Wesley Bell 在 GitHub 上的中文翻译可作参考);cffi 官方文档;《Fluent Python》第 2 版中译中 numba / Cython 的对照段落。安装提示:国内 pip 装 numba 优先用 pypi.tuna 镜像(pip install -i https://pypi.tuna.tsinghua.edu.cn/simple numba),否则 llvmlite 的下载经常超时。