Thang Tốc Độ Backtest: 298x Trên CPU Laptop, PnL Giống Hệt Đến Giao Dịch Cuối
Bài viết thuộc series "Backtest Không Ảo Tưởng".
📄 Bài viết này đã phát triển thành một bài báo nghiên cứu. Một kernel backtest phụ thuộc đường đi (path-dependent) được triển khai theo năm cách — từ pandas ngây thơ đến kernel numba song song — với mỗi bậc được đối chiếu chéo để cho ra PnL giống hệt nhau theo từng combo, vậy nên điều duy nhất khác biệt là tốc độ. Đọc bài báo trực tuyến (phiên bản tương tác + PDF) tại speed-ladder.marketmaker.cc, mã nguồn và dữ liệu tại github.com/suenot/backtest-speed-ladder.
Bảy mươi giây. Đó là thời gian triển khai tham chiếu ngây thơ (naive) cần để quét 80 tổ hợp tham số của một chiến lược moving-average trên 150,000 nến: pandas rolling().apply() cho các chỉ báo, một vòng lặp Python thuần cho các giao dịch. Đây là hồ sơ (profile) mà một phần lớn code nghiên cứu thực tế chạy trên đó, bởi vì đó chính là hồ sơ nảy sinh khi viết chiến lược theo cách hiển nhiên nhất.
Cùng một sweep đó, trên cùng một laptop, cho ra cùng một PnL cho mọi tổ hợp đến tận giao dịch cuối cùng: 0.23 giây.
Khoảng cách giữa hai con số đó — mức đo được 298x — chính là chủ đề của bài viết này. Không một phần trăm nào trong đó đến từ phần cứng mới. Không có GPU nào tham gia (thậm chí không có GPU nào khả dụng trên máy này theo nghĩa CUDA). Mỗi bậc của thang đều dùng cùng một chiến lược, cùng dữ liệu, cùng phí giao dịch, cùng số lượng giao dịch, được xác minh bởi một cổng tương đương (equivalence gate) sẽ làm hỏng toàn bộ benchmark nếu kết quả theo từng combo của bất kỳ triển khai nào lệch nhau. Điều duy nhất thay đổi là cách công việc được biểu diễn: cái gì chạy trong interpreter, cái gì chạy dạng biên dịch, và cái gì chạy song song. Và vì một baseline cố tình chậm có thể tô hồng bất kỳ con số tiêu đề nào, thêm một con số nữa ngay từ đầu: ngay cả so với một triển khai numpy vector hóa có năng lực — đoạn code mà một lập trình viên numpy giỏi sẽ viết ra — engine hoàn thiện vẫn nhanh hơn khoảng 13x.
Khi một tìm kiếm tham số chạy chậm, phản xạ tự nhiên là tìm đến phần cứng lớn hơn — một GPU, một cluster, một ngân sách cloud. Thực tế đo được của thí nghiệm này lại chỉ về một nơi kém hào nhoáng hơn nhiều: nút thắt cổ chai là engine (một vòng lặp bên trong dạng interpreted thực hiện các lời gọi Python theo từng window) và điều phối (orchestration) (chạy các combo độc lập tuần tự trên một lõi). Cả hai đều có thể sửa được trong một buổi chiều, trên chính chiếc máy bạn đang sở hữu, mà không làm thay đổi kết quả.
Đây là toàn bộ thang tốc độ ngay từ đầu. Mọi thứ bên dưới là giải phẫu của từng bước.
| Bậc | Triển khai | Wall time | Tăng tốc | Combo/giây |
|---|---|---|---|---|
| M0 | pandas: rolling.apply + vòng lặp Python theo nến |
69.92 s | 1.0x | 1.1 |
| M1 | numpy: WMA cửa sổ trượt + giao dịch vector hóa | 3.07 s | 22.7x | 26.0 |
| M2 | numba: WMA @njit + vòng lặp sự kiện @njit |
1.98 s | 35.3x | 40.4 |
| M3 | numba prange: luồng (threads) trên các combo |
0.32 s | 217.6x | 248.9 |
| M4 | process pool + numba: process trên các combo | 0.23 s | 297.9x | 340.9 |
Apple M2 Max (12 lõi), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) được ghim vào một luồng duy nhất để các bậc đơn luồng thực sự chỉ chạy trên một lõi. 150,000 nến × 80 combo, wall time tốt nhất trong 3 lần chạy, không tính thời gian warm-up JIT. Tất cả các bậc — kể cả baseline pandas — đều được đo đầy đủ và xác minh cho ra PnL và số lượng giao dịch giống hệt nhau theo từng combo trên toàn bộ 80 combo.
Một kernel, năm cách triển khai

