Tangga Kecepatan Engine Backtest: 298x pada CPU Laptop, PnL Identik hingga Transaksi Terakhir
Bagian dari seri "Backtests Without Illusions".
📄 Artikel ini berkembang menjadi paper riset. Satu kernel backtest path-dependent diimplementasikan dengan lima cara — dari pandas naif hingga kernel numba paralel — dengan setiap anak tangga diverifikasi silang untuk menghasilkan PnL per-kombinasi yang identik, sehingga satu-satunya yang berbeda adalah kecepatan. Baca paper-nya online (versi interaktif + PDF) di speed-ladder.marketmaker.cc, kode dan data di github.com/suenot/backtest-speed-ladder.
Tujuh puluh detik. Itulah waktu yang dibutuhkan implementasi referensi naif untuk menyapu 80 kombinasi parameter dari satu strategi moving-average sepanjang 150,000 bar: pandas rolling().apply() untuk indikator, sebuah loop Python biasa untuk transaksi. Ini adalah profil yang dijalankan oleh sebagian besar kode riset dunia nyata, karena inilah profil yang muncul begitu saja ketika Anda menulis strategi dengan cara yang paling jelas.
Sweep yang sama, pada laptop yang sama, menghasilkan PnL yang sama untuk setiap kombinasi hingga transaksi terakhir: 0.23 detik.
Selisih antara kedua angka itu — terukur 298x — adalah topik artikel ini. Tidak satu persen pun dari itu berasal dari hardware baru. Tidak ada GPU yang terlibat (bahkan tidak ada yang tersedia di mesin ini dalam artian CUDA). Setiap anak tangga menjalankan strategi yang sama, data yang sama, fee yang sama, jumlah transaksi yang sama, diverifikasi oleh gerbang ekuivalensi yang menggagalkan seluruh benchmark jika hasil per-kombinasi dari implementasi mana pun menyimpang. Yang berubah hanyalah bagaimana pekerjaan itu diekspresikan: apa yang berjalan di interpreter, apa yang berjalan terkompilasi, dan apa yang berjalan paralel. Dan karena baseline yang sengaja dibuat lambat bisa menyanjung angka headline apa pun, satu angka lagi di muka: bahkan dibandingkan implementasi numpy vektorisasi yang kompeten — kode yang akan dikirim oleh programmer numpy yang handal — engine yang sudah jadi masih sekitar 13x lebih cepat.
Ketika pencarian parameter terasa lambat, refleksnya adalah meraih hardware yang lebih besar — GPU, cluster, anggaran cloud. Realita terukur dari eksperimen ini menunjuk ke tempat yang jauh kurang glamor: bottleneck-nya adalah engine (inner loop terinterpretasi yang melakukan pemanggilan Python per-window) dan orkestrasi (menjalankan kombinasi independen secara serial pada satu core). Keduanya bisa diperbaiki dalam satu sore, pada mesin yang sudah Anda miliki, tanpa perubahan apa pun pada hasil.
Berikut seluruh tangga di muka. Semua yang di bawah adalah anatomi dari setiap langkah.
| Anak Tangga | Implementasi | Wall Time | Speedup | Kombo/dtk |
|---|---|---|---|---|
| M0 | pandas: rolling.apply + loop bar Python |
69.92 s | 1.0x | 1.1 |
| M1 | numpy: sliding-window WMA + transaksi tervektorisasi | 3.07 s | 22.7x | 26.0 |
| M2 | numba: WMA @njit + loop event @njit |
1.98 s | 35.3x | 40.4 |
| M3 | numba prange: thread lintas kombo |
0.32 s | 217.6x | 248.9 |
| M4 | process pool + numba: proses lintas kombo | 0.23 s | 297.9x | 340.9 |
Apple M2 Max (12 core), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) dipin ke satu thread sehingga anak tangga single-threaded benar-benar single-core. 150,000 bar × 80 kombo, wall time best-of-3, pemanasan JIT dikecualikan. Semua anak tangga — termasuk baseline pandas — diukur secara penuh dan diverifikasi menghasilkan PnL dan jumlah transaksi per-kombo yang identik pada seluruh 80 kombo.
Satu kernel, lima implementasi

