← 返回文章列表
July 6, 2026
5 分钟阅读

GPU 精度陷阱:Apple Metal 上的 fp32 回测如何悄无声息地返回垃圾结果

GPU 精度陷阱:Apple Metal 上的 fp32 回测如何悄无声息地返回垃圾结果
#算法交易
#回测
#gpu
#苹果芯片
#浮点数
#数值稳定性
#mlx
Part 8 of 10 · Collection
High-Performance Backtest Engines

"回测无幻觉"系列文章。

我们把参数扫描回测移植到了 GPU 上,速度提升了 2,796 倍。它返回的数字看起来说得过去。但在第一个能跑起来的版本里,这些数字其实彻头彻尾是垃圾——偏差达到两百倍——而且没有崩溃,没有警告,看起来一切正常。

这正是让苹果芯片上的 GPU 回测比 CPU 代码危险得多的那个陷阱:Metal 没有 float64。 在 Apple GPU 上,你的回测触碰到的每一个数字都是 32 位浮点数,不管你有没有要求。而向量化移动平均最诱人的那种做法——每个注重性能的量化人都会伸手去拿的 O(n) 前缀和技巧——恰恰就是 fp32 在价格量级下扛不住的那种公式。它不会报错。它满速运行,交给你一条看起来合情合理的权益曲线,而这条曲线所依赖的移动平均本身却有 211 倍的偏差。

有意思的地方在修复方法上:它不是"用更多位数"(做不到),也不是"更加小心"(朴素代码本来就已经够小心了)。真正的修复是用另一种求和方式计算同一个数学量——一次直接的窗口卷积——让每一个中间值都小到 fp32 能精确表示。同样的 WMA,结果精确到七位有效数字,还比 CPU 上单线程的 numba 快 55.9 倍。这篇文章就是一次尸检:Metal 为什么强迫你使用 fp32、那个显而易见的公式究竟在哪里溢出、正确的公式为什么不会溢出,以及我们如何通过数交易笔数、而不是盯着曲线看,证明了快速版本和诚实的 fp64 版本互相一致。

本文所有数字都在一台 Apple M2 Max 上测得,150,000 根 K 线 × 80 组参数组合,取三次里最好的一次,数据来自仓库中的 scripts/bench_param_sweep.py(M5 GPU 方法,提交 97eadaf)、scripts/engine_multitf_gpu.py04d71e8),以及 scripts/GPU_NOTES.md 中的设计笔记。

Metal 没有 float64

Apple 的 Metal GPU 流水线中 float64 被划掉——每条通道都被强制降到 32 位,整数仅在 1.6×10⁷ 以内精确

在 CPU 上,Python/numpy 回测默认以双精度运行。float64 给你 52 位尾数:整数在 2⁵³ ≈ 9×10¹⁵ 以内都能精确表示,相对精度大约是 1.1×10⁻¹⁶。你几乎从来不用去想这件事,因为在价格量级下——BTC 收盘价大约 30,000、一个累积和达到 10¹⁴——fp64 都还有大把余量。

Apple 的 GPU 没有这样的余量,因为它根本不提供 float64。Metal 着色语言里没有 double 类型;MLX、PyTorch-MPS,以及所有面向苹果芯片的框架都继承了这一点。没有开关能把它打开,也没有一个"慢但正确"的后备方案。只要你的计算触碰 GPU,它就是在 fp32 下进行的:

  • 尾数:23 位(算上隐含的前导位是 24 位)。整数仅在 2²⁴ = 16,777,216 ≈ 1.6×10⁷ 以内才是精确的。
  • 相对精度:约 1.2×10⁻⁷。 大致相当于七位有效十进制数字,仅此而已。

1.6×10⁷ 这道上限就是整个故事的核心。听起来很宽裕——一千六百万——直到你注意到,一次回测经常会构建出远大于这个数字的中间量,而一旦某个中间量越过 1.6×10⁷,fp32 就连连续的整数都表示不出来了,更别提你需要的那些小数结构。精度不是渐进式退化的;它是断崖式跌落的,而这道悬崖所在的那个数字,你的数据毫不犹豫就会越过去。