Để một phép so sánh tốc độ có ý nghĩa, thứ đang được tính toán phải được cố định chính xác, và mỗi triển khai phải được chứng minh là tính đúng thứ đó. Vì vậy thí nghiệm này cố định một kernel chiến lược duy nhất và giữ nguyên nó xuyên suốt cả năm bậc.
Kernel này là một giao cắt HMA/HMA3 — một hệ thống stop-and-reverse (dừng và đảo chiều) trên hai moving average kiểu Hull. Khối xây dựng cơ bản là weighted moving average (đường trung bình động có trọng số):
Hull Moving Average kết hợp ba đường trong số đó để giảm độ trễ (lag):
và HMA3 là một biến thể mượt hơn được xây dựng từ các WMA ở khoảng , và , rồi được làm mượt thêm một lần nữa. Với mỗi tổ hợp tham số, đó là bảy lượt WMA trên sáu độ dài cửa sổ khác nhau — một chồng chỉ báo (indicator stack) thực sự, không phải đồ chơi.
Quy tắc giao dịch có tính stateful (phụ thuộc trạng thái) một cách cố ý và hữu ích: hướng là long khi HMA nằm dưới HMA3 và short trong trường hợp còn lại; mở vị thế tại hướng xác định đầu tiên; ở mỗi lần giao cắt, đóng vị thế, ghi nhận PnL trừ đi phí round-trip 0.09%, và đảo chiều. Vị thế mang theo qua các nến — điều bạn làm ở nến phụ thuộc vào trạng thái đã tích lũy kể từ lần giao cắt trước đó. Sự phụ thuộc đường đi (path dependence) này chính là trọng tâm của thí nghiệm: đó là tính chất khiến backtest khác với các pipeline dataframe thông thường, và (như chúng ta sẽ đo) nó làm phức tạp câu hỏi về GPU — dù hóa ra không theo cách mà truyền miệng vẫn nói.
Phần còn lại của thiết lập, để bạn có thể tự đánh giá các con số:
- Dữ liệu: 150,000 nến chuyển động Brown hình học tổng hợp (synthetic geometric Brownian motion), có seed (
seed=42). Hiệu năng ở đây bị ràng buộc bởi kích thước mảng và độ dài cửa sổ, không phải bởi đường giá cụ thể nào được đưa vào — và một chuỗi tổng hợp khiến toàn bộ thí nghiệm mang tính tất định (deterministic) và có thể tái lập bởi bất kỳ ai. - Grid: 80 độ dài HMA khác nhau trải trên — vậy nên sweep này chứa cả các combo cửa sổ ngắn rẻ tiền lẫn các combo cửa sổ dài đắt đỏ, giống như một grid thực tế.
- Đo thời gian: wall-clock, tốt nhất trong 3 lần chạy cho mỗi bậc, với việc biên dịch JIT được warm-up bên ngoài bộ đếm thời gian và các worker của pool được warm-up trước khi đồng hồ bắt đầu chạy. Mỗi bậc — kể cả baseline pandas — đều được đo đầy đủ trên toàn bộ 80 combo. BLAS (Accelerate của Apple) được ghim vào một luồng duy nhất, vậy nên các bậc đơn luồng thực sự chỉ chạy trên một lõi: bậc numpy không âm thầm đa luồng hóa các phép matvec của nó phía sau lưng phép so sánh.
- Cổng tương đương (equivalence gate): sau khi đo thời gian, vector (PnL, số giao dịch) theo từng combo của mỗi bậc được so sánh với tham chiếu — số giao dịch phải khớp chính xác, PnL trong phạm vi sai số tuyệt đối điểm phần trăm. Lần chạy đã commit báo cáo
all_ok: truecho mọi bậc, kể cả baseline pandas, trên toàn bộ 80 combo. Nếu cổng này thất bại, sẽ không có benchmark nào cả — chỉ có năm chương trình tính năm thứ khác nhau ở năm tốc độ khác nhau, và đó chính là cách mà rất nhiều tuyên bố "engine của chúng tôi nhanh hơn 100x" âm thầm hoạt động.
Có một con số từ khối tương đương đáng để thành thật một chút: fingerprint cho combo đầu tiên là một PnL −5165.58 điểm phần trăm trên 57,029 giao dịch. Đó không phải là một kết quả chiến lược đáng xấu hổ — đó là độ dài HMA ngắn nhất (6) đảo chiều ở gần như mọi dao động nhỏ của một random walk và trả phí 0.09% mỗi lần, đúng như nó phải làm. Đây là một fingerprint đúng đắn (correctness fingerprint), không phải một backtest có thể giao dịch được. Đừng đọc alpha vào đó; hãy đọc tính tất định (determinism) vào đó — năm triển khai cùng cho ra 57,029 giao dịch giống nhau và cùng một PnL đến sáu chữ số thập phân, đó chính là ý nghĩa của "giống hệt nhau" ở đây.
Với điều đó đã được thiết lập, mọi mức tăng tốc bên dưới đều là tốc độ thuần túy. Không có gì bị xấp xỉ hóa để lược bỏ.
Bậc M0: hồ sơ pandas ngây thơ — 69.9 s