Agar perbandingan kecepatan bermakna, hal yang dihitung harus dipatok secara persis, dan setiap implementasi harus dibuktikan menghitung hal itu. Jadi eksperimen ini mematok satu kernel strategi dan menjaganya tetap konstan di seluruh lima anak tangga.
Kernelnya adalah HMA/HMA3 cross — sistem stop-and-reverse pada dua moving average bergaya Hull. Building block-nya adalah weighted moving average:
Hull Moving Average menggabungkan tiga di antaranya untuk memotong lag:
dan HMA3 adalah saudara yang lebih halus, dibangun dari WMA pada panjang kira-kira , dan , dihaluskan sekali lagi. Per kombinasi parameter, itu adalah tujuh pass WMA melintasi enam panjang window berbeda — stack indikator sungguhan, bukan mainan.
Aturan trading-nya sengaja dibuat stateful, dan berguna: arah adalah long ketika HMA di bawah HMA3 dan short jika sebaliknya; buka posisi pada arah terdefinisi pertama; pada setiap cross, tutup posisi, catat PnL dikurangi fee round-trip 0.09%, dan balik arah. Posisi terbawa lintas bar — apa yang Anda lakukan pada bar bergantung pada state yang terakumulasi sejak cross terakhir. Path dependence ini adalah inti dari eksperimen: inilah properti yang membuat backtest berbeda dari pipeline dataframe generik, dan (seperti akan kita ukur) mempersulit pertanyaan GPU — meski, ternyata, tidak dengan cara yang dikatakan folklore.
Sisa dari setup, agar Anda bisa menilai angkanya:
- Data: 150,000 bar geometric Brownian motion sintetis, di-seed (
seed=42). Performa di sini dibatasi oleh ukuran array dan panjang window, bukan oleh price path mana yang Anda masukkan — dan deret sintetis membuat seluruh eksperimen deterministik dan bisa direproduksi siapa pun. - Grid: 80 panjang HMA berbeda tersebar pada — sehingga sweep mengandung baik kombo window pendek yang murah maupun kombo window panjang yang mahal, seperti grid sungguhan.
- Timing: wall-clock, best-of-3 per anak tangga, dengan kompilasi JIT dipanaskan di luar timer dan worker pool dipanaskan sebelum jam mulai berjalan. Setiap anak tangga — termasuk baseline pandas — diukur secara penuh di seluruh 80 kombo. BLAS (Accelerate milik Apple) dipin ke satu thread, sehingga anak tangga single-threaded benar-benar single-core: anak tangga numpy tidak diam-diam multithreading matvec-nya di balik perbandingan.
- Gerbang ekuivalensi: setelah pengukuran waktu, vektor (PnL, jumlah transaksi) per-kombo dari setiap anak tangga dibandingkan dengan referensi — jumlah transaksi harus cocok persis, PnL dalam toleransi absolut poin persentase. Run yang tercatat melaporkan
all_ok: trueuntuk setiap anak tangga, termasuk baseline pandas, pada seluruh 80 kombo. Jika gerbang ini gagal, tidak ada benchmark — yang ada hanyalah lima program yang menghitung lima hal berbeda pada lima kecepatan berbeda, yang menjelaskan bagaimana banyak klaim "engine kami 100x lebih cepat" diam-diam bekerja.
Satu angka dari blok ekuivalensi layak mendapat momen kejujuran: fingerprint untuk kombo pertama adalah PnL sebesar −5165.58 poin persentase sepanjang 57,029 transaksi. Itu bukan hasil strategi yang perlu dipermalukan — itu adalah panjang HMA terpendek (6) yang membalik arah pada hampir setiap goyangan random walk dan membayar 0.09% setiap kalinya, persis seperti seharusnya. Ini adalah fingerprint kebenaran, bukan backtest yang bisa diperdagangkan. Jangan baca alpha di dalamnya; baca determinisme di dalamnya — lima implementasi mendarat pada 57,029 transaksi yang sama dan PnL yang sama hingga enam desimal adalah arti "identik" di sini.
Dengan itu ditetapkan, setiap speedup di bawah adalah kecepatan murni. Tidak ada yang didekati atau dikompromikan.
Anak Tangga M0: profil pandas naif — 69.9 s

