回測引擎速度階梯:筆記本 CPU 上 298 倍提速,PnL 精確到最後一筆交易
"回測無幻覺"系列文章。
📄 本文成長為了一篇研究論文。 一個路徑依賴的回測核心被實現了五種不同版本——從樸素的 pandas 一路到並行的 numba 核心——每一級都經過交叉核驗,產出的逐組合 PnL 完全相同,因此唯一的差異就是速度。可線上閱讀論文(互動版 + PDF):speed-ladder.marketmaker.cc,程式碼與資料見 github.com/suenot/backtest-speed-ladder。
七十秒。這是樸素的參照實現掃描一個移動平均策略在 150,000 根 K 線上的 80 組參數組合所需的時間:指標用 pandas 的 rolling().apply(),交易用一個普通的 Python 迴圈。這正是現實世界中大量研究程式碼執行時的畫像,因為這正是按照最直觀的方式寫策略時自然而然得到的畫像。
同一次掃描,在同一檯筆記本電腦上,每個組合的 PnL 精確到最後一筆交易都完全相同:0.23 秒。
這兩個數字之間的差距——實測 298 倍——正是本文要討論的主題。這裡面沒有一個百分點來自新硬體。全程沒有用到 GPU(這臺機器在 CUDA 意義上甚至都沒有 GPU 可用)。階梯上的每一級都是同一個策略、同一份資料、同一套手續費、同一個交易筆數,並由一道等價性關卡校驗:只要任何一個實現的逐組合結果出現分歧,整個基準測試就判定失敗。真正改變的只是工作被表達的方式:哪些程式碼跑在直譯器裡,哪些被編譯執行,哪些被並行執行。而且,因為刻意選一個很慢的基準可以讓任何頭條數字顯得更漂亮,這裡先給出另一個數字:即便相對一個稱職的向量化 numpy 實現——一個優秀的 numpy 程式設計師會交付的那種程式碼——最終引擎依然快約 13 倍。
當參數搜尋很慢時,本能反應是去找更強的硬體——GPU、叢集、雲預算。而這個實驗的實測事實指向了一個遠沒那麼光鮮的地方:瓶頸在於引擎(一個逐視窗呼叫 Python 的解釋執行內層迴圈)和編排(把相互獨立的組合序列地跑在一個核上)。這兩者都能在一個下午之內、用你已經擁有的機器修好,而且結果分毫不變。
先把整個階梯擺出來。下面的內容是對每一級臺階的解剖。
| 階梯級 | 實現方式 | 牆鍾時間 | 加速比 | 組合/秒 |
|---|---|---|---|---|
| M0 | pandas:rolling.apply + Python K 線迴圈 |
69.92 s | 1.0x | 1.1 |
| M1 | numpy:滑動視窗 WMA + 向量化交易 | 3.07 s | 22.7x | 26.0 |
| M2 | numba:@njit WMA + @njit 事件迴圈 |
1.98 s | 35.3x | 40.4 |
| M3 | numba prange:跨組合多執行緒 |
0.32 s | 217.6x | 248.9 |
| M4 | 程序池 + numba:跨組合多程序 | 0.23 s | 297.9x | 340.9 |
Apple M2 Max(12 核),Python 3.14.6,numpy 2.4.3,numba 0.64.0,BLAS(Accelerate)被釘死在單執行緒上,因此單執行緒各級階梯是真正意義上的單核執行。150,000 根 K 線 × 80 組合,取 3 次執行的最優牆鍾時間,JIT 預熱不計入計時。所有階梯級——包括 pandas 基準——都完整計時,並在全部 80 個組合上驗證產出完全相同的逐組合 PnL 和交易筆數。
一個核心,五種實現