Baseline này không phải là một strawman (lập luận ngụy tạo để dễ bác bỏ). Đây là đoạn code bạn sẽ có được khi viết một WMA theo đúng cách mà tài liệu pandas gợi ý, và vòng lặp sự kiện theo đúng cách mà mô tả chiến lược viết ra:
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
Tại sao điều này lại chậm? Không phải vì pandas "tệ" — mà vì nơi vòng lặp thực sự diễn ra. rolling(period).apply(lambda ...) là một vòng lặp cấp độ Python khoác lên mình bộ áo vector hóa. Với từng nến trong số 150,000 nến, pandas hiện thực hóa (materialize) một cửa sổ, băng qua ranh giới C/Python, gọi một callable Python, và box kết quả lại. Ngay cả với raw=True (ít nhất giúp lambda nhận một ndarray thuần thay vì một Series), chi phí interpreter cho mỗi lần gọi vẫn áp đảo hoàn toàn con số ~vài chục đến vài trăm FLOP mà cửa sổ đó thực sự cần. Nhân với bảy lượt WMA cho mỗi combo, và riêng chồng chỉ báo đã là hàng triệu lượt round-trip qua interpreter. Sau đó vòng lặp theo nến chạy thêm 150,000 lần lặp interpreted nữa cho mỗi combo, mỗi lần thực hiện chỉ mục có kiểm tra biên (bounds-checked indexing) trên các numpy scalar, box các số float, và dispatch động trên các kiểu dữ liệu mà interpreter phải khám phá lại từ đầu mỗi lần.
Kết quả: 69.92 s cho toàn bộ sweep, khoảng 0.87 s cho mỗi combo, throughput 1.1 combo mỗi giây. Với một grid 80 combo, bạn nhún vai và đợi một phút. Vấn đề là không ai chạy grid 80 combo trong thời gian dài — và chi phí này scale tuyến tính mãi mãi. Chúng ta sẽ quay lại điều đó.
Bậc M1: numpy — ngừng gọi Python trong vòng lặp — 3.07 s, 22.7x
Bậc tiếp theo loại bỏ cả hai vòng lặp interpreter cùng một lúc, và đáng để tách riêng hai thủ thuật này vì chúng có tính tổng quát rất khác nhau.
Phía chỉ báo là phần dễ, hoàn toàn tổng quát. Một weighted moving average trên toàn bộ cửa sổ chỉ đơn giản là một phép nhân ma trận–vector với một strided view của đầu vào — không copy, một lời gọi BLAS duy nhất:
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 xây dựng một view (n − p + 1, p) trên cùng vùng bộ nhớ, và win @ w tính tích vô hướng của mọi cửa sổ bằng code đã biên dịch. Hàng triệu lượt gọi lambda trở thành một lời gọi thư viện duy nhất.
Phía giao dịch mới là phần thú vị, bởi vì vòng lặp sự kiện có tính stateful — vậy mà, với kernel này, nó vẫn vector hóa được. Insight ở đây là vị thế tại bất kỳ nến nào chỉ phụ thuộc vào dấu của HMA − HMA3, chứ không phụ thuộc vào kết quả của bất kỳ giao dịch nào. Trạng thái không bao giờ phản hồi ngược lại vào các quyết định. Vì vậy toàn bộ vòng lặp thu gọn thành "tìm các điểm đảo dấu, thu thập giá tại các chỉ mục đó":
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 s, tăng tốc 22.7x, 26.0 combo mỗi giây — trên một lõi, với BLAS ghim vào một luồng duy nhất. Bậc này xứng đáng có một cái tên: đây là baseline có năng lực (competent baseline), triển khai mà một lập trình viên numpy giỏi sẽ viết ra, và là thước đo công bằng cho mọi thứ phía trên nó. Nhưng có hai lưu ý trung thực đi kèm với bậc này.
Thứ nhất, cách vector hóa này là một phép viết lại giải tích đặc thù cho chiến lược (strategy-specific analytical rewrite), không phải một phép biến đổi cơ học. Nó tồn tại được vì kernel này là stop-and-reverse không có stop, không có trailing exit, không có position sizing phụ thuộc vào PnL đang chạy. Thêm một stop-loss — tính năng bình thường nhất có thể tưởng tượng — thì lệnh thoát tại nến sẽ thay đổi lệnh vào nào tồn tại tại nến , trạng thái phản hồi ngược lại vào đường đi, và dạng đóng (closed form) biến mất. Hầu hết các kernel production đều nằm ở phía sai của lằn ranh đó.
Thứ hai, đây là bậc mà tính đúng đắn dễ chết nhất. Việc quản lý chỉ mục đảo dấu (+1 ở đây, [:-1] ở kia, việc gieo hướng đầu tiên) chính xác là loại code sản sinh ra các lỗi thực thi off-by-one — cùng loài với lỗi mà phân loại look-ahead của chúng tôi đã cho thấy có thể tạo ra Sharpe 15 từ nhiễu thuần túy. Cổng tương đương không phải là một thủ tục hình thức ở bậc này; đó là lý do duy nhất để tin tưởng nó. Những phép viết lại vector hóa tinh vi mà không có kiểm tra tương đương với một triển khai tham chiếu ngớ ngẩn chính là cách các engine trôi dạt xa khỏi chiến lược mà chúng tuyên bố đang kiểm thử.
Bậc M2: numba — biên dịch chính vòng lặp bạn muốn viết — 1.98 s, 35.3x