陷阱就在于,fp32 几乎总是没问题。回测里的大部分东西——价格、收益率、PnL、夏普比率——都舒舒服服地待在七位数字绰绰有余的范围里。所以朴素的移植版本能跑通,能通过冒烟测试,产出看起来合理的输出。故障恰好只局限在一个操作里,恰好就是某个中间量冲破 10⁷ 的那个地方——而这个操作,恰好就是所有人最先拿去向量化的那个。

诱人的公式:用一次前缀和扫描算出 WMA

我们的策略依赖 Hull 移动平均线。一条 HMA 是三条加权移动平均线组合而成;HMA3 这个变体则是四条。在长度为 p 的窗口上,使用线性核的加权移动平均定义为

WMAt=k=1pkxtp+kp(p+1)/2.\mathrm{WMA}_t = \frac{\sum_{k=1}^{p} k \cdot x_{t-p+k}}{p(p+1)/2}.

要在 150,000 根 K 线上扫描成千上万组参数组合,WMA 卷积本身就是全部开销所在。所以直觉的做法——在 CPU 上也是正确的做法——是用前缀和把每条 WMA 从 O(n·p) 降到 O(n)。你只需要预先算好两个累积和,

S1[t]=jtxj,S2[t]=jtjxj,S_1[t] = \sum_{j \le t} x_j, \qquad S_2[t] = \sum_{j \le t} j \cdot x_j,

然后任何一个窗口的线性加权和都能收敛成 S1S2 上少数几次做差与索引平移。不需要逐窗口循环,不需要逐窗口归约——两趟 cumsum 扫描下来,整个 WMA 矩阵就从数组运算里自然而然地掉出来了。它向量化得极其漂亮,完美映射到 GPU 的并行扫描(parallel-scan)原语上,而且在 fp64 下完全正确。

但它也是你在 fp32 下能做的最糟糕的一件事,原因就藏在 S2 里。

S2 = mx.cumsum(j * price)     # j is the global bar index: 0, 1, 2, ... , n-1

问题出在 j · price 这一项上。j 一路跑到 150,000,price 大约是 30,000,光是最后那一项就有 4.5×10⁹,而 S2 是 150,000 个这样的项累加起来的运行和。它根本不会停留在价格量级上。它一路爬升到 fp32 早就已经数不清的领地。

溢出发生在哪里:陷阱背后的算术

Two towering ~10¹⁴ fp32 sums subtracted to recover a small windowed value that vanishes beneath their rounding error — catastrophic cancellation

让我们把这些数量级摆在一起对比,因为整个故障就藏在这个对比里。

S2 = cumsum(j · price) 大约会达到 price · n²/2 ≈ 30,000 · (150,000)²/2 ≈ 3×10¹⁴。姑且记作 约 10¹⁴。 再回忆一下 fp32 的整数精确上限:约 1.6×10⁷。 这个运行和越过了 fp32 能精确表示的最后一个整数,整整越过了七个数量级。

具体意味着什么?在 10¹⁴ 附近,两个相邻可表示 fp32 数字之间的间隔——即最后一位的一个单位,ULP——大约是 2²³ ≈ 8×10⁶。 所以一旦 S2 爬到 10¹⁴ 这个量级,它的精度就只剩下 ±8×10⁶ 以内。它存的每一个值,都已经被四舍五入到了约 8×10⁶ 的最近整数倍上。

现在看看 WMA 的恢复过程对这件事做了什么。要提取单个窗口的加权和,你需要对两个相邻的 S2 值做差(再加上 S1 的修正项)。这两个 S2 值各自都在 ~10¹⁴ 量级,各自都带着 ±8×10⁶ 的舍入噪声。它们真正的差值——也就是你实际想要的那个窗口量——归一化之后,对应的是一条量级在价格本身、约 3×10⁴ 的 WMA。所以这里的算术是:

(1014±8×106)fp32(1014±8×106)fp32=a quantity you need to a few parts in 104true answer\underbrace{(10^{14} \pm 8\times10^6)}_{\text{fp32}} - \underbrace{(10^{14} \pm 8\times10^6)}_{\text{fp32}} = \underbrace{\text{a quantity you need to a few parts in } 10^4}_{\text{true answer}}