要讓速度對比真正有意義,被計算的東西必須被精確釘死,而且每一種實現都必須被證明確實在計算它。所以這個實驗固定了一個策略核心,並讓它在全部五級階梯上保持不變。
這個核心是一個 HMA/HMA3 交叉——建立在兩條 Hull 風格移動平均線之上的停止並反向(stop-and-reverse)系統。它的構建單元是加權移動平均線:
Hull 移動平均線組合了三條這樣的均線以削減滯後:
而 HMA3 是它更平滑的一個變體,由大致在 、 和 處的 WMA 構建而成,再額外平滑一次。每個參數組合就是跨六種不同視窗長度的七次 WMA 計算——這是一套真正的指標棧,不是玩具。
交易規則被刻意設計為有狀態的,而且這種有狀態是有用的:當 HMA 低於 HMA3 時方向為多,反之為空;在方向首次被確定時開倉;每次發生交叉,就平倉、記錄 PnL 減去 0.09% 的往返手續費,然後反向。倉位會跨 K 線延續——你在第 根 K 線上做什麼,取決於自上一次交叉以來積累的狀態。這種路徑依賴正是這個實驗的核心所在:正是這個特性讓回測有別於通用的資料幀流水線,而且(正如我們將要測量的那樣)它讓 GPU 的問題變得複雜——只不過複雜的方式並不是坊間傳說的那種。
其餘的實驗設定如下,方便你自行判斷這些數字:
- 資料: 150,000 根合成幾何布朗運動 K 線,帶種子(
seed=42)。這裡的效能受限於陣列大小和視窗長度,而不是餵給它的具體價格路徑——用合成序列可以讓整個實驗對任何人都是確定性且可復現的。 - 網格: 80 個分佈在 區間上的不同 HMA 長度——所以這次掃描既包含開銷小的短視窗組合,也包含開銷大的長視窗組合,就像一個真實網格那樣。
- 計時: 牆鍾時間,每一級取 3 次執行中的最優值,JIT 編譯在計時器之外預熱,程序池的 worker 在計時開始前就已預熱。每一級階梯——包括 pandas 基準——都在全部 80 個組合上完整計時。BLAS(蘋果的 Accelerate)被釘死在單執行緒上,所以單執行緒各級階梯是真正意義上的單核執行:numpy 那一級並沒有在對比背後悄悄用多執行緒去做它的矩陣-向量乘法。
- 等價性關卡: 計時結束後,每一級階梯的逐組合 (PnL、交易筆數) 向量都會與參照實現做比對——交易筆數必須完全一致,PnL 的絕對誤差須在 個百分點以內。已提交的執行結果對每一級階梯——包括 pandas 基準——在全部 80 個組合上都報告
all_ok: true。如果這道關卡沒通過,那就根本沒有基準測試可言——那只是五個程式在以五種不同的速度計算五種不同的東西,而這恰恰是很多"我們的引擎快 100 倍"之類說法悄悄運作的方式。
等價性區塊裡有一個數字值得坦誠說明一下:第一個組合的指紋是 57,029 筆交易上 −5165.58 個百分點的 PnL。這不是什麼需要感到難堪的策略結果——它是最短的 HMA 長度(6)在一段隨機遊走的幾乎每一次擺動上都發生翻轉,並且每次都按理應該地支付 0.09% 的手續費。這是一個正確性指紋,而不是一個可交易的回測結果。不要從中讀出 alpha;要從中讀出確定性——五種實現落在同樣的 57,029 筆交易、同樣精確到小數點後六位的 PnL 上,這正是本文中"完全相同"的含義。
確立了這一點之後,下面提到的每一個加速比都是純粹的速度提升,沒有任何近似被悄悄省略掉。
M0 階梯:樸素的 pandas 畫像——69.9 秒