Bậc M2 áp dụng triết lý ngược lại: thay vì bẻ cong thuật toán để khớp với các primitive vector hóa, hãy viết các vòng lặp ngây thơ — rồi biên dịch chúng. Numba (Lam, Pitrou & Seibert, 2015) biên dịch JIT một tập con số học của Python thông qua LLVM thành machine code:
@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)
Vòng lặp sự kiện bên trong nb_sweep về mặt văn bản chính là vòng lặp M0. Các nhánh, continue, trạng thái được mang trong biến local — tất cả đều vậy. Dưới @njit, các biến local đó sống trong thanh ghi (register), các nhánh là các lệnh jump thực sự, và chi phí cho mỗi lần lặp giảm từ mức micro giây của dispatch interpreter xuống còn nano giây.
1.98 s — 35.3x so với pandas, nhưng chỉ khoảng 1.6x so với numpy (tính ra: 3.07/1.98). Bước tiến khiêm tốn đó tự nó đã mang tính chỉ dẫn: các vòng lặp bên trong của numpy vốn đã được biên dịch rồi, vậy nên phần thắng của numba trên phép toán chỉ báo bị giới hạn ở việc bỏ qua bước hiện thực hóa cửa sổ và các mảng trung gian. Phần mang tính chuyển hóa nằm ở nơi khác:
- Vòng lặp sự kiện giờ đây miễn phí — và "miễn phí" ở đây được đo, không phải chỉ là tu từ. M1 đã dồn hết sự khéo léo để khiến logic giao dịch có thể vector hóa được. M2 khiến sự khéo léo đó trở nên không cần thiết — vòng lặp ngây thơ, dễ kiểm chứng, dễ sửa đổi chạy ở tốc độ machine code. Đo riêng giai đoạn tính chỉ báo tách khỏi vòng lặp giao dịch bên trong kernel đã biên dịch này cho thấy 99.3% thời gian thuộc về phép toán chỉ báo WMA và chỉ 0.7% thuộc về vòng lặp sự kiện stateful. Bạn có thể thêm một stop-loss vào ngày mai mà không cần một dự án nghiên cứu — và hãy giữ lấy con số tách bạch đó; nó sẽ quyết định lại lập luận về GPU bên dưới.
- Nó mở khóa hai bậc tiếp theo. Một kernel đã biên dịch, giải phóng GIL, ít cấp phát bộ nhớ chính là đơn vị công việc mà điều phối song song cần. Bạn không thể song song hóa M0 một cách hiệu quả — mười hai bản sao của sự chậm chạp vẫn là chậm chạp, chỉ là nóng hơn thôi.
Một lưu ý về phương pháp luận: numba biên dịch ở lần gọi đầu tiên, và việc biên dịch đó (hàng trăm mili giây) không được nằm bên trong bộ đếm thời gian — harness warm-up JIT trên một lát cắt 500 nến trước khi đo, và cache=True giữ lại các kernel đã biên dịch qua các lần khởi chạy process. Các benchmark "quên" chi tiết này sẽ cho ra các con số numba hoặc bị đánh giá bất công là tệ (vì tính cả compile lạnh) hoặc không thể tái lập.
Bậc M3: prange — sự song song bạn đã sẵn có — 0.32 s, 217.6x