这就是最纯粹形式的灾难性抵消:每个操作数的舍入误差(±8×10⁶)比你想恢复的答案本身还要大。信号比它被提取出来的那些数字的噪声本底还要小。这不是丢掉几位数字的问题——你丢掉的是全部数字,而算出来的结果,完全由 cumsum 累积下来的舍入误差主导。

来自 GPU_NOTES.md 的实测后果是:在 150,000 根 K 线、价格约 30,000 的条件下,用这种方式算出来的 WMA,相对 fp64 的最大相对误差达到约 211。 不是 211%——是 211 倍。算出来的移动平均可能与真实值相差整整两个数量级。而真正让它成为一个陷阱而非普通 bug 的地方在于:它能跑完全程,返回有限的、看起来说得过去的数字。 不会溢出到无穷大,不会出现 NaN,不会抛异常。一条偏差 211 倍的移动平均线,看起来依然像一条移动平均线——它是平滑的,是有限的,在那些抵消恰好比较轻微的 K 线上,数值大致还在合理区间——于是它顺利通过了每一项不是直接对照可信参照的健全性检查。你得到的是一次完整的回测、一条完整的权益曲线、一整套"最优"参数——全都建立在一个虚构出来的指标之上。

修复方法不是提高精度——而是换一种求和方式

A short sliding window of ~200 same-scale terms summed directly by convolution — every partial sum stays near price scale, inside fp32's exact range

一旦看到这个误差,本能的反应是去追求更高精度——用 fp64 累加,或者用补偿求和(Kahan 求和)。在 Metal 上,前者根本不可用。但你两个都不需要,因为问题从来都不在于位数。问题在于公式本身。 前缀和这个技巧制造出了 10¹⁴ 量级的中间量,然后又把它们减回来;它制造出的这些量级,是算法本身的产物,而不是答案的产物。只要选一种从不制造这些量级的公式,fp32 就完全没问题。

这种公式就是定义本身:直接的窗口卷积。 不用两个全局累积和,而是把长度为 p 的线性核在整条序列上滑动,就地求和。每个输出都是至多 p ≈ 200 项的和,而每一项都是 权重 × price,权重已经归一化到总和为 1——所以每一项的量级都是 price / p,每一个部分和都停留在价格量级附近(~3×10⁴),没有任何一个中间量会接近 fp32 上限的六个数量级以内。没有什么可抵消的,因为从来没有什么被膨胀过。

在 MLX 里,这就是一个原语——mx.conv1d——恰恰是 GPU 天生擅长快速完成的事情:

def _mx_wma_valid(x, period):
    w = mx.arange(1, period + 1, dtype=mx.float32) / (period * (period + 1) / 2.0)
    return mx.conv1d(x.reshape(1, -1, 1), w.reshape(1, period, 1), padding=0).reshape(-1)

同样的 WMA,在数学上与前缀和版本、以及 CPU 上的 fp64 vec_wma/nb_wma 完全等价。但现在实测得到的相对 fp64 的最大相对误差是 8.2×10⁻⁷——恰好落在 fp32 约 1.2×10⁻⁷ 的噪声本底上,七位有效数字完全吻合。这个在纸面上看起来更慢的公式(O(n·p) 而不是 O(n))却是唯一正确的那个,而且——正因为它是一次密集卷积,GPU 能同时在 K 线和窗口两个维度上并行——它跑起来也快得惊人。我们把相对误差从 211 降到了 8×10⁻⁷,靠的是改变求和的方式,而不是改变求和时用几位数字。

这样做还带来两个实践上的注意点。第一,MLX 不会像 numpy 那样让 NaN 穿过 conv1d 传播,所以预热区间(前 p−1 根 K 线,窗口平均在这里没有定义)在 GPU 上没法用 NaN 标记出来。但我们也不需要它这么做:每条序列的有效起点都能通过解析方式提前知道,无效前缀会被填上从不会被读取的零,NaN 填充随后在 CPU 上恢复——与向量化版本和 numba 版本的有效性掩码逐位完全一致。第二,整个扫描共用同一条 cand_close 序列,并且在各个组合之间大量复用窗口,所以一次带多个输出通道的批量 conv1d,就能在一次 GPU 调用里算出这次扫描需要的每一条独一无二的 WMA,用一次 mx.eval() 就把结果实体化出来。