Baseline ini bukan strawman. Ini adalah kode yang Anda dapatkan ketika Anda menulis WMA dengan cara yang disarankan dokumentasi pandas dan loop event dengan cara yang dibaca dari deskripsi strategi:
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
Mengapa ini lambat? Bukan karena pandas "buruk" — melainkan karena di mana iterasi itu hidup. rolling(period).apply(lambda ...) adalah loop tingkat Python yang mengenakan kostum vektorisasi. Untuk setiap satu dari 150,000 bar, pandas mematerialisasi sebuah window, melintasi batas C/Python, memanggil callable Python, dan membungkus hasilnya. Bahkan dengan raw=True (yang setidaknya memberi lambda sebuah ndarray telanjang alih-alih Series), overhead interpreter per-panggilan jauh melampaui puluhan-hingga-ratusan FLOP yang sebenarnya dibutuhkan window itu. Kalikan dengan tujuh pass WMA per kombo, dan stack indikator saja sudah jutaan round-trip interpreter. Lalu loop bar berjalan lagi 150,000 iterasi terinterpretasi per kombo, masing-masing melakukan indexing bounds-checked pada skalar numpy, membungkus float, dan melakukan dispatch dinamis pada tipe yang ditemukan ulang oleh interpreter setiap kalinya.
Hasilnya: 69.92 s untuk sweep, sekitar 0.87 s per kombo, throughput 1.1 kombo per detik. Pada grid 80-kombo, Anda mengangkat bahu dan menunggu semenit. Masalahnya adalah tidak ada yang menjalankan grid 80-kombo dalam jangka panjang — dan biaya ini terus berskala linear selamanya. Kita akan kembali ke itu.
Anak Tangga M1: numpy — berhenti memanggil Python dalam loop — 3.07 s, 22.7x
Anak tangga pertama menghilangkan kedua loop interpreter sekaligus, dan penting untuk memisahkan kedua trik ini karena keduanya memiliki generalitas yang sangat berbeda.
Sisi indikator adalah yang mudah dan sepenuhnya general. Weighted moving average pada semua window hanyalah perkalian matriks–vektor terhadap strided view dari input — tanpa copy, satu panggilan 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 membangun view (n − p + 1, p) dari memori yang sama, dan win @ w menghitung dot product setiap window dalam kode terkompilasi. Sejuta pemanggilan lambda menjadi satu panggilan library.
Sisi transaksi yang menarik, karena loop event-nya stateful — namun, untuk kernel ini, ia tetap tervektorisasi. Insight-nya adalah posisi pada bar mana pun hanya bergantung pada tanda HMA − HMA3, bukan pada hasil transaksi apa pun. State tidak pernah masuk kembali ke keputusan. Jadi seluruh loop runtuh menjadi "temukan sign flip, kumpulkan harga pada indeks-indeks itu":
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, speedup 22.7x, 26.0 kombo per detik — pada satu core, dengan BLAS dipin ke satu thread. Anak tangga ini pantas mendapat label: ini adalah baseline yang kompeten, implementasi yang akan dikirim oleh programmer numpy yang handal, dan tolok ukur yang adil untuk segala sesuatu di atasnya. Namun dua catatan jujur menyertai anak tangga ini.
Pertama, vektorisasi ini adalah penulisan ulang analitis yang spesifik-strategi, bukan transformasi mekanis. Ia ada karena kernelnya adalah stop-and-reverse tanpa stop, tanpa trailing exit, tanpa position sizing yang bergantung pada PnL berjalan. Tambahkan stop-loss — fitur paling biasa yang bisa dibayangkan — dan exit pada bar mengubah entry mana yang ada pada bar , state masuk kembali ke path, dan bentuk closed-form menguap. Sebagian besar kernel produksi hidup di sisi salah dari garis itu.
Kedua, ini adalah anak tangga di mana kebenaran mati. Pembukuan flip-index (+1 di sini, [:-1] di sana, seeding arah pertama) adalah persis jenis kode yang menghasilkan bug eksekusi off-by-one — spesies bug yang sama yang ditunjukkan taksonomi look-ahead kami bisa memproduksi Sharpe 15 dari noise. Gerbang ekuivalensi bukan formalitas di anak tangga ini; itulah satu-satunya alasan untuk mempercayainya. Penulisan ulang vektorisasi yang cerdas tanpa pemeriksaan ekuivalensi terhadap implementasi referensi yang bodoh adalah cara engine menyimpang dari strategi yang diklaim diujinya.
Anak Tangga M2: numba — kompilasi loop yang sebenarnya ingin Anda tulis — 1.98 s, 35.3x