Đây là quan sát khiến tìm kiếm tham số quy mô lớn trở nên đặc biệt: 80 combo hoàn toàn độc lập với nhau. Không trạng thái chung, không thứ tự, không giao tiếp. Đây là công việc "song song đến mức xấu hổ" (embarrassingly parallel) mà các bậc M0–M2 lại chạy trên một lõi trong số mười hai lõi, chỉ vì thói quen thuần túy.
Numba khiến việc sửa gần như chỉ là cú pháp — đổi range của vòng lặp combo thành 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
Vì nb_sweep được biên dịch ở chế độ nopython, nó không giữ GIL, và lớp threading của numba trải các lượt lặp ra trên toàn bộ 12 lõi. Mảng close chỉ đọc được chia sẻ bởi tất cả các luồng với chi phí bằng không.
0.32 s — 217.6x so với pandas, 248.9 combo mỗi giây. Bước tiến so với M2 đơn luồng là khoảng 6.2x trên 12 lõi (tính ra: 1.98/0.32), và mức thiếu hụt so với "12x lý tưởng" đáng để thành thật thừa nhận thay vì che giấu: 12 lõi của M2 Max là 8 lõi performance + 4 lõi efficiency, vậy nên trần lý thuyết chưa bao giờ là 12x; 80 combo có chi phí chênh lệch nhau rất lớn (một HMA độ dài 6 rẻ hơn nhiều so với một HMA độ dài 200), vậy nên các luồng hoàn thành không đồng đều; và mỗi lời gọi kernel cấp phát các mảng trung gian của nó từ một bộ cấp phát dùng chung. Mức tăng tốc song song trên các máy thực tế trông như vậy đó. Bất kỳ ai tuyên bố một con số Nx sạch sẽ trên N lõi cho các tác vụ không đồng nhất đều đang đo thứ gì đó mang tính tổng hợp giả tạo.
Bậc M4: một process pool cho một phần ba cuối cùng — 0.23 s, 297.9x
Bậc cuối cùng thay thế threads bằng processes — cùng một kernel đã biên dịch, được điều phối bởi một 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 s — 297.9x so với pandas, 340.9 combo mỗi giây. Hãy đọc lại con số throughput đó lần nữa: chiếc laptop này giờ đây đang chạy khoảng 340 backtest đầy đủ 150,000 nến mỗi giây, mỗi backtest tính bảy weighted moving average và mô phỏng hàng chục nghìn giao dịch stateful.
Lợi thế so với prange là có thật nhưng khiêm tốn — khoảng 1.4x (tính ra: 0.32/0.23) — và cơ chế hợp lý ở đây là việc lập lịch và cô lập bộ nhớ: với chunksize=1, pool phát ra từng combo một, vậy nên sự pha trộn không đồng đều giữa các cửa sổ rẻ và đắt được cân bằng tải động trên các lõi bất đối xứng, và mỗi worker process có bộ cấp phát riêng của nó, tránh được tranh chấp trên các biến tạm theo từng combo. Chúng tôi trình bày những điều này như các cơ chế phù hợp với phép đo, chứ không phải như những sự thật đã được chứng minh riêng biệt.
Process không miễn phí, và harness thanh toán chi phí của chúng một cách trung thực bên ngoài bộ đếm thời gian, nơi chúng là các chi phí một lần (khởi động worker, gửi close đến từng worker qua initializer, warm-up JIT cho từng worker) — bởi vì trong một tìm kiếm thực tế, các chi phí đó được khấu hao trên hàng nghìn combo, không phải tám mươi. Lời khuyên chung trung thực: prange đơn giản hơn và thường là đủ; một process pool thắng thế khi các tác vụ nặng nề (chunky), grid lớn, hoặc công việc theo từng combo của bạn giữ GIL ở đâu đó mà numba không chạm tới được.
Và với điều đó, thang tốc độ được phân rã thành một tóm tắt gọn gàng. Từ M0 đến M2 — engine: 35.3x trên một lõi duy nhất, nhờ chuyển vòng lặp ra khỏi interpreter. Từ M2 đến M4 — điều phối: thêm 8.4x nữa (tính ra: 1.98/0.23), nhờ sử dụng các lõi vốn đã sẵn có ở đó. Nhân lại: 298x. Không phần cứng mới, kết quả giống hệt nhau. Và khi đo từ baseline M1 có năng lực thay vì baseline ngây thơ, engine hoàn thiện vẫn cao hơn khoảng 13x (tính ra: 3.07/0.23) — thang tốc độ này không phải là một hiện vật giả tạo do chọn điểm xuất phát chậm.
Tại sao không dùng GPU — phiên bản trung thực