這個基準並不是一個稻草人。它就是當你按照 pandas 文件建議的方式寫 WMA、按照策略描述本身的方式寫事件迴圈時,自然會得到的程式碼:
def pd_wma(s: pd.Series, period: int) -> np.ndarray:
w = np.arange(1, period + 1, dtype=np.float64)
w /= w.sum()
return s.rolling(period).apply(lambda x: np.dot(x, w), raw=True).to_numpy()
def run_pandas_one(close, length):
h, h3 = pd_hma(close, length), pd_hma3(close, length) # 7 rolling.apply WMAs
total, ntr, prev_dir, entry, pos = 0.0, 0, 0, 0.0, 0
for i in range(len(close)): # Python bar loop
if np.isnan(h[i]) or np.isnan(h3[i]):
continue
d = 1 if h[i] < h3[i] else -1
if prev_dir == 0:
prev_dir, pos, entry = d, d, close[i]
continue
if d != prev_dir: # cross: close + reverse
pnl = ((close[i] - entry) if pos == 1
else (entry - close[i])) / entry * 100 - FEE
total += pnl
ntr += 1
pos, entry, prev_dir = d, close[i], d
return total, ntr
為什麼這會很慢?不是因為 pandas "不好"——而是因為迭代發生在哪裡。rolling(period).apply(lambda ...) 是一個披著向量化外衣的 Python 層迴圈。對 150,000 根 K 線中的每一根,pandas 都要實體化一個視窗、跨越一次 C/Python 邊界、呼叫一個 Python 可呼叫物件,再把結果裝箱。即便用了 raw=True(它至少能讓 lambda 拿到一個裸的 ndarray 而不是 Series),單次呼叫的直譯器開銷也遠遠超過該視窗實際需要的那區區幾十到幾百次浮點運算(FLOP)。乘以每個組合七次 WMA 計算,光是指標棧就是數百萬次直譯器往返。然後這個 K 線迴圈每個組合還要再跑 150,000 次解釋執行的迭代,每一次都要對 numpy 標量做帶邊界檢查的索引、給浮點數裝箱,並對型別做動態分派——而這個型別是直譯器每一次都要重新發現的。
結果:整次掃描耗時 69.92 秒,平均每個組合約 0.87 秒,吞吐量為每秒 1.1 個組合。在一個 80 組合的網格上,你聳聳肩,等一分鐘就過去了。問題在於,沒有人會長期只跑 80 組合的網格——而這個成本會永遠線性擴充下去。我們後面還會回到這一點。
M1 階梯:numpy——別再在迴圈裡呼叫 Python——3.07 秒,22.7 倍
往上第一級階梯一次性消除了兩個直譯器迴圈,而這兩個技巧值得分開來看,因為它們的通用性截然不同。
指標那一側是簡單、完全通用的那一個。對全部視窗計算加權移動平均,不過是對輸入的一個步幅檢視做一次矩陣-向量乘法——不需要複製,一次 BLAS 呼叫搞定:
def vec_wma(x: np.ndarray, period: int) -> np.ndarray:
w = np.arange(1, period + 1, dtype=np.float64)
win = np.lib.stride_tricks.sliding_window_view(x, period) # zero-copy view
out = np.full(len(x), np.nan)
out[period - 1:] = win @ w / w.sum() # one matvec
return out
sliding_window_view 在同一塊記憶體上構建出一個 (n − p + 1, p) 的檢視,而 win @ w 用編譯好的程式碼計算出每個視窗的點積。上百萬次 lambda 呼叫變成了一次庫函數呼叫。
交易那一側才是有意思的部分,因為事件迴圈是有狀態的——然而對這個核心來說,它居然能被向量化。關鍵洞察在於:任意一根 K 線上的倉位只取決於 HMA − HMA3 的符號,而不取決於任何交易結果。狀態從不會反過來影響決策。於是整個迴圈就坍縮成了"找出符號翻轉的位置,在這些位置上取出價格":
d = np.where(h[idx] < h3[idx], 1, -1) # direction per valid bar
flips = np.flatnonzero(np.diff(d) != 0) + 1 # bars where it crosses
cross = idx[np.concatenate(([0], flips))] # entry/exit indices
side = d[np.concatenate(([0], flips))]
entries, exits, s = close[cross[:-1]], close[cross[1:]], side[:-1]
pnl = np.where(s == 1, (exits - entries) / entries,
(entries - exits) / entries) * 100 - FEE
return float(pnl.sum()), int(pnl.size)
3.07 秒,22.7 倍加速,每秒 26.0 個組合——單核執行,BLAS 被釘死在單執行緒上。這一級階梯值得單獨貼個標籤:它是稱職基準,是一個優秀的 numpy 程式設計師會交付的實現,也是衡量之上所有階梯級的公平標尺。但這一級階梯也伴隨著兩點必須坦誠說明的注意事項。
第一,這種向量化是一次針對該策略的解析式改寫,而不是一種機械式變換。它之所以成立,是因為這個核心是停止並反向的,沒有止損、沒有追蹤離場、沒有依賴累計 PnL 的倉位大小調整。只要加上一個止損——這可以說是最普通不過的功能——第 根 K 線上的離場就會改變第 根 K 線上存在哪個入場,狀態就會反過來影響路徑,閉式解也就隨之蒸發。大多數生產環境的核心,都站在這條線錯誤的那一側。
第二,這一級階梯正是正確性最容易犧牲的地方。翻轉索引的記賬工作(這裡一個 +1,那裡一個 [:-1],還有首個方向的種子設定)恰恰就是那種會產生差一(off-by-one)執行錯誤的程式碼——正是我們在前視偏差分類法中展示過的那一類錯誤,它能從純噪聲中憑空捏造出 15 的夏普比率。在這一級階梯上,等價性關卡不是走過場——它是你能信任這段程式碼的唯一理由。沒有針對一個笨拙參照實現做等價性核驗的聰明向量化改寫,正是引擎悄悄偏離它自稱在測試的那個策略的方式。
M2 階梯:numba——編譯你真正想寫的那種迴圈——1.98 秒,35.3 倍

