GPU 何時才划算:參數掃描的屋頂線,一個 167x 頭條其實是 27x 演算法 × 6.2x 硬體
"回測無幻覺"系列文章。
速度階梯一文刻意收在了一個不太令人滿意的地方。我們把一個 80 組合的參數掃描,在一臺筆記本 CPU 上從 pandas 的 69.9 秒壓到並行 numba 的 0.23 秒——實測 298x——然後論證說 GPU 並不是那塊缺失的拼圖。評論區沒有平靜地接受這個結論,而它也理應不接受。"不是缺失的拼圖"是一個關於某一種工作負載、某一個規模的斷言。它不是自然規律。所以這篇文章做了件誠實的事:把 GPU 擺上試驗檯。
結論不是一個判決,而是一條曲線。GPU 相對 CPU 的領先幅度不是一個你能印在幻燈片上的單一數字;它是你每次呼叫交給它多少工作量的函數。在我們的多時間框架指標預計算上,GPU 相對 CPU 引擎的加速比,從**每次只要它算一個參數組合時的 54.5x,一路升到一次要它算 61 個時的 359.6x。**同一個核心、同一份資料、同一套硬體——唯一改變的只是批次。一個只報告其中一個數字、隱去另一個的基準測試,測的不是 GPU,而是在挑選一個頭條。
而且即便是 359.6x,也不是它看上去的樣子。把它剝開,一個 **167x 的多時間框架大頭條會分解為 27x 的演算法——一次同樣讓 CPU 快 27x 的改寫——乘以 6.2x 的真實硬體。**那 27x 不是 GPU 做的,是數學做的。這篇文章講的就是把這兩者區分開來,因為把它們混為一談,正是一塊 $2,000 的顯示卡被賣出去、去解決一個 git commit 本可以免費解決的問題的方式。
資料來源:下文所有數字都在一臺 Apple M2 Max 上實測,通過 MLX 在 Metal GPU 上跑 fp32,對比在十二個 CPU 執行緒上跑 fp64 的 numba,來自我們的 engine_multitf_gpu.py 和 bench_param_sweep.py 基準指令碼,每一項都由一道等價性校驗把關,確認 GPU 路徑與 CPU 路徑產出相同的交易。這一篇沒有公開的配套論文——指令碼本身就是記錄。
這是一個屋頂線問題,不是一次基準測試

"GPU 到底快多少"之所以沒有單一答案,原因就在於屋頂線模型(Williams、Waterman 與 Patterson,2009)。一個處理器可達到的效能同時被兩條天花板封頂:一條平坦的,由峰值算力(FLOP/s)決定;一條傾斜的,由記憶體頻寬乘以算術強度——你每搬運一位元組資料所做的有用運算次數——決定。每位元組廉價的工作待在傾斜天花板之下,受頻寬限制;每位元組豐厚的工作夠到平坦天花板,屬於計算密集型。GPU 的平坦天花板高高凌駕於 CPU 之上,但它那條傾斜天花板,以及至關重要的每次呼叫的固定成本,卻沒有那麼優雅地隨之降下來。
對一次參數掃描來說,還有第三個項主導著圖表的左側:**啟動與傳輸開銷。**每一次 GPU 呼叫都要付出一個大致固定的代價——分發核心,把輸入搬過(在 Apple Silicon 上是統一的、但並非免費的)記憶體邊界,再把結果搬回來。把一批 個組合的牆鍾時間建模為
其中 是那個固定開銷, 是 GPU 每個組合的邊際成本, 是 CPU 的。於是加速比為
這一個分式就解釋了整篇文章。在 很小時,分母裡的 會把這個比值壓垮——你花了代價把 GPU 喚醒,卻幾乎沒用上它。隨著 增大,你把 攤到更多組合上,加速比就朝著它的漸近線 (那個真實的硬體比值)攀升。半程點落在 處:一個批次空間裡的"脊點",它告訴你,你的掃描要寬到什麼程度,GPU 才能發揮出哪怕一半的實力。一次幾十個組合的掃描,遠遠坐在那個脊點的左邊。一次數千個組合的掃描,則坐在平坦段上,GPU 終於掙得了它的天花板。
所以正確的問題從來不是"GPU 是不是更快"。而是"我的掃描是否在脊點的右邊,以及我每個組合的工作是否足夠計算密集,能在到達那裡時夠到平坦天花板"。兩者都必須成立。本文餘下的部分,就是去測量這些門檻究竟落在哪裡。
單時間框架的裁決:GPU 只是勉強勝出