"Cứ port nó sang GPU đi" là phản hồi phổ biến nhất cho một parameter sweep chậm, vậy nên thí nghiệm này đo hai con số mà cuộc trò chuyện đó nên bắt đầu từ đó — và không con số nào ủng hộ phiên bản lười biếng của bất kỳ câu trả lời nào.
Mô hình roofline (Williams, Waterman & Patterson, 2009) phân loại một kernel theo cường độ số học (arithmetic intensity) của nó — số FLOP trên mỗi byte được di chuyển. Với chồng chỉ báo WMA trong sweep này, tính FLOP cho mỗi nến trên mỗi cửa sổ độ dài so với một lần đọc 8 byte cho mỗi nến, toàn bộ sweep 80 combo tính ra khoảng 6.2 GFLOP trên 576 MB được truyền qua:
(Đó là con số lý tưởng hóa trên sáu cửa sổ WMA khác nhau cho mỗi combo; tính cả bảy lượt như thực tế thực thi cho ra 11.07 FLOP/byte. Kết luận vẫn như nhau theo cả hai cách tính.)
Con số đó quan trọng vì những gì nó loại trừ: tuyên bố phổ biến rằng phép toán backtest là "memory-bound, nên GPU không giúp được gì" là sai ở đây. Ở mức ~10.8 FLOP/byte, phép toán chỉ báo dứt khoát mang tính compute — vượt xa qua điểm gờ (ridge point) mà tại đó phần cứng thông thường ngừng bị giới hạn bởi băng thông. Một GPU hoàn toàn có thể gộp lô 80 combo × 7 lượt WMA thành một nhúm kernel lớn và nghiền nát phần số học đó. Nếu chồng chỉ báo là toàn bộ vấn đề, lập luận về GPU sẽ đáng được cân nhắc nghiêm túc.
Con số đo được thứ hai giết chết câu trả lời lười biếng còn lại — câu mà chính chúng tôi cũng đã suýt chọn. Đo riêng giai đoạn tính chỉ báo tách khỏi vòng lặp giao dịch bên trong kernel đã biên dịch cho ra một tỷ lệ tách bạch 99.3% chỉ báo, 0.7% vòng lặp sự kiện. Lập luận hấp dẫn — "backtest có một vòng lặp sự kiện stateful, phân nhánh, và chính điều đó là thứ chặn GPU" — sai về mặt định lượng ở đây: CPU dành gần như toàn bộ thời gian của nó đúng vào phần mà một GPU có thể xử lý theo lô. Diễn đạt lại 80 combo × 7 lượt WMA thành các phép convolution theo lô lớn và bạn có một tải công việc tensor hoàn toàn hợp lý. Vậy nên câu hỏi trung thực không phải là liệu công việc có thể chuyển sang GPU hay không — phần lớn nó có thể. Câu hỏi là liệu chuyến đi đó có bõ công hay không, và với sweep này thì không, vì hai lý do cụ thể:
1. Bề rộng có thể khai thác là 80 combo — và GPU là một cỗ máy bề rộng. Trục song song trung thực duy nhất trong một parameter sweep chính là bản thân grid: bên trong một combo, đường đi 150,000 nến mang tính tuần tự. Một GPU muốn hàng chục nghìn work item độc lập để lấp đầy các lane của nó và che giấu độ trễ; sweep này chỉ cung cấp tám mươi. Mười hai lõi CPU đã bão hòa bề rộng đó rồi — đó chính xác là những gì các bậc M3–M4 đã đo được. Với số lượng combo mà tại đó bề rộng của GPU thậm chí mới bắt đầu phát huy tác dụng, thang tốc độ CPU đã đang cung cấp hàng trăm backtest đầy đủ mỗi giây rồi.
2. Toàn bộ công việc chỉ mất 0.23 giây. Ở tốc độ M4, một combo tốn khoảng 2.9 ms (tính ra: 0.23 s / 80). So với ngân sách đó, độ trễ khởi chạy kernel và các điểm đồng bộ hóa thiết bị không phải là sai số làm tròn có thể khấu hao được — chúng là một phần đáng kể của công việc. (Trên chiếc máy Apple bộ nhớ hợp nhất (unified-memory) này, việc truyền dữ liệu host-to-device chỉ là mối lo nhỏ; trên một hộp CUDA GPU rời, chi phí đó cũng cộng thêm vào hóa đơn.) Lợi thế GPU kinh điển khấu hao các chi phí cố định trên những lô công việc khổng lồ; một sweep dưới một giây không bao giờ tạo ra được điều đó.
Còn vòng lặp sự kiện thì sao? Đó là phần duy nhất sẽ không xử lý được theo lô — tuần tự, phân nhánh, phụ thuộc đường đi, một loop-carried dependency dài 150,000 nến mà không phần cứng nào có thể song song hóa được bên trong một combo, với chính xác loại nhánh phân kỳ mà các lane SIMT ghét cay ghét đắng. Một bản port GPU sẽ để phần này lại trên CPU hoặc chạy nó một lane cho mỗi combo. Nhưng ở mức 0.7% của kernel, đó là một số hạng Amdahl quá nhỏ để quyết định bất cứ điều gì. Đó là phần sẽ không đi được; đó không phải là lý do để không đi. (Nhớ lại từ bậc M1 rằng với các kernel không có phản hồi (feedback-free), vòng lặp thậm chí có thể được vector hóa giải tích — phép viết lại mà bạn sẽ mất ngay khi chiến lược mọc thêm một stop.)
Một ghi chú nền tảng để cho đầy đủ: trên chiếc máy này (Apple Silicon), con đường GPU sẽ là MLX hoặc PyTorch-MPS, không phải CUDA — cupy và hệ sinh thái CUDA đơn giản là không áp dụng được — và cả hai đều đòi hỏi viết lại hot path bằng một tensor dialect chỉ để thử thí nghiệm này. Đó là một chi phí có thật mà, theo phân tích ở trên, không có lợi ích nào được xác định cho hình dạng của sweep này. Phần thảo luận về GPU ở đây mang tính phân tích, dựa trên cường độ số học đo được và tỷ lệ tách bạch chỉ báo/vòng lặp đo được, và chúng tôi gắn nhãn nó như vậy: không có lần chạy CUDA nào được thực hiện vì không lần nào khả thi trên phần cứng đã công bố.
Câu tóm tắt mà chúng tôi sẽ bảo vệ trong review: gần như toàn bộ công việc này có thể chuyển sang GPU; sweep này chỉ đơn giản là quá hẹp và quá ngắn để chuyến đi đó bõ công. Và hãy đọc điều đó theo cả hai hướng — đây không phải là một sự gạch bỏ. Việc tái cấu trúc "ma trận lớn" theo lô — diễn đạt lại sweep thành các phép toán tensor lớn trên hàng nghìn combo cùng một lúc, hoặc một kernel thực sự không có phản hồi xử lý theo lô từ đầu đến cuối — là một hướng đi thật sự và đầy hứa hẹn, xứng đáng có một nghiên cứu riêng, không phải một sự bác bỏ. Ở mức 80 combo và 0.23 giây, nó đơn giản là chưa đủ điều kiện để có tấm vé đó. Nếu tải công việc của bạn có bề rộng đó, phép toán sẽ thay đổi, và bạn nên làm lại phép tính đó, đừng trích dẫn chúng tôi.
Nút thắt cổ chai thực sự nằm ở đâu: engine và điều phối