Anak tangga M2 mengambil filosofi kebalikannya: alih-alih memutar-mutar algoritma agar cocok dengan primitif vektorisasi, tulis loop naif — lalu kompilasi. Numba (Lam, Pitrou & Seibert, 2015) melakukan JIT-compile subset numerik Python melalui LLVM menjadi mesin kode:
@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)
Loop event di dalam nb_sweep secara tekstual adalah loop M0. Cabang, continue, state yang dibawa dalam variabel lokal — semuanya. Di bawah @njit, variabel lokal itu hidup di register, cabang-cabangnya adalah instruksi jump sungguhan, dan biaya per-iterasi turun dari mikrodetik dispatch interpreter menjadi nanodetik.
1.98 s — 35.3x dibanding pandas, tetapi hanya sekitar 1.6x dibanding numpy (turunan: 3.07/1.98). Langkah sederhana itu sendiri bersifat instruktif: loop dalam numpy sudah terkompilasi, sehingga kemenangan numba pada matematika feature terbatas pada melewati materialisasi window dan array antara. Bagian yang transformatif ada di tempat lain:
- Loop event kini gratis — dan "gratis" itu terukur, bukan retoris. M1 menghabiskan kecerdasannya untuk membuat logika transaksi bisa divektorisasi. M2 membuat kecerdasan itu tidak perlu — loop naif, auditable, dan mudah dimodifikasi berjalan pada kecepatan mesin. Mengukur waktu tahap feature secara terpisah dari loop transaksi di dalam kernel terkompilasi ini mengatribusikan 99.3% waktunya ke matematika feature WMA dan hanya 0.7% ke loop event yang stateful. Anda bisa menambahkan stop-loss besok tanpa proyek riset — dan simpan pemisahan itu; ia akan menentukan ulang argumen GPU di bawah.
- Ia membuka dua anak tangga berikutnya. Kernel yang terkompilasi, melepaskan GIL, dan ringan alokasi adalah unit kerja yang dibutuhkan orkestrasi paralel. Anda tidak bisa memparalelkan M0 secara produktif — dua belas salinan yang lambat tetaplah lambat, hanya lebih hangat.
Satu catatan metodologis: numba mengkompilasi pada panggilan pertama, dan kompilasi itu (ratusan milidetik) tidak boleh berada di dalam timer — harness memanaskan JIT pada irisan 500-bar sebelum pengukuran, dan cache=True mempertahankan kernel terkompilasi lintas peluncuran proses. Benchmark yang "lupa" detail ini menghasilkan angka numba yang entah tidak adil buruk (kompilasi dingin ikut terhitung) atau tidak bisa direproduksi.
Anak Tangga M3: prange — paralelisme yang sudah Anda miliki — 0.32 s, 217.6x