先從速度階梯用過的那個工作負載開始:一個單時間框架的 HMA/HMA3 掃描,150,000 根 K 線上的 80 個組合。我們給那把梯子加了第六級臺階——M5,通過 MLX 在 Apple GPU 上計算指標,交易仍在 CPU 上提取。預熱完畢,取三次執行的最優,等價性關卡亮綠燈:
| 方法 | 牆鍾時間 | 相對 pandas | 組合/秒 |
|---|---|---|---|
| M0 pandas + 迴圈 | 287.08s | 1.0x | 0.3 |
| M2 numba(1 核) | 2.02s | 142x | 39.7 |
| M4 多程序 + numba(12 核) | 0.33s | 883x | 245.9 |
| M5 MLX GPU(fp32) | 0.10s | 2796x | 779.2 |
對著樸素的序列基準,GPU 看上去像個英雄——2,796x。但這是任何誠實的人都不該做的對比:它讓一個優秀的 GPU 實現,去對陣最差的那個 CPU 實現。把 GPU 擺到你真正會部署的那個 CPU 面前——同一個核心跑滿全部十二個核心,也就是 M4——勝勢就坍縮成了清醒的 3.2x(779 對 246 個組合每秒)。一整塊顯示卡,跑完整個掃描,也只以三倍的優勢勝過一個十二核 CPU 池。
3.2 倍不是什麼都沒有。但它也不是任何人買 GPU 的理由。而且它恰恰是屋頂線對一次這麼窄的掃描所預言的結果:80 個組合位於脊點左側。固定的啟動與傳輸開銷 在一個 0.10 秒的作業裡仍佔據著可觀的一塊,所以我們從未觸及漸近線 。更糟的是,每個組合成本里有一部分是那個 O(n) 的交易提取過程,我們刻意把它留在了 CPU 上——這是一個 GPU 根本無法加速的項(下一節詳談原因)。對一個這種規模的單時間框架研究迴圈,速度階梯當初的裁決依然成立:**GPU 不是那塊缺失的拼圖。**並行 numba 早已把你送到了 0.23–0.33 秒,而把它再削到 0.10,並不是解開研究者困局的那把鑰匙。掃描周圍的編排才是。
那句裁決裡有意思的詞是這種規模。沿著批次軸向右移動,故事就變了。
成本究竟花在了哪裡
在我們把批次放大之前,先看看我們實際上在為什麼買單,因為屋頂線只有在昂貴的那部分是計算密集型時才會給你回報。對這次掃描做效能剖析,會發現它幾乎全是一件事:**加權移動平均卷積。**一條 HMA 是三次 WMA,一條 HMA3 是四次;每個組合都要在整條序列上把它們重新算一遍。交易提取——走一遍那兩條指標陣列,找出 hma - hma3 的符號翻轉,記錄成交——是單獨一趟廉價的 O(n) 過程。這次掃描是一個披著交易策略外衣的卷積工作負載。
這種拆分,恰好對應屋頂線的兩種區間:
- **卷積是計算密集型的。**每個價格都會被讀進許多重疊的視窗求和裡,所以算術強度——每搬運一位元組所做的運算次數——很高。這類工作夠向平坦的計算天花板,而 GPU 的平坦天花板正是那條高聳的。更妙的是,這些視窗還跨組合地重疊:一條長度為 40 的 WMA 對幾十個組合都有用,所以一次批次預計算把它共享一次,而不是每個組合各算一遍。批次不僅攤薄了啟動開銷;它還通過複用載入的資料抬高了算術強度。這就是該放到 GPU 上的那部分。
- **交易提取受頻寬限制、且充滿分支。**一趟順序遍歷,每一次交叉處都有資料依賴的分支,基本沒有複用。它的算術強度接近地板,控制流對一臺 SIMD 裝置充滿敵意。把它推到 GPU 上收益甚微、代價高昂;它留在 CPU 上。這意味著它是阿姆達爾定律式拆分中的序列尾巴——一個 GPU 加速比永遠無法穿透的固定地板,也是單時間框架數字為何在 3.2x 處飽和的部分原因。
這個核心的多時間框架版本里,還藏著第二條、更鋒利的教訓,它正是我們一直答應要解釋的那個 27x 的來源。多時間框架引擎會把一條更高時間框架的 HMA 無前視地對齊到 1 分鐘的基準索引上。按最直觀的方式寫,那是每根 K 線 O(length^1.5) 的工作量——在每一根基準 K 線上都重算一遍更高時間框架的移動平均線。但那條對齊後的 HMA,對於"最近幾根已收盤的更高時間框架 K 線組成的短緩衝區加上執行中的收盤價"而言是線性的,所以整個逐根 K 線的計算就坍縮成了一個固定的權重向量:在已收盤 K 線序列上做一次 conv1d,後面跟一次 O(n) 的 gather。數以億計的冗餘運算,變成了一次在短得多的序列上的卷積。
這次坍縮是一個演算法上的勝利,而不是硬體上的。它是一個更好的公式。它能在 GPU 上跑,也能在 CPU 上跑得一樣好——np.correlate 加一次 gather,用 fp64。請牢牢記住這一點:多時間框架頭條數字裡最大的那個因子,是一次連一塊 GPU 都不需要的機器就能用上的改寫。當我們最終分解那個 167x 時,這就是那個 27x。
領先幅度隨批次增大