Tám mươi combo là một grid trình diễn. Tìm kiếm tham số thực tế là nơi các yếu tố này ngừng mang tính học thuật, bởi vì grid tăng trưởng theo kiểu nhân lên: bốn tham số với mười giá trị mỗi tham số là combo; thêm xác thực walk-forward với một tá fold và bạn đã ở mức backtest đầy đủ trước khi bạn khám phá được bất cứ điều gì. Đây là lời nguyền của chiều (curse of dimensionality), và đó là lý do vì sao chiến lược tìm kiếm — Optuna, coordinate descent, Sobol — nhận được nhiều sự chú ý đến vậy: tìm kiếm thông minh hơn ghé thăm ít điểm hơn.
Nhưng thang tốc độ hé lộ nửa còn lại của phương trình, ít được bàn tới hơn: chi phí cho mỗi điểm được ghé thăm. Ngoại suy tuyến tính các throughput đã đo được (các combo độc lập với nhau, nên đây là số học, không phải mô hình hóa):
| Kích thước grid | Ở M0 (1.1 combo/s) | Ở M4 (340.9 combo/s) |
|---|---|---|
| 10,000 combo | ~2.4 giờ | ~30 giây |
| 100,000 combo | ~24 giờ | ~5 phút |
Cùng một thí nghiệm là một batch job chạy qua đêm trên engine ngây thơ lại là một truy vấn tương tác trên engine đã được tinh chỉnh. Sự khác biệt đó tích lũy theo cách mà các bảng wall-clock đánh giá thấp: ở 5 phút mỗi sweep bạn lặp lại — bạn chạy lại với một rò rỉ đã được sửa, bạn thêm một fold, bạn mở rộng grid, bạn kiểm tra ý tưởng nảy ra trong bữa trưa. Ở 24 giờ mỗi sweep, bạn không làm vậy. Tốc độ của engine thiết lập nhịp độ của vòng lặp nghiên cứu, và nhịp độ của vòng lặp nghiên cứu chính là sản phẩm thực sự.
Cũng có một cách đọc toàn bộ thang tốc độ này theo định luật Amdahl:
Tăng tốc bất kỳ giai đoạn đơn lẻ nào theo hệ số đều bị giới hạn bởi mọi thứ khác mà bạn để lại chậm. Thang tốc độ này tuân theo đúng thứ tự đó: mức tăng 35.3x của engine tấn công vào số hạng đang chiếm ưu thế (vòng lặp interpreted, trong cả chồng chỉ báo lẫn vòng lặp), và mức tăng 8.4x của điều phối tấn công vào số hạng chiếm ưu thế sau đó (mười một lõi nhàn rỗi). Tỷ lệ tách bạch chỉ báo/vòng lặp là cùng một bài học thu nhỏ — chúng tôi đã không thể chỉ ra được hình dạng thực sự của lập luận GPU nếu không đo xem thời gian thực sự đi đâu. Profile trước, rồi mới tối ưu hóa — theo đúng thứ tự đó. Cùng một logic đó chi phối lớp dữ liệu nằm phía trên engine: benchmark Polars vs pandas của chúng tôi tìm thấy cùng một khuôn mẫu (10–3500x trên các pipeline grouped rolling) cho nửa load-and-transform của stack, và cùng một kết luận hybrid — engine dạng cột (columnar) cho pipeline, một kernel đã biên dịch cho mô phỏng phụ thuộc đường đi.
Hai lưu ý trung thực để khép lại vòng tròn về tính tổng quát. Thứ nhất, thí nghiệm này cố ý mang tính tự chứa (self-contained) và tổng hợp (synthetic) — dữ liệu có seed, một kernel, một chiếc máy đã công bố — để bất kỳ ai cũng có thể tái lập hiện tượng này một cách tất định; các con số wall-clock sẽ khác nhau trên phần cứng của bạn, nhưng tính tương đương và hướng đi của thang tốc độ thì không. Thứ hai, hiện tượng này không phải là một hiện vật giả tạo do thiết lập tổng hợp: benchmark của engine HMA production của chúng tôi (bench_param_sweep.py, chạy trên dữ liệu sàn giao dịch thực với mô hình phí và fill production đầy đủ) cho thấy cùng hình dạng thang tốc độ đó, với con đường numba đạt khoảng 100–200x cao hơn hồ sơ pandas ngây thơ. Thí nghiệm tự chứa này tồn tại để bạn không phải tin vào các con số production của chúng tôi chỉ dựa trên niềm tin.
Điểm rút ra
- Thang tốc độ là 298x, và nó phân rã thành: 35.3x engine × 8.4x điều phối. Việc chuyển vòng lặp ra khỏi interpreter (pandas → numba) và trải các combo độc lập ra trên các lõi (một → mười hai) nhân lại thành một mức tăng tốc gần ba bậc độ lớn trên cùng một chiếc laptop không đổi. 69.92 s → 0.23 s; 1.1 → 340.9 combo/s. Và đây không phải là hiện vật giả tạo do chọn baseline chậm: so với triển khai numpy vector hóa có năng lực, engine hoàn thiện vẫn nhanh hơn ~13x.
- Đòi hỏi tính tương đương trước khi bạn ngưỡng mộ tốc độ. Mỗi bậc ở đây đều cho ra PnL và số lượng giao dịch giống hệt nhau theo từng combo, được kiểm tra tự động trên toàn bộ 80 combo (sai số tuyệt đối trên PnL, chính xác tuyệt đối trên số giao dịch). Một engine nhanh mà tính ra thứ gì đó hơi khác đi thì không phải là nhanh — đó là sai ở throughput cao, và các phép viết lại vector hóa chính là nơi sự sai lệch thường lẻn vào.
@njitđánh bại vector hóa tinh vi khi xử lý logic stateful. Bậc numpy cần một dạng đóng đặc thù cho chiến lược mà sẽ chết ngay khi bạn thêm một stop-loss. Bậc numba biên dịch vòng lặp ngây thơ, dễ kiểm chứng — cùng hạng tốc độ, không có sự mong manh nào, và đó chính là đơn vị có thể song song hóa được.- Câu trả lời về GPU là "không phải cho sweep này" — vì những lý do bạn nên có thể gọi tên được. Phép toán chỉ báo mang tính compute (10.78 FLOP/byte) và nó chiếm 99.3% kernel đã biên dịch, vậy nên cả "backtest là memory-bound" lẫn "vòng lặp stateful chiếm ưu thế" đều không đứng vững trước phép đo. Những lý do trung thực là bề rộng và ngân sách: 80 combo song song có thể khai thác mà 12 lõi CPU đã bão hòa rồi, và một công việc tổng 0.23 giây mà chi phí khởi chạy và đồng bộ hóa sẽ nuốt trọn. Việc tái cấu trúc ma trận lớn theo lô ở bề rộng thực sự vẫn là một hướng đi đầy hứa hẹn, không phải một hướng đã bị bác bỏ.
- Tốc độ engine chính là nhịp độ nghiên cứu. Ở throughput của engine ngây thơ, một tìm kiếm 100,000 backtest mất một ngày; ở throughput đỉnh của thang tốc độ, nó mất năm phút. Trước khi mua phần cứng hoặc thuê một cluster, hãy kiểm tra xem nút thắt cổ chai của bạn có thực sự là silicon hay không — của chúng tôi là một
lambdabên trongrolling.applyvà mười một lõi nhàn rỗi.
Toàn bộ thí nghiệm — cả năm triển khai, harness tương đương, phép tính roofline, và mọi con số trong bài viết này đều có thể tái tạo lại từ một script tất định duy nhất — nằm trong bài báo đồng hành tại speed-ladder.marketmaker.cc, cùng với mã nguồn và dữ liệu tại github.com/suenot/backtest-speed-ladder.
Sweep từng mất bảy mươi giây giờ chỉ mất một phần tư giây. Cùng những giao dịch đó, cùng PnL đó, cùng chiếc laptop đó. Chiếc GPU bạn sắp đi xin cấp có thể chờ; vòng lặp interpreter bạn sắp đưa vào production thì không thể.
Tác Giả
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.