跳转至

我的"加速层"让程序慢了近百倍——一个从未被测量的模块

本文所有性能数字均来自可复现脚本:experiments/bench_accel.py → evidence/bench_accel_results.csv / .md。 实验环境:WSL2 / Python 3.12 / numba 0.68.1 / numpy 2.5.4,固定随机种子,每档重复取最优。

一、现象

先说背景里那个让我自信过头的时刻:在一个 WSL2 环境里,PyTorch 和 CuPy 的 GPU 一切正常,Numba 的 CUDA 却始终不可用。当时的排查记录写了好几份文档,最后收敛成一个决定:不修,绕开——GPU 加速只消费现成库(PyTorch/CuPy),不写自定义内核。 当年的原始报错没有存档,只有结论和记忆——排查骨架倒是留在几份文档里(修复尝试、修复决策对比、GPU 状态终版):PyTorch GPU 与 CuPy GPU 一切正常、唯独 Numba CUDA 在 WSL2 上不可用,修复决策文档对比了两条路线之后选择了不修。这个决定本身没错,但它给了我一种错觉:"这个系统的加速体系是健康的。"

直到我对加速层做第一次真正的测量。加速器模块里写着"Numba JIT 加速"的三个函数——一个装饰器都没有,就是三个纯 Python 的 for 循环。测量结果:

数据规模 n "加速层"路径 NumPy 向量化 真 JIT 版本 加速层/NumPy
1 万 0.001905s 0.000019s 0.000005s 慢 100 倍
10 万 0.019363s 0.000339s 0.000036s 慢 57 倍
100 万 0.193637s 0.002089s 0.000376s 慢 93 倍

最大回撤的计算同样如此(慢约 33 倍)。而真正加上 @njit 装饰器的同一段算法,比 NumPy 快 4~9 倍,首次编译只花了 0.251 秒。

还有一个更讽刺的细节:这个加速层有一个"自动回退"结构——检测到 numba 可用就走"加速路径",否则回退 NumPy。也就是说,在这台装了 numba 的机器上,每次调用走的都是最慢的那条路。不装 numba,反而更快。

而项目文档里写着它的性能声称:"比 pandas 快数量级""指标计算快 50 倍"——仓库里找不到任何一份生成这些数字的基准数据。

二、我一开始的错误判断

先回答数字来源,因为查过了:性能声称的出处是实施总结文档——白纸黑字写着 "Indicator calculation: 50x faster than pandas",甚至配了一张 2.5s 对 0.05s 的对照表;但这些数字没有任何基准脚本或原始输出与之对应——表格是无源之水,同一批文档之间互相引用,从未对过一次账。至于写这个模块时的心境,代码自己的痕迹比记忆可靠:numba_cudf_accelerator.py 里三个以 _numba 结尾命名的函数、import 进来从未使用的装饰器名、一套三层回退结构——每一步都是照着“加速系统的样子”搭的,只是没有任何一步停下来量一次。

复盘三个典型误判:

  1. "import 了 = 用上了"。代码里确实 from numba import jit, cuda 了,但那两个名字除了被 import 之外从未被使用。看到 import 语句,大脑就自动把"库的能力"记成了"代码的能力"。
  2. "fallback 结构 = 双保险"。回退结构防的是"库不可用时程序崩",它防不了"回退路径比正常路径更慢"——甚至防不了"主路径本身就是负优化"。回退解决的是可用性,不是性能。
  3. "系统整体能用 = 每一层都有效"。端到端能出结果,我就没怀疑过中间某层在拖后腿。性能问题不报错、不抛异常,只会安静地吃时间。

三、排查与关键证据

证据 1(代码事实,可逐行核对):加速器文件里三个以 _numba 结尾的函数,没有任何 numba 装饰器,函数体就是纯 Python 循环。命名暗示的能力与代码实际的能力不符。

证据 2(实测三路对照):同一算法、同一输入(固定随机种子)、同机同环境,三组对照:

  • 纯 Python 循环(加速层现状):比 NumPy 慢 57~100 倍;
  • NumPy 向量化(所谓"回退路径"):本组基线;
  • 真 @njit(补上装饰器的同一算法):比 NumPy 快 4~9 倍,且编译开销一次性仅 0.251 秒。

证据 3(行为一致性):三路输出的数值全部对拍一致(allclose = True)。也就是说,三条路径算的是同一个东西——性能差异完全来自实现方式,不存在"为了快牺牲了正确性"的借口空间。

证据 4(缺失的证据):模块里写好了基准测试方法,但仓库里不存在任何一份它运行后保存的结果。所有性能声称都是无源之水。

四、根因

  1. 加速层没有性能验收环节。合入的标准是"能算对",不是"算得快"。一个以"加速"为名的模块,可以无限期地不快。
  2. 回退结构掩盖了失效。分层回退让每条路径都有代码可看,制造了"体系完备"的观感;但"库不可用"和"库可用时更慢"是两种完全不同的故障,回退结构只防前一种。
  3. 文档与代码分头生长。性能表格不是从测量里长出来的,而是从期望里写出来的——没有"数字必须能反查到生成它的脚本"的规矩,声称和事实就断了链。

五、约束与取舍

  • 保留了三路实现(纯 Python / Numba / CuPy)换取兼容性。代价:三条路径的行为和性能差异需要持续对齐,而现实中从没对齐过——本文的基准就是第一次。修正方向:要么给每条路径都上装饰器并纳入基准验收,要么删到只剩两条。
  • 放弃了给回测引擎做 GPU 化(WSL2 上 Numba CUDA 不可用后的决策)。代价:参数扫描的规模上限被 CPU 锁死。收益:不写内核、不维护驱动兼容性,环境稳定。在个人项目的规模下,这笔交易我认为是对的——但它的前提是"我知道我放弃了什么",而这句话只有做了测量之后才配说。
  • 修正动作(已完成):给真 JIT 版本补上装饰器并落基准;把"文档性能表格必须能反查到原始输出"写进自己的写作规则。

六、可复用检查清单

  1. 任何名为"加速"的代码,合入条件必须包含一份落盘的基准数据(输入规模、环境、原始输出文件三要素);
  2. fallback 的每一层单独基准化——回退正确 ≠ 回退不慢,主路径本身也要测;
  3. 文档里的每个性能数字必须能反查到生成它的脚本与原始输出,查不到就删掉;
  4. GPU 路径把"主机↔设备搬运"单独计时,小数据场景先算搬运占比再谈加速;
  5. 环境层"某库不可用"的结论用自检脚本固化成可重跑的断言,别靠记忆和文档。

复现材料

  • 脚本:experiments/bench_accel.py
  • 原始结果:evidence/bench_accel_results.csv / .md
  • 版本快照:evidence/old_env_freeze.txt(事故环境实际安装的 167 个包及版本——注意它与 requirements.txt 声称的版本并不一致,这是另一篇文章的素材)
  • GPU 自检:事故环境的解释器已不在机上(本机现存各解释器均无 numba),old_env_freeze.txt 那份 167 包的冻结清单是它留下的唯一指纹;scripts/check_cuda.py 保留为固化断言——任何新环境跑一遍,输出即该环境的 GPU 加速能力清单。