現在做屋頂線讓我們去做的那個測量。取那條昂貴的軸——在 1 分鐘基準序列(我們擁有的最長 K 線流)上對齊的更高時間框架 HMA 預計算——然後每次呼叫餵給 GPU 數量遞增的長度組合,。這裡的 CPU 基準是那個誠實的生產引擎:跨全部十二個核心、用 prange 的 numba。對每一個批次,我們都測量兩者並取其比值。
| 批次 (組合/次呼叫) | GPU 相對 12 核 CPU 引擎的加速比 |
|---|---|
| 1 | 54.5x |
| 2 | 102.5x |
| 4 | 129.5x |
| 8 | 187.4x |
| 16 | 267.4x |
| 32 | 245.0x |
| 61 | 359.6x |
這就是 用真實測量畫出來的樣子。在 時,GPU 就已經領先 54.5x——因為這個對比針對的是樸素的逐根 K 線引擎,所以哪怕只有一個組合,演算法坍縮也已經烘焙在裡面了——但它離自己的天花板還差得遠:固定開銷在一個單組合的呼叫裡仍佔主導。把批次翻倍,加速比就幾乎翻倍到 102.5x;到 是 267.4x;到 是 359.6x,而且肉眼可見還在爬。**領先幅度隨問題規模增大而增長。**這是關於 GPU 與參數掃描最重要的一句話,而它恰恰與 GPU 加速比通常被引用的方式相反——彷彿它們是晶片的某種恆定屬性似的。
兩點誠實的說明,因為這裡是回測無幻覺系列,而一張乾淨單調的表格永遠值得懷疑。
第一,那個凹陷: 讀數為 245.0x,低於 時的 267.4x。這不是可以掃到地毯底下的噪聲——它是一個分塊邊界造成的假象。我們的 conv1d 會把 32 個長度打包進一個核心分塊,所以 恰好把一個分塊塞滿、沒有餘量,而 溢位到了第二個分塊裡,後者碰巧把裝置喂得更飽。屋頂線關注的是趨勢,而一臺真實裝置內部存在量化臺階;我們如實報告這點抖動,而不是繞開它去挑選資料。
第二,也更重要:**54.5x 和 359.6x 都是針對樸素 CPU 引擎的,兩者都不是硬體收益。**這兩個數字裡仍然含著那個 27x 的演算法坍縮。如果你把 CPU 基準換成在 CPU 上執行的那個坍縮後的演算法——同一個公式、fp64、兩邊都經過最佳化——每一行都會大致縮小那麼一個因子。而這正是下一節要精確算清的那個分解。
誠實的分解:27x 演算法乘以 6.2x 硬體