M2 階梯採用了相反的思路:與其把演算法硬拗成適配向量化原語的形狀,不如直接寫樸素的迴圈——然後把它編譯掉。Numba(Lam、Pitrou 與 Seibert,2015)通過 LLVM,把 Python 的一個數值子集即時編譯(JIT)成機器碼:
@njit(cache=True)
def nb_wma(x, period):
n = x.shape[0]
out = np.full(n, np.nan)
wsum = period * (period + 1) / 2.0
for i in range(period - 1, n): # the "slow" loop, now machine code
s = 0.0
for j in range(period):
s += x[i - period + 1 + j] * (j + 1)
out[i] = s / wsum
return out
@njit(cache=True)
def nb_sweep(close, half, full, sq, p3, p2, pi, fee):
h = nb_wma(2.0 * nb_wma(close, half) - nb_wma(close, full), sq)
a = 3.0 * nb_wma(close, p3) - nb_wma(close, p2) - nb_wma(close, pi)
h3 = nb_wma(a, pi)
nb_sweep 內部的事件迴圈,在文本上就是 M0 的那個迴圈。分支、continue、儲存在區域性變數裡的狀態——全都一模一樣。而在 @njit 之下,這些區域性變數存活在暫存器裡,分支變成了真正的跳轉指令,每次迭代的開銷從直譯器分派的微秒級降到了納秒級。
1.98 秒——相對 pandas 快 35.3 倍,但相對 numpy 只快約 1.6 倍(推算:3.07/1.98)。這個不算大的跨步本身就很能說明問題:numpy 的內層迴圈本來就已經是編譯過的,所以 numba 在特徵計算上的優勢,僅限於省掉視窗實體化和中間陣列。真正帶來質變的部分在別處:
- 事件迴圈現在是"免費"的——而這個"免費"是實測出來的,不是修辭。 M1 把它的聰明才智都花在了讓交易邏輯可向量化上。M2 讓這份聰明變得沒有必要——那個樸素的、可審計的、易於修改的迴圈現在就以機器速度執行。在這個編譯後的核心內部,把特徵計算階段與交易迴圈分開計時,會發現 99.3% 的時間花在 WMA 特徵計算上,而有狀態的事件迴圈只佔 0.7%。你明天就能加上一個止損,而不需要立一個研究專案——請記住這個時間佔比,下文關於 GPU 的論證會重新用到它。
- 它解鎖了接下來的兩級階梯。 一個編譯好的、釋放 GIL 的、分配開銷很輕的核心,正是並行編排所需要的那種工作單元。你沒法有效地把 M0 並行化——十二份慢,終究還是慢,只是讓機器更熱一點。
有一點方法論上的說明:numba 在首次呼叫時才會編譯,而這次編譯(耗時幾百毫秒)絕不能被計入計時器——測試框架會先在一段 500 根 K 線的切片上把 JIT 預熱好,再開始測量,而 cache=True 會讓編譯好的核心跨程序啟動持久化。"漏掉"這個細節的基準測試,得到的 numba 數字要麼不公平地偏差(把冷編譯也算進去了),要麼無法復現。
M3 階梯:prange——你早就擁有的那份並行能力——0.32 秒,217.6 倍