Berikut observasi yang membuat pencarian parameter massal istimewa: 80 kombo itu sepenuhnya independen. Tidak ada state bersama, tidak ada urutan, tidak ada komunikasi. Ini adalah pekerjaan yang embarrassingly parallel, yang dijalankan anak tangga M0–M2 pada satu core dari dua belas, murni karena kebiasaan.
Numba membuat perbaikannya nyaris sintaksis — tukar range loop kombo dengan 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
Karena nb_sweep dikompilasi dalam mode nopython, ia tidak memegang GIL, dan lapisan threading numba menyebar iterasi ke seluruh 12 core. Array close yang read-only dibagikan ke semua thread tanpa biaya.
0.32 s — 217.6x dibanding pandas, 248.9 kombo per detik. Langkah dari M2 single-threaded sekitar 6.2x pada 12 core (turunan: 1.98/0.32), dan kekurangan dari "ideal 12x" layak diakui secara jujur alih-alih disembunyikan: 12 core milik M2 Max adalah 8 core performa + 4 core efisiensi, jadi batas nominalnya memang tidak pernah 12x; 80 kombo memiliki biaya yang sangat tidak seragam (HMA panjang-6 jauh lebih murah daripada panjang-200), sehingga thread selesai secara tidak rata; dan setiap panggilan kernel mengalokasikan array antaranya dari allocator bersama. Speedup paralel pada mesin sungguhan terlihat seperti ini. Siapa pun yang mengutip Nx-pada-N-core yang bersih untuk task heterogen sedang mengukur sesuatu yang sintetis.
Anak Tangga M4: process pool untuk sepertiga terakhir — 0.23 s, 297.9x
Anak tangga terakhir mengganti thread dengan proses — kernel terkompilasi yang sama, diorkestrasi oleh 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 dibanding pandas, 340.9 kombo per detik. Baca ulang throughput itu: laptop ini sekarang menjalankan kira-kira 340 backtest 150,000-bar penuh per detik, masing-masing menghitung tujuh weighted moving average dan mensimulasikan puluhan ribu transaksi stateful.
Keunggulan atas prange nyata tapi sederhana — sekitar 1.4x (turunan: 0.32/0.23) — dan mekanisme yang masuk akal adalah penjadwalan dan isolasi memori: dengan chunksize=1 pool membagikan kombo satu per satu, sehingga campuran window murah dan mahal yang tidak rata melakukan load-balancing secara dinamis di seluruh core asimetris, dan setiap proses worker mendapat allocator sendiri, menghindari kontensi pada temporary per-kombo. Kami melaporkan ini sebagai mekanisme yang konsisten dengan pengukuran, bukan sebagai fakta yang dibuktikan secara terpisah.
Proses tidak gratis, dan harness membayar biayanya secara jujur di luar timer di mana biaya itu adalah biaya satu-kali (startup worker, pengiriman close ke setiap worker via initializer, pemanasan JIT per-worker) — karena dalam pencarian sungguhan, biaya ini teramortisasi lebih dari ribuan kombo, bukan delapan puluh. Panduan umum yang jujur: prange lebih sederhana dan biasanya cukup; process pool menang ketika task-nya besar-besar, grid-nya besar, atau pekerjaan per-kombo Anda memegang GIL di tempat yang tidak bisa dijangkau numba.
Dan dengan itu, tangga ini terfaktorkan menjadi ringkasan yang bersih. Dari M0 ke M2 — engine: 35.3x pada satu core, dari memindahkan iterasi keluar dari interpreter. Dari M2 ke M4 — orkestrasi: 8.4x lagi (turunan: 1.98/0.23), dari menggunakan core yang sudah ada. Dikalikan: 298x. Tanpa hardware baru, hasil identik. Dan diukur dari baseline M1 yang kompeten alih-alih baseline naif, engine yang sudah jadi masih berdiri sekitar 13x lebih tinggi (turunan: 3.07/0.23) — tangga ini bukan artefak dari memilih titik awal yang lambat.
Mengapa bukan GPU — versi yang jujur