要把演算法和矽片分開,你得在同一個多時間框架網格上測量三條路徑,而不是兩條。所以基準測試運行了:
- cpu-engine——生產環境的 numba 引擎,跨全部核心的逐根 K 線對齊 HMA。這是樸素但並行的基準。
- cpu-collapsed——坍縮後的權重向量,
np.correlate加 gather,fp64,跑在 CPU 上。與(1)硬體相同,演算法更優。 - gpu-mlx——把坍縮後的權重作為一次批次
conv1d跑在 Metal GPU 上,fp32。與(2)演算法相同,硬體不同。
把它們排在一起,完整的多時間框架頭條數字就乾淨地因式分解了:
左邊的因子,27x,是演算法——上一節裡那個從逐根 K 線到卷積的坍縮。它跟 GPU 毫無關係。用 numpy 把它實現出來,你的筆記本 CPU 在這個工作負載上就快了 27x,代價不過是一次重構。右邊的因子,6.2x,是硬體——Metal GPU 相對十二個 CPU 核心上同一個最佳化過的演算法那份誠實的、同類對同類的勝利。那 6.2x 才是你真正需要一塊 GPU 才能拿到的唯一部分。
這就是整篇文章的寓意,用算術講出來。當一份廠商基準、一個庫的 README,或者一位興奮的同事給你亮出"GPU 上 167x"時,你的條件反射應該是一個問題:*CPU 基準是什麼?*如果基準是那個樸素實現——而它幾乎總是,因為一個慢的基準能做出更漂亮的幻燈片——那麼頭條裡的大部分都是一個 CPU 本也有權享有的演算法收益,只有那點殘差才是硬體。這裡的殘差是 6.2x。一句 167x 的說法,把硬體的貢獻誇大了大約 27 倍。
再注意硬體這個因子本身是怎麼隨問題規模移動的。在那個小的單時間框架掃描上,GPU 相對最佳 CPU 的真實勝利是 3.2x。在那個更大的多時間框架預計算上,它是 6.2x——同樣兩塊晶片,硬體優勢卻幾乎翻倍,純粹是因為更大的工作負載在 CPU 還跟得上之前,就把你往屋頂線上推得更高、更靠近 GPU 的平坦計算天花板。硬體領先也不是一個常數。它是同一條上升曲線上的一個點,而你在那條曲線上向右移動的方式,就是把批次做得更大、把每個組合的工作做得更豐厚。
一份決策指南:掃描到底要多寬?