這裡有一個觀察,讓海量參數搜尋變得特殊:這 80 個組合是完全獨立的。沒有共享狀態,沒有順序要求,沒有通訊需求。這是一種"尷尬並行"(embarrassingly parallel)的工作,而 M0–M2 階梯卻純粹出於習慣,把它跑在十二個核裡的一個核上。
Numba 讓這個修復幾乎只是語法層面的事——把組合迴圈裡的 range 換成 prange:
@njit(parallel=True, cache=True)
def nb_sweep_all(close, params, fee):
N = params.shape[0]
totals = np.empty(N, dtype=np.float64)
ntrs = np.empty(N, dtype=np.int64)
for k in prange(N): # threads across combos
t, ntr = nb_sweep(close, params[k, 0], params[k, 1], params[k, 2],
params[k, 3], params[k, 4], params[k, 5], fee)
totals[k] = t
ntrs[k] = ntr
return totals, ntrs
因為 nb_sweep 是以 nopython 模式編譯的,它不持有 GIL,numba 的執行緒層會把這些迭代攤開分給全部 12 個核心。只讀的 close 陣列被所有執行緒以零成本共享。
0.32 秒——相對 pandas 快 217.6 倍,每秒 248.9 個組合。相對單執行緒的 M2,在 12 個核上的跨步大約是 6.2 倍(推算:1.98/0.32),而與"理想的 12 倍"之間的落差值得坦誠說明,而不是藏起來:M2 Max 的 12 個核心是 8 個性能核加 4 個能效核,所以名義上限從來就不是 12 倍;這 80 個組合的開銷差異極大(長度為 6 的 HMA 遠比長度為 200 的便宜),所以各執行緒完成的時間參差不齊;再加上每次核心呼叫都要從一個共享的分配器裡為中間陣列申請記憶體。真實機器上的並行加速比就是這個樣子的。任何人在異構任務上宣稱乾淨的"N 核 N 倍",測的都是某種人造的東西。
M4 階梯:用程序池啃下最後一段——0.23 秒,297.9 倍
最後一級階梯把執行緒換成了程序——同一個編譯好的核心,由 ProcessPoolExecutor 編排:
with ProcessPoolExecutor(max_workers=12, initializer=_init_worker,
initargs=(close,)) as ex: # ship data ONCE
list(ex.map(_warmup_worker, range(12 * 3))) # JIT-warm every worker
results = list(ex.map(_run_one_combo, grid, chunksize=1))
0.23 秒——相對 pandas 快 297.9 倍,每秒 340.9 個組合。再讀一遍這個吞吐量:這檯筆記本電腦現在每秒能跑大約 340 次完整的、150,000 根 K 線的回測,每一次都要計算七條加權移動平均線,並模擬數萬筆有狀態的交易。
相對 prange 的優勢是真實存在的,但幅度不大——約 1.4 倍(推算:0.32/0.23)——合理的機制可能是排程和記憶體隔離:chunksize=1 讓程序池一次只分發一個組合,於是開銷參差不齊的視窗混合能在這些不對稱的核心之間動態負載均衡,而且每個 worker 程序都有自己獨立的分配器,避開了逐組合臨時物件上的爭用。我們把這些說成是與實測結果相符的機制,而不是已被單獨證明的事實。
程序不是免費的,測試框架誠實地把它們的成本放在計時器之外支付,作為一次性成本(worker 啟動、通過初始化器把 close 送到每個 worker、每個 worker 的 JIT 預熱)——因為在真實的搜尋中,這些成本會被攤薄到成千上萬個組合上,而不是八十個。誠實的通用建議是:prange 更簡單,通常也夠用;當任務粒度較大、網格較大,或者你的逐組合工作在 numba 觸及不到的地方持有 GIL 時,程序池才會佔優。
至此,整個階梯可以清晰地拆解開來。從 M0 到 M2——引擎:把迭代移出直譯器,在單核上帶來 35.3 倍。從 M2 到 M4——編排:使用本來就已經在那裡的那些核心,再帶來 8.4 倍(推算:1.98/0.23)。相乘:298 倍。沒有新硬體,結果分毫不變。而如果從稱職的 M1 基準而不是樸素基準來測量,最終引擎依然高出約 13 倍(推算:3.07/0.23)——這個階梯不是靠選一個慢起點製造出來的假象。
為什麼不用 GPU——誠實的版本

