GPU 精度陷阱:Apple Metal 上的 fp32 回測如何悄無聲息地返回垃圾結果
"回測無幻覺"系列文章。
我們把參數掃描回測移植到了 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.py(04d71e8),以及 scripts/GPU_NOTES.md 中的設計筆記。
Metal 沒有 float64

在 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 的視窗上,使用線性核的加權移動平均定義為
要在 150,000 根 K 線上掃描成千上萬組參數組合,WMA 卷積本身就是全部開銷所在。所以直覺的做法——在 CPU 上也是正確的做法——是用字首和把每條 WMA 從 O(n·p) 降到 O(n)。你只需要預先算好兩個累積和,
然後任何一個視窗的線性加權和都能收斂成 S1 和 S2 上少數幾次做差與索引平移。不需要逐視窗迴圈,不需要逐視窗歸約——兩趟 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 早就已經數不清的領地。
溢位發生在哪裡:陷阱背後的算術

讓我們把這些數量級擺在一起對比,因為整個故障就藏在這個對比裡。
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。所以這裡的算術是:
這就是最純粹形式的災難性抵消:每個運算元的舍入誤差(±8×10⁶)比你想恢復的答案本身還要大。訊號比它被提取出來的那些數字的噪聲本底還要小。這不是丟掉幾位數字的問題——你丟掉的是全部數字,而算出來的結果,完全由 cumsum 累積下來的舍入誤差主導。
來自 GPU_NOTES.md 的實測後果是:在 150,000 根 K 線、價格約 30,000 的條件下,用這種方式算出來的 WMA,相對 fp64 的最大相對誤差達到約 211。 不是 211%——是 211 倍。算出來的移動平均可能與真實值相差整整兩個數量級。而真正讓它成為一個陷阱而非普通 bug 的地方在於:它能跑完全程,返回有限的、看起來說得過去的數字。 不會溢位到無窮大,不會出現 NaN,不會拋異常。一條偏差 211 倍的移動平均線,看起來依然像一條移動平均線——它是平滑的,是有限的,在那些抵消恰好比較輕微的 K 線上,數值大致還在合理區間——於是它順利通過了每一項不是直接對照可信參照的健全性檢查。你得到的是一次完整的回測、一條完整的權益曲線、一整套"最優"參數——全都建立在一個虛構出來的指標之上。
修復方法不是提高精度——而是換一種求和方式

一旦看到這個誤差,本能的反應是去追求更高精度——用 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 均線——h 和 h3——的交叉點上開倉。一個價格約 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 不會大聲地失敗;它失敗得很有禮貌,還帶著一堆數字。
真正能保護你的這三個習慣,代價都很低:
- 搞清楚你的中間量落在哪裡,而不只是輸入和輸出落在哪裡。 輸入(價格 ~10⁴)和輸出(WMA ~10⁴)都舒舒服服地落在 fp32 的精確範圍之內。災難完全出在一個隱藏的中間量(
S2~10¹⁴)身上,而 API 和型別系統都沒有把它暴露出來。在信任任何 fp32 歸約之前,先問一句:最大的部分和會達到多大——如果它越過了約 10⁷,就換一種公式。 - 優先選擇能把量級控制住的公式。 用直接卷積代替字首和;用區域性視窗代替全域性掃描;把居中化/差分放在求和之前而不是之後。先放大、再抵消,是一種反模式。正確的演算法往往是那種在紙面漸進複雜度上看起來更差、卻從不製造出一個之後必須再抵消掉的量的演算法。
- 通過一個離散不變數,對照 fp64 參照做校驗。 不要比較曲線;要比較某種量化的、下游的東西——交易筆數、交叉次數、倉位變動事件。一個離散不變數,能把一個悄無聲息的 211 倍誤差,變成一次震耳欲聾的斷言失敗,也能把一個可以接受的 8×10⁻⁷ 誤差,變成一個微小、有界、可解釋的差值。這和前視偏差的單 K 線位移測試是同一種紀律:一個廉價的診斷手段,能把一次看不見的故障變成一次看得見的故障。
這些都不是什麼高深的數值分析。這不過是那種最普通的衛生習慣——在一個緩慢但可信的回測認證之前,不去相信一個跑得快的回測——只是把這套習慣延伸到了這樣一個地方:語言本身已經不再提醒你,精度已經悄悄下降了十二個數量級。
要點總結

- Apple 的 GPU 沒有 float64——你回測裡每一個經過 GPU 的數字都是 fp32。 整數僅在約 1.6×10⁷ 以內精確,精度約為 1.2×10⁻⁷。沒有開關,沒有後備方案。回測裡的大部分內容都能扛住這一點;通常恰好有一個操作扛不住。
- 字首和 WMA 就是這個陷阱。
cumsum(j · price)爬升到約 10¹⁴,超出 fp32 精確上限整整七個數量級,而恢復一個視窗的值,又逼著你去對兩個這樣的數字做差,它們各自的舍入誤差(±8×10⁶)早就已經遠遠蓋過了答案本身。實測最大相對誤差:211 倍。 它從不崩潰——它返回的是看起來說得過去的垃圾。 - 修復方法是換一種求和方式,而不是加更多位數。 直接的視窗卷積(
mx.conv1d)讓每一個部分和都停留在價格量級附近,於是 fp32 能守住七位誠實的數字:相對誤差 8.2×10⁻⁷,還比單執行緒 numba 快 55.9 倍。 你沒法在 Metal 上買到 fp64,而你也根本不需要。 - 用一個離散不變數去校驗,永遠不要用曲線。 交易筆數一致性檢查抓住了這一點:fp32 的 conv1d 與 fp64 之間,在 479,016 筆交易中有 90 筆(0.019%) 存在分歧,全都是臨界交叉,全都遠低於崩潰閾值——這正是一個正確方法的特徵,與 211 倍的誤差截然不同。"發生分歧的組合佔比"是個誘餌指標;要衡量的是差多少。
- 整次掃描達到每秒 779 個組合——相對 pandas 基準快 2,796 倍,相對整個 12 核 CPU 池快 3.2 倍——但端到端的優勢(相對單執行緒 numba 快 19.6 倍)要小於純卷積的優勢(55.9 倍),因為 CPU 上的交易提取現在成了瓶頸。過了某個點之後,速度拼的是編排,而不是算術。
這次 GPU 移植快了 2,796 倍,而在第一個能跑起來的版本里,卻完全是錯的——而這兩件事彼此毫無關係。速度是真實的。垃圾則來自一個隱藏的 10¹⁴ 中間量,fp32 扛不住它,也沒有任何錯誤資訊會提到它。如果一次回測突然快了很多,而數字看起來還是沒問題,那並不是什麼確認。在 Metal 上,"看起來沒問題"看起來恰好就是 211 倍相對誤差的樣子。
這是本系列一直在攀爬的那道階梯上屬於 GPU 的一級:回測引擎速度階梯、多程序的 IPC 稅、洩漏的前視偏差分類法,以及決定"好"到底意味著什麼的目標函數設計。如果算得又快又錯,速度就一文不值。
Authors
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.