证明自己没有掉进去:靠交易笔数校验一致性

上一节应该会让人心里犯嘀咕,问出一个不舒服的问题:如果一条偏差 211 倍的 WMA 看起来依然像一条 WMA,你怎么知道那个 8×10⁻⁷ 的版本就真的对了,而不是错得更隐蔽而已?你没法靠肉眼判断。你需要一个流水线下游、离散部分暴露出来的不变量——而回测恰好给了你一个完美的不变量:交易记录。

阶梯上的其他方法(M0–M4)全都跑在 fp64 下,所以我们对它们施加了严格的等价性断言——完全相同的交易笔数,PnL 匹配到 atol=1e-6。fp32 的 GPU 方法(M5)从构造上就不可能通过这个断言,而如果为了迁就它,悄悄放松所有人的断言标准,那正是这个系列存在的意义所要对抗的那种不诚实。所以 M5 得到了它自己专属的定量一致性报告——report_equiv_fp32——把它提取出来的交易与 fp64 参照做比对。

任何残留分歧背后的机制都值得精确说清楚,因为它不是那种抵消灾难——而是你本来就该预料到的、普通的、微小的 fp32 舍入。策略在两条 Hull 均线——hh3——的交叉点上开仓。一个价格约 30,000 的指标上约 1×10⁻⁶ 的相对误差,换算成绝对波动大约是 0.03。在绝大多数 K 线上,这两条曲线之间的距离远大于这个波动,交叉判断毫无歧义。但在临界的那根 K 线上——也就是 h − h3 本身离零只差 0.03 以内——这个波动就足以翻转比较结果的符号,把一次交叉挪动一根 K 线,从而多出或少掉一笔交易。

这正是为什么"发生分歧的组合占比"是个毫无价值的健康指标——我们最初的一致性检查就是用了这个指标,结果闹了笑话。在 150,000 根 K 线上,每个组合都有成千上万次交叉,所以几乎每个组合都至少会出现一根临界 K 线——80 个组合里有 37 个"出现分歧",听起来很吓人,但什么都说明不了。真正重要的指标是差多少

  • 全部 80 个组合的 PnL 差值: 最大 |Δ| = 1.843 个百分点,最大相对值 = 1.25×10⁻²;崩溃阈值为 5 个百分点。
  • 每个组合的交易笔数漂移: 最大 |Δn| = 4 笔交易(相对成千上万笔而言),最大相对值 = 2.5×10⁻³;崩溃阈值为 1%。
  • 汇总来看:479,016 笔交易中有 90 笔发生偏移——占 0.019%。

将近五十万笔交易里出了九十笔,每一笔都是临界交叉被一个比一个价格最小变动单位还小的舍入波动轻轻推了一下,而没有一个接近崩溃阈值。这正是一个正确的 fp32 方法的特征——分歧微小、有界、可解释——与 211 倍的相对误差完全是两种动物。设置这些阈值,就是为了抓住那种打着"这不过是 fp32 而已"的幌子、实则已经损坏的公式;而真实的差值,却比阈值低了一个数量级。交易笔数才是那个权益曲线一直拒绝充当的判官。

回报,以及 GPU 帮不上忙的地方

既然正确性已经确立,速度这件事就值得拿出来说说——然后再诚实地加上限定条件,因为 GPU 的优势在整条流水线上并不是均匀分布的。

单独看纯粹的 WMA 卷积——也就是整套方法存在的意义所要加速的那个操作——fp32 的 conv1d 批处理在 8.2×10⁻⁷ 的相对误差下,跑出了比单线程 numba 快 55.9 倍的成绩。这是一个干净的、真正对等的 GPU 对 CPU 数字:同样的数学,一个编译过的 CPU 线程对垒 Metal GPU。