"直接把它搬到 GPU 上"是面對一次緩慢的參數掃描時最常見的反應,所以這個實驗測量了這場討論理應從之出發的兩個數字——而這兩個數字都不支援那種偷懶版本的任何一種答案。
屋頂線模型(roofline model,Williams、Waterman 與 Patterson,2009)按照算術強度——每搬運一位元組資料對應多少次浮點運算(FLOP)——對一個核心做分類。對於這次掃描中的 WMA 特徵棧,按每根 K 線、每個長度為 的視窗 次 FLOP,對比每根 K 線一次 8 位元組讀取來計算,整個 80 組合的掃描大約相當於在流經的 576 MB 資料上完成 6.2 GFLOP:
(這是按每個組合六種不同 WMA 視窗計算出的理想化數值;如果按實際執行的七次計算來算,得到的是 11.07 FLOP/位元組。無論哪種演算法,結論都一樣。)
這個數字之所以重要,是因為它排除了什麼:那種流傳甚廣的說法——"回測運算是受記憶體頻寬限制的,所以 GPU 幫不上忙"——在這裡是錯的。在約 10.8 FLOP/位元組的水平上,特徵計算明顯偏向計算密集型——遠遠越過了典型硬體不再受頻寬限制的那個拐點。GPU 完全能夠把 80 個組合 × 7 次 WMA 計算批次打包成少數幾個大核心,把這些運算量啃下來。如果特徵棧就是問題的全部,那麼上 GPU 的理由會相當站得住腳。
第二個實測數字,扼殺了另一個偷懶的答案——那個我們自己也很可能會脫口而出的答案。在編譯後的核心內部,把特徵計算階段與交易迴圈分開計時,得到的佔比是99.3% 特徵計算,0.7% 事件迴圈。那個誘人的論點——"回測有一個有狀態、帶分支的事件迴圈,正是它擋住了 GPU"——在這裡從數字上講是錯的:CPU 幾乎把全部時間都花在了那個 GPU 本來可以批次處理的部分上。把 80 個組合 × 7 次 WMA 計算重新表述為大規模的批次卷積,你就得到了一個完全合理的張量工作負載。所以,誠實的問題不是這些工作能不能搬到 GPU 上去——它的大部分都能。真正的問題是這趟旅程是否划算,而對這次掃描來說,答案是不划算,原因有兩個具體的方面:
1. 可利用的寬度只有 80 個組合——而 GPU 是一臺"寬度機器"。 一次參數掃描中唯一誠實的並行軸就是網格本身:在單個組合內部,那 150,000 根 K 線組成的路徑是嚴格順序的。GPU 需要數萬個獨立的工作項來填滿它的通道、隱藏延遲;而這次掃描只提供了八十個。十二個 CPU 核心就已經把這份寬度吃滿了——這正是 M3–M4 階梯實測出來的東西。要等到組合數量大到足以讓 GPU 的寬度真正派上用場時,CPU 階梯本身就已經能做到每秒交付數百次完整回測了。
2. 整個作業只需要 0.23 秒。 以 M4 的速度算,一個組合的開銷約為 2.9 毫秒(推算:0.23 秒 / 80)。相對這樣的預算,核心啟動延遲和裝置同步點根本不是可以攤薄掉的舍入誤差——它們是這次作業中實實在在的一部分。(在這臺統一記憶體的蘋果機器上,主機到裝置的傳輸只是個次要問題;而在一臺獨立 GPU 的 CUDA 機器上,這部分開銷同樣也要算進賬單。)經典的 GPU 優勢,靠的是把固定開銷攤到海量的工作批次上;一次不到一秒的掃描永遠製造不出這樣的批次。
那事件迴圈呢?它是唯一無法被批次化的部分——序列、帶分支、路徑依賴,一條長達 150,000 根 K 線的迴圈攜帶依賴(loop-carried dependency),在單個組合內部沒有任何硬體能把它並行化,而且它恰好帶著 SIMT 通道最討厭的那種分支發散。GPU 移植方案要麼會把它留在 CPU 上,要麼讓每個組合各佔一條通道來跑它。但它只佔核心 0.7% 的時間,是一個小到不足以決定任何事情的 Amdahl 項。它是那個搬不走的部分;但它不是不該搬的理由。(回想一下 M1 階梯:對於沒有反饋的核心,這個迴圈甚至能被解析式地向量化——而這份改寫,在策略一旦長出一個止損時就會失去。)
為求完整,補充一條平臺層面的腳註:在這臺機器上(Apple Silicon),GPU 路徑會是 MLX 或 PyTorch-MPS,而不是 CUDA——cupy 和 CUDA 生態在這裡根本不適用——而無論選哪一條路徑,光是為了嘗試這個實驗,都需要把熱路徑重寫成張量方言。按照上面的分析,這是一項真實的成本,而對這次掃描的形狀來說,並沒有找到能匹配的收益。這裡關於 GPU 的討論是分析性的,建立在實測的算術強度和實測的特徵/迴圈佔比之上,我們也如實標註:沒有跑過任何 CUDA,因為在已披露的這臺硬體上,這本來就不可能。
我們會在同行評審中為之辯護的總結句是:這項工作幾乎全部都能搬到 GPU 上;只是這次掃描規模太窄、耗時太短,不值得這趟旅程。 而這句話要往兩個方向去讀——它不是一句一筆勾銷的否定。批次化的"大矩陣"重構方案——把掃描重新表述為一次性跨越數千個組合的大規模張量運算,或者一個真正無反饋、能端到端批次化的核心——是一個真實且有前景的方向,值得專門做一項研究,而不是被一句話打發掉。在 80 個組合、0.23 秒的規模下,它只是還沒有掙到這張門票而已。如果你的工作負載有那樣的寬度,這筆賬就會改變,那時你應該自己重新算一遍,而不是引用我們的結論。
真正的瓶頸在哪裡:引擎與編排