"Tinggal port saja ke GPU" adalah respons paling umum untuk sweep parameter yang lambat, jadi eksperimen ini mengukur dua angka yang seharusnya menjadi titik awal percakapan itu — dan tidak satu pun mendukung versi malas dari kedua jawaban.
Roofline model (Williams, Waterman & Patterson, 2009) mengklasifikasikan kernel berdasarkan intensitas aritmetika — FLOP per byte yang dipindahkan. Untuk stack feature WMA dalam sweep ini, dengan menghitung FLOP per bar per window sepanjang terhadap satu pembacaan 8-byte per bar, seluruh sweep 80-kombo bekerja pada sekitar 6.2 GFLOP di atas 576 MB yang dialirkan:
(Itu adalah hitungan yang diidealkan atas enam panjang window WMA berbeda per kombo; menghitung tujuh pass seperti yang benar-benar dieksekusi memberi 11.07 FLOP/byte. Kesimpulannya sama saja.)
Angka itu penting karena apa yang ia singkirkan: klaim populer bahwa matematika backtest "memory-bound, jadi GPU tidak bisa membantu" adalah salah di sini. Pada ~10.8 FLOP/byte, matematika feature ini jelas cenderung compute-ish — jauh melewati ridge point di mana hardware tipikal berhenti dibatasi bandwidth. GPU benar-benar bisa mem-batch 80 kombo × 7 pass WMA menjadi segelintir kernel besar dan melahap aritmetika itu. Jika stack feature adalah seluruh masalahnya, kasus GPU akan layak dipertimbangkan.
Angka terukur kedua membunuh jawaban malas lainnya — yang sebenarnya akan kita raih sendiri. Mengukur waktu tahap feature secara terpisah dari loop transaksi di dalam kernel terkompilasi memberikan pembagian 99.3% feature, 0.7% loop event. Argumen yang menggoda — "backtest punya loop event yang stateful dan bercabang, dan itulah yang memblokir GPU" — secara kuantitatif salah di sini: CPU menghabiskan hampir seluruh waktunya persis di bagian yang bisa di-batch oleh GPU. Susun ulang 80 kombo × 7 pass WMA sebagai konvolusi batch besar dan Anda punya beban kerja tensor yang cukup masuk akal. Jadi pertanyaan yang jujur bukanlah apakah pekerjaan ini bisa dipindahkan ke GPU — sebagian besar bisa. Pertanyaannya adalah apakah perjalanan itu terbayar, dan untuk sweep ini jawabannya tidak, karena dua alasan spesifik:
1. Lebar yang bisa dieksploitasi adalah 80 kombo — dan GPU adalah mesin lebar. Satu-satunya sumbu paralelisme yang jujur dalam sweep parameter adalah grid itu sendiri: di dalam satu kombo, path 150,000-bar bersifat sekuensial. GPU menginginkan puluhan ribu item kerja independen untuk mengisi jalurnya dan menyembunyikan latensi; sweep ini menawarkan delapan puluh. Dua belas core CPU sudah menjenuhkan lebar itu — itulah persis yang diukur anak tangga M3–M4. Untuk jumlah kombo di mana lebar GPU baru mulai terasa, tangga CPU sudah menghasilkan ratusan backtest penuh per detik.
2. Seluruh pekerjaan hanya 0.23 detik. Pada kecepatan M4, satu kombo berbiaya sekitar 2.9 ms (turunan: 0.23 s / 80). Terhadap anggaran itu, latensi peluncuran kernel dan titik sinkronisasi device bukanlah kesalahan pembulatan yang bisa diamortisasi — mereka adalah fraksi material dari pekerjaan. (Pada mesin Apple unified-memory ini, transfer host-to-device adalah masalah minor; pada mesin CUDA GPU diskrit, itu juga masuk tagihan.) Kemenangan GPU klasik mengamortisasi overhead tetap di atas batch pekerjaan yang sangat besar; sweep sub-detik tidak pernah menghasilkan itu.
Dan loop event-nya? Itulah bagian yang tidak akan di-batch — sekuensial, bercabang, path-dependent, dependensi yang dibawa-loop sepanjang 150,000 bar yang tidak bisa diparalelkan oleh hardware apa pun di dalam satu kombo, dengan persis cabang divergen yang dibenci lane SIMT. Port GPU akan meninggalkannya di CPU atau menjalankannya satu lane per kombo. Tapi pada 0.7% dari kernel, itu adalah term Amdahl yang terlalu kecil untuk menentukan apa pun. Itulah bagian yang tidak akan pergi; bukan alasan untuk tidak pergi. (Ingat dari anak tangga M1 bahwa untuk kernel bebas-umpan-balik, loop bahkan bisa divektorisasi secara analitis — penulisan ulang yang hilang begitu strategi tumbuh sebuah stop.)
Satu catatan kaki platform untuk kelengkapan: pada mesin ini (Apple Silicon), jalur GPU akan berupa MLX atau PyTorch-MPS, bukan CUDA — cupy dan ekosistem CUDA sama sekali tidak berlaku — dan keduanya akan membutuhkan penulisan ulang hot path dalam dialek tensor hanya untuk mencoba eksperimen ini. Itu adalah biaya sungguhan tanpa, berdasarkan analisis di atas, imbalan yang teridentifikasi untuk bentuk sweep ini. Diskusi GPU di sini bersifat analitis, berlandaskan intensitas aritmetika terukur dan pembagian feature/loop terukur, dan kami memberi label demikian: tidak ada run CUDA yang dijalankan karena tidak ada yang mungkin dilakukan pada hardware yang diungkapkan.
Kalimat ringkasan yang akan kami bela dalam review: hampir seluruh pekerjaan ini bisa dipindahkan ke GPU; sweep ini terlalu sempit dan terlalu singkat agar perjalanan itu terbayar. Dan baca itu dalam kedua arah — ini bukan penolakan total. Reformulasi "matriks-besar" yang di-batch — menyusun ulang sweep sebagai operasi tensor besar di ribuan kombo sekaligus, atau kernel bebas-umpan-balik yang benar-benar di-batch dari ujung ke ujung — adalah arah sungguhan dan menjanjikan yang layak mendapat studi khusus, bukan penolakan. Pada 80 kombo dan 0.23 detik, ia hanya belum mendapatkan tiketnya. Jika beban kerja Anda memiliki lebar itu, aritmetikanya berubah, dan Anda harus mengulanginya sendiri, bukan mengutip kami.
Di mana bottleneck sebenarnya: engine dan orkestrasi