但一次扫描不只是卷积。一旦 HMA/HMA3 矩阵在 GPU 上算出来,交易还得被提取出来——这是一次遍历每个组合交叉点的 O(n) 遍历——而这一步我们是在 CPU 上以 fp64 完成的,复用其他方法完全相同的交易语义,而不是在 GPU 上重新实现一遍。端到端的 timed() 数字把一切都算了进去:内核预热被排除在外(与排除 numba 编译时间对称),但 GPU→CPU 的传输和 CPU 上的交易提取都算在内。在 150,000 根 K 线 × 80 个组合、取三次最优的条件下,M2 Max 上的结果:

方法 墙钟时间 相对基准的加速比 组合/秒
M0 pandas + Python 循环* 287.08s 1.0× 0.3
M1 向量化 numpy 3.14s 91.5× 25.5
M2 numba(单线程) 2.02s 142.3× 39.7
M3 多进程 + 向量化 0.50s 570.2× 158.9
M4 多进程 + numba(12 核) 0.33s 882.5× 245.9
M5 MLX GPU(fp32) 0.10s 2796.0× 779.2

*M0 是从一个均匀抽取的 5 组合样本外推得到的。

完整引擎的 M5 达到了每秒 779 个组合——相对 pandas 基准快 2,796 倍,相对单线程 numba 快 19.6 倍相对整个跑 numba 的 12 核 CPU 池(M4)快 3.2 倍。 一块 GPU 打赢了这台机器上的每一个 CPU 核心,还超出了三倍。

现在来加上诚实的限定条件:注意到端到端的 GPU 优势(相对 M2 快 19.6 倍)要小于纯卷积的优势(相对 numba 快 55.9 倍)。这个落差正是阿姆达尔定律准时赴约。GPU 把卷积消灭得如此彻底,以至于它们不再是瓶颈;剩下的部分——GPU 完全没有加速到的那段 O(n) CPU 交易提取——现在主宰了 M5 的墙钟时间。这正是这个系列里速度阶梯IPC 税两篇文章反复撞上的同一个教训:过了某个点之后,胜负手不再是"让快的部分更快",而是编排——数据放在哪里、哪个阶段现在是串行的、你在设备和主机之间搬运数据要付出什么代价。去追逐一个把交易提取塞进定制 Metal 内核的假想 M6,也只能从那份不断缩小的 CPU 份额里再抠回来一点点,这就是我们没有去做它的原因。

普遍的教训:默认状态就是悄无声息的数值垃圾

先从 Hull 均线和 MLX 里跳出来看,因为这个陷阱远远不止适用于这一个指标。

GPU 回测那套诱人的说辞是"一个大矩阵":把每一组参数组合都塞进一个张量,把整次扫描变成寥寥几个密集数组操作,交给硬件去啃。这套说辞是真的——上面那些加速比都是真的。但它悄悄改变了你脚下的数值体系,而这个改变在代码里根本看不出来。在 CPU 上,你的各种默认设置一直在保护你:fp64、NaN 的传播、一个能悄无声息跑到 10¹⁴ 都不出问题的 cumsum。把同一行数组表达式搬到 Metal 上,你就落进了 fp32、落进了那道 1.6×10⁷ 的硬性整数上限,而完全相同的那行代码——cumsum(j * price)——就从精确变成了垃圾。语法里没有任何东西会警告你。编译器毫无异议。输出是有限的、看起来说得过去的。fp32 不会大声地失败;它失败得很有礼貌,还带着一堆数字。