八十個組合只是一個演示性質的網格。真實的參數搜尋才是這些因素不再是紙上談兵的地方,因為網格是乘法式增長的:四個參數、每個十個取值,就是 個組合;再加上十幾折的 walk-forward 驗證,你在還沒探索出任何東西之前,就已經要跑 次完整回測了。這就是維度災難,也是為什麼搜尋策略——Optuna、座標下降、Sobol——會受到如此多關注的原因:更聰明的搜尋會訪問更少的點。
但這個階梯揭示了這個等式中另外那一半、較少被討論的部分:每訪問一個點的成本。把實測的吞吐量線性外推(組合之間相互獨立,所以這只是算術,不是建模):
| 網格規模 | 在 M0 下(1.1 組合/秒) | 在 M4 下(340.9 組合/秒) |
|---|---|---|
| 10,000 combos | ~2.4 hours | ~30 seconds |
| 100,000 combos | ~24 hours | ~5 minutes |
同一個實驗,在樸素引擎上是一次通宵跑的批處理作業,在調優過的引擎上則是一次互動式查詢。這種差異複合累積的方式,是牆鍾時間表格所低估的:在每次掃描只需 5 分鐘的情況下,你會去迭代——修好一個洩漏後重新跑一遍,加一折驗證,把網格拓寬,試一試午飯時想到的那個點子。在每次掃描需要 24 小時的情況下,你不會。引擎的速度設定了研究迴圈的節奏,而研究迴圈的節奏,才是真正的產出物。
對整個階梯,也存在一種阿姆達爾定律(Amdahl's law)式的解讀:
把任意一個階段 提速 倍,其收益都會被你留下來沒最佳化的其他一切所限制。這個階梯尊重了這個順序:35.3 倍的引擎增益,打的是那個佔主導地位的項(無論在特徵棧還是迴圈裡,都是解釋執行的迭代);而 8.4 倍的編排增益,打的是那之後佔主導地位的項(十一個閒置的核心)。特徵/迴圈佔比拆分,也是同一個道理的縮影——如果不去實測時間究竟花在了哪裡,我們就沒法說清 GPU 論證的真實形狀。先做效能剖析,再做最佳化——順序不能顛倒。同樣的邏輯也支配著引擎上游的資料層:我們的 Polars 與 pandas 基準測試 在這套技術棧的載入與轉換那一半上,發現了完全相同的模式(分組滾動流水線上 10–3500 倍),也得出了同樣的混合結論——流水線用列式引擎,路徑依賴的模擬用編譯好的核心。
最後補充兩點誠實的說明,把關於普適性的這個環收好。第一,這個實驗刻意保持自包含和合成性——有種子的資料、一個核心、一臺已披露的機器——這樣任何人都能確定性地復現這個現象;具體的牆鍾數字在你的硬體上會不一樣,但等價性和階梯的方向不會變。第二,這個現象並不是合成實驗設定製造出來的假象:我們生產環境 HMA 引擎的基準測試(bench_param_sweep.py,跑在真實交易所資料上,配著完整的生產級手續費和成交模型)呈現出同樣的階梯形狀,numba 路徑落在樸素 pandas 畫像之上大約 100–200 倍的位置。之所以還要做這個自包含的實驗,就是為了讓你不必單憑信任來接受我們生產環境裡的數字。
要點總結
- 這個階梯是 298 倍,可以拆解為:35.3 倍引擎 × 8.4 倍編排。 把迭代移出直譯器(pandas → numba),再把相互獨立的組合攤開分給多個核心(一個 → 十二個),在一臺硬體毫無變化的筆記型電腦上相乘出了一個逼近三個數量級的加速比。69.92 秒 → 0.23 秒;1.1 → 340.9 組合/秒。而且這不是慢基準製造出來的假象:相對稱職的向量化 numpy 實現,最終引擎依然快約 13 倍。
- 在欣賞速度之前,先要求等價性。 這裡的每一級階梯都產出完全相同的逐組合 PnL 和交易筆數,並在全部 80 個組合上自動把關(PnL 絕對誤差容限 ,交易筆數完全精確)。一個計算出細微不同結果的快引擎並不是快——它只是在高吞吐量下犯錯,而向量化改寫正是錯誤通常悄悄溜進來的地方。
- 對有狀態的邏輯,
@njit勝過聰明的向量化。 numpy 那一級階梯需要一個針對該策略的閉式解,一旦加上止損就會失效。numba 那一級階梯編譯的是樸素、可審計的迴圈——同一個速度量級,卻沒有那份脆弱性,而且它正是能被並行化的那個單元。 - 關於 GPU 的答案是"這次掃描用不上"——而且你應該能說清楚為什麼。 特徵計算偏計算密集型(10.78 FLOP/位元組),而且它佔了編譯核心 99.3% 的時間,所以"回測受記憶體頻寬限制"和"有狀態迴圈佔主導"這兩種說法,都經不起實測的檢驗。真正誠實的原因是寬度和預算:80 個組合的可利用並行度,12 個 CPU 核心早就吃滿了;而 0.23 秒的總作業時長,會被核心啟動和同步開銷吃掉。在真正的寬度下做批次化的大矩陣重構,仍然是一個有前景的方向,而不是一個被推翻的方向。
- 引擎的速度就是研究的節奏。 以樸素引擎的吞吐量,一次 100,000 次回測的搜尋要花一天;以階梯頂端的吞吐量,只要五分鐘。在買硬體或租叢集之前,先檢查一下你的瓶頸到底是不是矽片——我們的瓶頸是藏在
rolling.apply裡的一個lambda,和 十一個閒置的核心。
完整的實驗——全部五種實現、等價性測試框架、屋頂線計算,以及本文中每一個可以從單一確定性指令碼重新生成出來的數字——都收錄在配套論文中,見 speed-ladder.marketmaker.cc,程式碼和資料見 github.com/suenot/backtest-speed-ladder。
曾經耗時七十秒的那次掃描,現在只需要四分之一秒。同樣的交易,同樣的 PnL,同樣的筆記型電腦。你正打算申請的那塊 GPU,可以再等等;你正打算交付的那個直譯器迴圈,等不了了。
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.