Delapan puluh kombo adalah grid demonstrasi. Pencarian parameter sungguhan adalah tempat faktor-faktor ini berhenti menjadi akademis, karena grid tumbuh secara multiplikatif: empat parameter dengan masing-masing sepuluh nilai adalah kombo; tambahkan validasi walk-forward dengan selusin fold dan Anda sudah pada backtest penuh sebelum Anda menjelajahi apa pun. Ini adalah kutukan dimensionalitas, dan inilah mengapa strategi pencarian — Optuna, coordinate descent, Sobol — mendapat begitu banyak perhatian: pencarian yang lebih cerdas mengunjungi lebih sedikit titik.
Tapi tangga ini mengungkap separuh lain dari persamaan yang kurang dibahas: biaya per titik yang dikunjungi. Mengekstrapolasi throughput terukur secara linear (kombo bersifat independen, jadi ini aritmetika, bukan pemodelan):
| Ukuran Grid | Pada M0 (1.1 kombo/dtk) | Pada M4 (340.9 kombo/dtk) |
|---|---|---|
| 10,000 kombo | ~2.4 jam | ~30 detik |
| 100,000 kombo | ~24 jam | ~5 menit |
Eksperimen yang sama yang merupakan batch job semalaman pada engine naif adalah query interaktif pada engine yang sudah dituning. Selisih itu berlipat ganda dengan cara yang tidak tercermin dalam tabel wall-clock: pada 5 menit per sweep, Anda beriterasi — Anda menjalankan ulang dengan kebocoran yang diperbaiki, Anda menambahkan satu fold, Anda memperlebar grid, Anda menguji ide yang muncul saat makan siang. Pada 24 jam per sweep, Anda tidak melakukannya. Kecepatan engine menentukan tempo loop riset, dan tempo loop riset adalah produk sesungguhnya.
Ada juga pembacaan hukum Amdahl atas seluruh tangga ini:
Mempercepat tahap mana pun dengan faktor dibatasi oleh segala hal lain yang Anda biarkan lambat. Tangga ini menghormati urutan itu: kenaikan engine 35.3x menyerang term yang mendominasi (iterasi terinterpretasi, baik di stack feature maupun loop-nya), dan kenaikan orkestrasi 8.4x menyerang term yang mendominasi setelahnya (sebelas core yang menganggur). Pembagian feature/loop adalah pelajaran yang sama dalam miniatur — kita tidak akan bisa menamai bentuk sebenarnya dari argumen GPU tanpa mengukur ke mana waktu itu sebenarnya pergi. Profil dulu, baru optimasi — dalam urutan itu. Logika yang sama mengatur lapisan data di hulu engine: benchmark Polars vs pandas kami menemukan pola identik (10–3500x pada pipeline rolling yang dikelompokkan) untuk separuh load-and-transform dari stack, dan kesimpulan hibrida yang sama — engine kolomar untuk pipeline, kernel terkompilasi untuk simulasi path-dependent.
Dua catatan kejujuran untuk menutup lingkaran generalitas ini. Pertama, eksperimen ini sengaja dibuat mandiri dan sintetis — data yang di-seed, satu kernel, satu mesin yang diungkapkan — sehingga siapa pun bisa mereproduksi fenomena ini secara deterministik; angka wall-clock akan berbeda pada hardware Anda, tetapi ekuivalensi dan arah tangga ini tidak. Kedua, fenomena ini bukan artefak dari setup sintetis: benchmark engine HMA produksi kami (bench_param_sweep.py, dijalankan pada data exchange sungguhan dengan model fee dan fill produksi penuh) menunjukkan bentuk tangga yang sama, dengan jalur numba mendarat kira-kira 100–200x di atas profil pandas naif. Eksperimen mandiri ini ada agar Anda tidak perlu mempercayai angka produksi kami begitu saja.
Poin-poin Kunci
- Tangga ini adalah 298x, dan ia terfaktorkan: 35.3x engine × 8.4x orkestrasi. Memindahkan iterasi keluar dari interpreter (pandas → numba) dan menyebar kombo independen lintas core (satu → dua belas) berlipat menjadi speedup sekitar tiga orde-magnitudo pada laptop yang tidak berubah. 69.92 s → 0.23 s; 1.1 → 340.9 kombo/dtk. Dan ini bukan artefak baseline lambat: dibandingkan implementasi numpy vektorisasi yang kompeten, engine yang sudah jadi masih ~13x.
- Tuntut ekuivalensi sebelum mengagumi kecepatan. Setiap anak tangga di sini menghasilkan PnL dan jumlah transaksi per-kombo yang identik, digerbangi secara otomatis pada seluruh 80 kombo (toleransi absolut pada PnL, persis pada transaksi). Engine cepat yang menghitung sesuatu yang sedikit berbeda bukanlah cepat — itu salah pada throughput tinggi, dan penulisan ulang vektorisasi adalah tempat kesalahan itu biasanya menyelinap masuk.
@njitmengalahkan vektorisasi cerdas untuk logika stateful. Anak tangga numpy membutuhkan bentuk closed-form spesifik-strategi yang mati begitu Anda menambahkan stop-loss. Anak tangga numba mengkompilasi loop naif yang auditable — kelas kecepatan yang sama, tanpa kerapuhan itu, dan inilah unit yang bisa diparalelkan.- Jawaban GPU adalah "bukan untuk sweep ini" — dengan alasan yang seharusnya bisa Anda sebutkan. Matematika feature bersifat compute-ish (10.78 FLOP/byte) dan itu adalah 99.3% dari kernel terkompilasi, sehingga baik "backtest itu memory-bound" maupun "loop stateful yang mendominasi" tidak bertahan dari pengukuran. Alasan jujurnya adalah lebar dan anggaran: 80 kombo paralelisme yang bisa dieksploitasi yang sudah dijenuhkan oleh 12 core CPU, dan pekerjaan total 0.23 detik yang akan dilahap oleh overhead peluncuran dan sinkronisasi. Reformulasi matriks-besar yang di-batch pada lebar sungguhan tetap merupakan arah yang menjanjikan, bukan arah yang terbantahkan.
- Kecepatan engine adalah tempo riset. Pada throughput engine naif, pencarian 100,000-backtest adalah satu hari; pada throughput puncak tangga ini, itu lima menit. Sebelum membeli hardware atau menyewa cluster, periksa apakah bottleneck Anda benar-benar silikon. Bottleneck kami adalah sebuah
lambdadi dalamrolling.applydan sebelas core yang menganggur.
Eksperimen lengkapnya — kelima implementasi, harness ekuivalensi, komputasi roofline, dan setiap angka dalam artikel ini yang bisa diregenerasi dari satu skrip deterministik — ada di paper pendamping di speed-ladder.marketmaker.cc, dengan kode dan data di github.com/suenot/backtest-speed-ladder.
Sweep yang membutuhkan tujuh puluh detik kini membutuhkan seperempat dari satu detik. Transaksi yang sama, PnL yang sama, laptop yang sama. GPU yang hampir Anda ajukan permintaannya bisa menunggu; loop interpreter yang hampir Anda kirim ke produksi tidak bisa.
Penulis
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.