真正能保护你的这三个习惯,代价都很低:

  1. 搞清楚你的中间量落在哪里,而不只是输入和输出落在哪里。 输入(价格 ~10⁴)和输出(WMA ~10⁴)都舒舒服服地落在 fp32 的精确范围之内。灾难完全出在一个隐藏的中间量S2 ~10¹⁴)身上,而 API 和类型系统都没有把它暴露出来。在信任任何 fp32 归约之前,先问一句:最大的部分和会达到多大——如果它越过了约 10⁷,就换一种公式。
  2. 优先选择能把量级控制住的公式。 用直接卷积代替前缀和;用局部窗口代替全局扫描;把居中化/差分放在求和之前而不是之后。先放大、再抵消,是一种反模式。正确的算法往往是那种在纸面渐进复杂度上看起来更差、却从不制造出一个之后必须再抵消掉的量的算法。
  3. 通过一个离散不变量,对照 fp64 参照做校验。 不要比较曲线;要比较某种量化的、下游的东西——交易笔数、交叉次数、仓位变动事件。一个离散不变量,能把一个悄无声息的 211 倍误差,变成一次震耳欲聋的断言失败,也能把一个可以接受的 8×10⁻⁷ 误差,变成一个微小、有界、可解释的差值。这和前视偏差的单 K 线位移测试是同一种纪律:一个廉价的诊断手段,能把一次看不见的故障变成一次看得见的故障。

这些都不是什么高深的数值分析。这不过是那种最普通的卫生习惯——在一个缓慢但可信的回测认证之前,不去相信一个跑得快的回测——只是把这套习惯延伸到了这样一个地方:语言本身已经不再提醒你,精度已经悄悄下降了十二个数量级。

要点总结

The one-line pivot: cumsum(j·price) crossed out at 211× error, mx.conv1d beside it at 8×10⁻⁷ — same WMA, different sum

  1. Apple 的 GPU 没有 float64——你回测里每一个经过 GPU 的数字都是 fp32。 整数仅在约 1.6×10⁷ 以内精确,精度约为 1.2×10⁻⁷。没有开关,没有后备方案。回测里的大部分内容都能扛住这一点;通常恰好有一个操作扛不住。
  2. 前缀和 WMA 就是这个陷阱。 cumsum(j · price) 爬升到约 10¹⁴,超出 fp32 精确上限整整七个数量级,而恢复一个窗口的值,又逼着你去对两个这样的数字做差,它们各自的舍入误差(±8×10⁶)早就已经远远盖过了答案本身。实测最大相对误差:211 倍。 它从不崩溃——它返回的是看起来说得过去的垃圾。
  3. 修复方法是换一种求和方式,而不是加更多位数。 直接的窗口卷积(mx.conv1d)让每一个部分和都停留在价格量级附近,于是 fp32 能守住七位诚实的数字:相对误差 8.2×10⁻⁷,还比单线程 numba 快 55.9 倍。 你没法在 Metal 上买到 fp64,而你也根本不需要。
  4. 用一个离散不变量去校验,永远不要用曲线。 交易笔数一致性检查抓住了这一点:fp32 的 conv1d 与 fp64 之间,在 479,016 笔交易中有 90 笔(0.019%) 存在分歧,全都是临界交叉,全都远低于崩溃阈值——这正是一个正确方法的特征,与 211 倍的误差截然不同。"发生分歧的组合占比"是个诱饵指标;要衡量的是差多少
  5. 整次扫描达到每秒 779 个组合——相对 pandas 基准快 2,796 倍,相对整个 12 核 CPU 池快 3.2 倍——但端到端的优势(相对单线程 numba 快 19.6 倍)要小于纯卷积的优势(55.9 倍),因为 CPU 上的交易提取现在成了瓶颈。过了某个点之后,速度拼的是编排,而不是算术。

这次 GPU 移植快了 2,796 倍,而在第一个能跑起来的版本里,却完全是错的——而这两件事彼此毫无关系。速度是真实的。垃圾则来自一个隐藏的 10¹⁴ 中间量,fp32 扛不住它,也没有任何错误信息会提到它。如果一次回测突然快了很多,而数字看起来还是没问题,那并不是什么确认。在 Metal 上,"看起来没问题"看起来恰好就是 211 倍相对误差的样子。

这是本系列一直在攀爬的那道阶梯上属于 GPU 的一级:回测引擎速度阶梯、多进程的 IPC 税、泄漏的前视偏差分类法,以及决定"好"到底意味着什么的目标函数设计。如果算得又快又错,速度就一文不值。

免责声明:本文提供的信息仅用于教育和参考目的,不构成财务、投资或交易建议。加密货币交易涉及重大损失风险。

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

紧跟市场步伐

订阅我们的时事通讯,获取独家 AI 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。