把屋頂線折回成一個你能在花錢之前就做出的決定。當屋頂線的兩個條件同時成立時,GPU 才划算:你的掃描在批次脊點的右邊(,於是固定的啟動與傳輸開銷被攤薄了),並且你的每個組合的工作是計算密集型的(算術強度足夠豐厚,能夠到平坦天花板,而不是一趟稀薄的 O(n) 過程)。具體地,從我們測到的結果看:
- **一個單時間框架策略的幾十個組合:跳過 GPU。**你在脊點左側;相對並行 numba,在一個本就只要十分之一秒的作業上,誠實的勝利是 ~3.2x。瓶頸不在核心,而在它周圍的一切。
- **數千個組合,或者一個真正多時間框架 / 多指標的預計算:GPU 掙得了它的位置。**開銷被攤薄,共享的卷積抬高了算術強度,硬體勝利爬升到 6.2x,並隨批次繼續上升。這正是 GPU 能把一次通宵掃描變成一次咖啡時間的區間。
- **先爬 CPU 階梯——它更便宜,而且它排在前面。**那 CPU 上的 298x 和那個 27x 的演算法坍縮都是免費或近乎免費的,而且它們是前提,不是替代品:GPU 的 6.2x 是疊加在坍縮後的演算法之上的,而那個演算法你本來就得寫。把一塊 GPU 硬拴到一條樸素流水線上,測出來的多半是那份樸素。
GPU 分支上還有一項與速度無關的稅,你必須把它算進成本:**Apple 的 Metal GPU 根本沒有 fp64。**一切都在 fp32 裡跑,相對精度約 1.2e-7。這就扼殺了那個教科書式的快速移動平均技巧——O(n) 的字首和 WMA——因為在 150k 根 K 線上、價格量級接近 30,000 時,那些累加和會達到 ~1e14,比 fp32 的安全整數範圍高出七個數量級;我們實測到的相對誤差高達 ~2e2(是兩百倍,不是百分之二)。可用的表述方式是直接的視窗卷積,每個視窗求和都是有限個量級相當的項,fp32 能保持約 8e-7 的精度。即便如此,一個靠 hma - hma3 符號做決定的策略,偶爾仍會在兩條曲線幾乎相觸的臨界 K 線上翻轉一次交叉,因為 fp32 的舍入會把一次近乎平局的情況推向一邊。這就是為什麼 GPU 路徑隨附一道衡量交易分歧了多少的等價性關卡——以基點計的 PnL 差值、交易筆數的相對偏移——而不是去斷言一種它永遠無法交付的逐位相同的輸出。在我們這次執行裡,那份分歧是 479,016 筆成交裡有 90 筆發生了偏移(0.019%),穩穩在容差之內,但這份負擔是真實的:**上 GPU 意味著你要揹負一個數值一致性的故事,而不只是一個更快的時鐘。**那份工程成本,同樣是盈虧平衡的一部分。
數字是蘋果形狀的;曲線不是
上面每一個數字都出自一臺 Apple M2 Max:一臺統一記憶體裝置,GPU 和 CPU 共享一個記憶體池,還有一塊只有 fp32、沒有雙精度的 GPU。一塊獨立的 NVIDIA 或 AMD 顯示卡會改變這些常數,而且值得把每一個常數往哪個方向移動講清楚,因為即便數字變了,論證的形狀依然成立。
- **傳輸開銷 會變得更糟,而不是更好。**一塊獨立顯示卡待在 PCIe 後面,所以輸入和結果要跨匯流排做一次真實的複製,而這正是統一記憶體所避免的。這會把批次脊點 往右推——你需要一次更寬的掃描,獨立 GPU 才能攤薄它的啟動開銷。在一臺 PCIe 裝置上,屋頂線的左邊緣更陡,而不是更緩。
- **平坦天花板 會變得更高。**一塊資料中心 GPU 擁有遠比整合 GPU 更多的 FLOP/s 和頻寬,所以在一次能把裝置餵飽的掃描上,漸近的硬體勝利會比我們的 6.2x 更大。夠到曲線右側的回報增大了;坐在左側的過路費也增大了。
- **fp64 回來了,字首和技巧也隨之回來。**在一塊擁有真正雙精度的顯示卡上,O(n) 的字首和 WMA 重新可行,一致性關卡也能收緊到逐位精確。我們付出的那份特定的 fp32 稅——用直接卷積而非字首和、用一道衡量分歧的關卡而非一個斷言——是 Apple Silicon 的一個細節,不是一條定律。
這些都不改變論點。在任何裝置上,:一個你必須攤薄的固定開銷,一條你只能從右側逼近的漸近線。常數屬於硬體;曲線屬於算術。在你相信任何人的頭條數字——包括我們的——之前,先用一段五行的批次掃描測出你自己的 、 和 。
這一切如何相連
這是一個關於回測速度究竟從何而來的小子系列裡的第四次測量,而這些拼塊拼成了一個關於在花錢之前該最佳化什麼的統一論證:
- 速度階梯從 pandas 爬到並行 numba,單靠 CPU 就拿下 298x,並把 GPU 留作一個懸而未決的問題。這篇文章回答了它:GPU 是一個真實但有條件的第五級臺階,相對最頂端的 CPU 臺階值 3.2x–6.2x,而且只有在掃描寬到足以爬上屋頂線之後才作數。
- IPC 稅在另一個方向上做了同樣的動作——測量離開程序要付出什麼代價——並得出了同樣形狀的結論:邊界(一個 socket、一次 GPU 啟動)很便宜;稅出在你跨越它的頻率和健談程度上。你批次處理 GPU 呼叫的理由,和你批次處理 IPC 的理由是同一個:攤薄一份固定的、每次跨越都要付的成本。
- 聚合 parquet 快取是 GPU 預計算所做之事的 CPU 側版本——把共享的指標算一次,在每個組合之間複用它們。GPU 只是把那條複用加批次的原則搬到了矽片上。
- 而那道 fp32 一致性關卡,是一個縮微版的回測-實盤一致性問題:一旦你的快路徑算出了與參照略有不同的東西,你就欠下一份對這份分歧的量化交代,而不是揮揮手了事。
把這一切串起來的準則,和整個系列所推崇的是同一條:去測量你實際上被推銷的那個東西。加速比是一個比值,而一個比值有分子和分母。大多數 GPU 帶來的失望,源自一個被挑來奉承你的分母——CPU 基準;而大多數 GPU 的浪費,源自跑了一次太小、根本離不開屋頂線左邊緣的掃描。
要點總結
- **GPU 加速比是一條曲線,不是一個數字。**在我們的多時間框架預計算上,相對 CPU 的領先幅度,從每次呼叫一個組合時的 54.5x 一路跑到六十一個時的 359.6x——同一塊晶片,同一份資料。任何單一的數字都只是那條曲線上的一個點;要問它是在多大的批次下測出來的。
- **永遠要盤問 CPU 基準。**那個 167x 的多時間框架頭條數字,乾淨地因式分解為 27x 的演算法(一次從逐根 K 線到卷積的坍縮,它讓 CPU 同樣提速)乘以 6.2x 的真實硬體。這裡公平的 GPU 相對最佳 CPU 勝利,是單時間框架 3.2x、多時間框架 6.2x——而不是 167x。
- **領先幅度隨問題規模增長,硬體因子也一樣。**更大的批次和更豐厚的單組合工作會把你往屋頂線上推:誠實的硬體勝利本身,僅僅靠把工作負載做大,就從 3.2x 升到了 6.2x。小規模掃描坐在脊點左側,幾乎得不到什麼好處。
- **先修好演算法、先爬 CPU 階梯——GPU 的勝利疊加在它們之上,而不是取而代之。**那 6.2x 是相對你本來就得寫的那個坍縮後演算法測出來的。把一塊 GPU 硬拴到一條樸素流水線上,你測出來的多半是那份樸素,而不是矽片。
- **上 GPU 意味著你要揹負一個數值一致性的故事。**Metal 上沒有 fp64,字首和 WMA 技巧在價格量級下失效(相對誤差 ~2e2),而靠交叉符號做決定的策略會在臨界 K 線上翻轉。交付一道以基點量化分歧的等價性關卡;把那份工程成本算進你的盈虧平衡裡。
當有人告訴你 GPU 讓他們的回測快了一百倍時,他們幾乎什麼都沒告訴你。問問他們批次大小和 CPU 基準,那個一百倍通常會化解成一個個位數的硬體勝利,外面裹著一個他們本可以免費得到的演算法勝利——在一次足夠大的掃描上,它值得擁有,理由恰如屋頂線所言,一個也不多。
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.