← Kembali ke artikel
July 1, 2026
Bacaan 5 minit

Tangga Kelajuan Enjin Backtest: 298x pada CPU Laptop, PnL Identik hingga Dagangan Terakhir

Tangga Kelajuan Enjin Backtest: 298x pada CPU Laptop, PnL Identik hingga Dagangan Terakhir
#algotrading
#backtest
#prestasi
#numba
#vektorisasi
#pengoptimuman

Sebahagian daripada siri "Backtest Tanpa Ilusi".

📄 Artikel ini berkembang menjadi kertas kajian. Satu kernel backtest yang bergantung pada laluan dilaksanakan dalam lima cara — daripada pandas naif hingga kernel numba selari — dengan setiap anak tangga disemak silang untuk menghasilkan PnL per-kombo yang identik, jadi satu-satunya perkara yang berbeza adalah kelajuan. Baca kertas kajian dalam talian (versi interaktif + PDF) di speed-ladder.marketmaker.cc, kod dan data di github.com/suenot/backtest-speed-ladder.

Tujuh puluh saat. Itulah masa yang diambil oleh pelaksanaan rujukan naif untuk menyapu 80 kombinasi parameter bagi satu strategi purata bergerak merentasi 150,000 bar: pandas rolling().apply() untuk penunjuk, gelung Python biasa untuk dagangan. Ini adalah profil yang dijalankan oleh sebahagian besar kod penyelidikan dunia sebenar, kerana ia adalah profil yang terhasil apabila strategi ditulis dengan cara yang paling jelas.

Sapuan yang sama, pada laptop yang sama, menghasilkan PnL yang sama untuk setiap kombinasi hingga ke dagangan terakhir: 0.23 saat.

Jurang antara dua nombor itu — terukur 298x — adalah subjek artikel ini. Tiada satu peratus pun daripadanya datang daripada perkakasan baharu. Tiada GPU terlibat (tiada pun tersedia pada mesin ini dalam erti CUDA). Setiap anak tangga adalah strategi yang sama, data yang sama, yuran yang sama, bilangan dagangan yang sama, disahkan oleh get kesetaraan yang menggagalkan keseluruhan penanda aras jika keputusan per-kombo mana-mana pelaksanaan menyimpang. Apa yang berubah hanyalah cara kerja itu dinyatakan: apa yang berjalan dalam jurubahasa, apa yang berjalan disusun (compiled), dan apa yang berjalan secara selari. Dan kerana garis dasar yang sengaja perlahan boleh mengelabui mana-mana nombor tajuk, satu lagi angka di hadapan: walaupun berbanding pelaksanaan numpy vektor yang cekap — kod yang akan dihantar oleh pengaturcara numpy yang mahir — enjin siap masih kira-kira 13x lebih pantas.

Apabila carian parameter perlahan, refleks biasa adalah mencari perkakasan yang lebih besar — GPU, kluster, bajet awan. Realiti terukur eksperimen ini menunjuk ke arah yang jauh kurang glamor: kesesakan adalah enjin (gelung dalaman yang ditafsir melakukan panggilan Python per-tetingkap) dan orkestrasi (menjalankan kombo bebas secara berurutan pada satu teras). Kedua-duanya boleh dibetulkan dalam satu petang, pada mesin yang anda sudah miliki, tanpa sebarang perubahan pada keputusan.

Berikut adalah keseluruhan tangga di hadapan. Segala-galanya di bawah adalah anatomi setiap langkah.

Anak Tangga Pelaksanaan Masa dinding Kelajuan Kombo/s
M0 pandas: rolling.apply + gelung bar Python 69.92 s 1.0x 1.1
M1 numpy: WMA tetingkap gelongsor + dagangan vektor 3.07 s 22.7x 26.0
M2 numba: @njit WMA + gelung peristiwa @njit 1.98 s 35.3x 40.4
M3 numba prange: bebenang merentasi kombo 0.32 s 217.6x 248.9
M4 kumpulan proses + numba: proses merentasi kombo 0.23 s 297.9x 340.9

Apple M2 Max (12 teras), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) dipaku kepada satu bebenang supaya anak tangga bebenang tunggal benar-benar satu teras. 150,000 bar × 80 kombo, masa dinding terbaik-daripada-3, pemanasan JIT dikecualikan. Semua anak tangga — termasuk garis dasar pandas — dimasa sepenuhnya dan disahkan menghasilkan PnL per-kombo dan bilangan dagangan yang identik pada kesemua 80 kombo.

Satu kernel, lima pelaksanaan

Lima anak tangga satu tangga: kernel backtest yang sama menaiki daripada garis dasar pandas 70 saat kepada larian numba selari 0.23 saat, setiap langkah disahkan menghasilkan PnL identik

Untuk menjadikan perbandingan kelajuan bermakna, perkara yang dikira mesti ditentukan dengan tepat, dan setiap pelaksanaan mesti dibuktikan mengiranya. Jadi eksperimen ini menetapkan satu kernel strategi dan mengekalkannya malar merentasi kesemua lima anak tangga.

Kernel itu adalah silang HMA/HMA3 — sistem stop-and-reverse pada dua purata bergerak gaya Hull. Blok binaan adalah purata bergerak berpemberat:

WMAp(x)i=j=1pjxip+jj=1pj\mathrm{WMA}_p(x)_i = \frac{\sum_{j=1}^{p} j \cdot x_{i-p+j}}{\sum_{j=1}^{p} j}

Hull Moving Average menggubah tiga daripadanya untuk memotong lag:

HMAn(x)=WMAn(2WMAn/2(x)WMAn(x))\mathrm{HMA}_n(x) = \mathrm{WMA}_{\lfloor\sqrt{n}\rceil}\Big(2\,\mathrm{WMA}_{\lfloor n/2\rceil}(x) - \mathrm{WMA}_{n}(x)\Big)

dan HMA3 adalah saudara yang lebih licin dibina daripada WMA pada kira-kira n/6n/6, n/4n/4 dan n/2n/2, dilicinkan sekali lagi. Bagi setiap kombinasi parameter itu adalah tujuh laluan WMA merentasi enam panjang tetingkap berbeza — timbunan penunjuk sebenar, bukan mainan.

Peraturan dagangan sengaja bersifat stateful (berstatus) dengan cara yang berguna: arah adalah long apabila HMA berada di bawah HMA3 dan short sebaliknya; buka kedudukan pada arah pertama yang ditakrifkan; pada setiap silang, tutup kedudukan, catat PnL tolak yuran pusing-pergi 0.09%, dan berbalik. Kedudukan itu berterusan merentasi bar — apa yang anda lakukan pada bar ii bergantung pada status terkumpul sejak silang terakhir. Kebergantungan laluan ini adalah keseluruhan inti eksperimen: ini adalah sifat yang menjadikan backtest berbeza daripada saluran paip dataframe generik, dan (seperti yang akan kita ukur) ia merumitkan soalan GPU — walaupun tidak, seperti yang ternyata, dengan cara yang didakwa oleh cerita rakyat.

Selebihnya persediaan, supaya anda boleh menilai nombor-nombor itu:

  • Data: 150,000 bar pergerakan Brownian geometri sintetik, disemai (seed=42). Prestasi di sini terikat kepada saiz tatasusunan dan panjang tetingkap, bukan laluan harga mana yang anda berikan — dan siri sintetik menjadikan keseluruhan eksperimen deterministik dan boleh diulang oleh sesiapa sahaja.
  • Grid: 80 panjang HMA berbeza tersebar merentasi [6,200][6, 200] — jadi sapuan itu mengandungi kedua-dua kombo tetingkap pendek yang murah dan kombo tetingkap panjang yang mahal, seperti grid sebenar.
  • Pemasaan: wall-clock, terbaik-daripada-3 setiap anak tangga, dengan penyusunan JIT dipanaskan di luar pemasa dan pekerja kumpulan dipanaskan sebelum jam bermula. Setiap anak tangga — termasuk garis dasar pandas — dimasa sepenuhnya merentasi kesemua 80 kombo. BLAS (Accelerate Apple) dipaku kepada satu bebenang, jadi anak tangga bebenang tunggal benar-benar satu teras: anak tangga numpy tidak diam-diam menggunakan pelbagai bebenang untuk matvec-nya di sebalik perbandingan.
  • Get kesetaraan: selepas pemasaan, setiap vektor (PnL, bilangan dagangan) per-kombo pada setiap anak tangga dibandingkan dengan rujukan — bilangan dagangan mesti sepadan tepat, PnL kepada dalam toleransi mutlak 10610^{-6} mata peratusan. Larian yang dikomit melaporkan all_ok: true untuk setiap anak tangga, termasuk garis dasar pandas, pada kesemua 80 kombo. Jika get ini gagal, tiada penanda aras — hanya lima program mengira lima perkara berbeza pada lima kelajuan berbeza, yang merupakan cara banyak dakwaan "enjin kami 100x lebih pantas" diam-diam berfungsi.

Satu nombor daripada blok kesetaraan patut diberi perhatian sejenak dengan jujur: cap jari bagi kombo pertama adalah PnL −5165.58 mata peratusan merentasi 57,029 dagangan. Itu bukan keputusan strategi yang perlu dimalukan — itu adalah panjang HMA yang terpendek (6) berbalik pada hampir setiap gegaran gelombang rawak dan membayar 0.09% setiap kali, tepat seperti sepatutnya. Ia adalah cap jari ketepatan, bukan backtest yang boleh didagangkan. Jangan baca alfa ke dalamnya; baca determinisme ke dalamnya — lima pelaksanaan mendarat pada 57,029 dagangan yang sama dan PnL yang sama hingga enam titik perpuluhan adalah apa maksud "identik" di sini.

Dengan itu ditetapkan, setiap peningkatan kelajuan di bawah adalah kelajuan tulen. Tiada apa yang dihampiri secara kasar.

Anak Tangga M0: profil pandas naif — 69.9 s

Anatomi garis dasar pandas naif: tetingkap rolling.apply melahirkan panggilan lambda Python untuk setiap satu daripada 150,000 bar manakala gelung jurubahasa merangkak di bawahnya

Garis dasar ini bukan strawman. Ia adalah kod yang anda perolehi apabila anda menulis WMA seperti yang dicadangkan oleh dokumentasi pandas dan gelung peristiwa seperti yang dibaca dalam penerangan 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 perlahan? Bukan kerana pandas itu "buruk" — kerana di mana lelaran itu tinggal. rolling(period).apply(lambda ...) adalah gelung peringkat Python yang memakai kostum vektor. Bagi setiap satu daripada 150,000 bar, pandas mewujudkan tetingkap, melintasi sempadan C/Python, memanggil callable Python, dan mengotak keputusan. Walaupun dengan raw=True (yang sekurang-kurangnya menyerahkan ndarray telanjang kepada lambda dan bukannya Series), overhead jurubahasa per-panggilan mengatasi jauh ~puluhan-hingga-ratusan FLOP yang sebenarnya diperlukan oleh tetingkap itu. Darabkan dengan tujuh laluan WMA per kombo, dan timbunan penunjuk sahaja adalah berjuta-juta pergi-balik jurubahasa. Kemudian gelung bar berjalan lagi 150,000 lelaran ditafsir per kombo, setiap satu melakukan pengindeksan diperiksa-sempadan pada skalar numpy, mengotak float, dan menghantar secara dinamik pada jenis yang ditemui semula oleh jurubahasa setiap kali.

Hasilnya: 69.92 s untuk sapuan itu, kira-kira 0.87 s setiap kombo, daya pemprosesan 1.1 kombo sesaat. Pada grid 80-kombo anda mengangkat bahu dan menunggu seminit. Masalahnya ialah tiada siapa menjalankan grid 80-kombo untuk lama — dan kos ini berskala secara linear selama-lamanya. Kita akan kembali kepada itu.

Anak Tangga M1: numpy — berhenti memanggil Python dalam gelung — 3.07 s, 22.7x

Anak tangga pertama ke atas menghapuskan kedua-dua gelung jurubahasa sekaligus, dan patut memisahkan kedua-dua muslihat itu kerana ia mempunyai keumuman yang sangat berbeza.

Sisi penunjuk adalah yang mudah, sepenuhnya umum. Purata bergerak berpemberat merentasi semua tetingkap hanyalah hasil darab matriks–vektor terhadap pandangan berjalur input — tiada salinan, 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 membina pandangan (n − p + 1, p) bagi memori yang sama, dan win @ w mengira hasil darab titik setiap tetingkap dalam kod tersusun. Berjuta-juta panggilan lambda menjadi satu panggilan pustaka.

Sisi dagangan adalah yang menarik, kerana gelung peristiwa itu berstatus — namun begitu, untuk kernel ini, ia berjaya divektorkan. Wawasannya ialah kedudukan pada mana-mana bar hanya bergantung pada tanda HMA − HMA3, bukan pada mana-mana hasil dagangan. Status tidak pernah memberi maklum balas kepada keputusan. Jadi keseluruhan gelung itu runtuh menjadi "cari terbalikan tanda, kumpulkan harga pada indeks tersebut":

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, kelajuan 22.7x, 26.0 kombo sesaat — pada satu teras, dengan BLAS dipaku kepada satu bebenang. Anak tangga ini layak diberi label: ia adalah garis dasar cekap, pelaksanaan yang akan dihantar oleh pengaturcara numpy yang mahir, dan penanda adil bagi segala-galanya di atasnya. Tetapi dua amaran jujur mengiringi anak tangga ini.

Pertama, vektorisasi ini adalah penulisan semula analitikal khusus-strategi, bukan transformasi mekanikal. Ia wujud kerana kernel itu adalah stop-and-reverse tanpa stop, tanpa keluar trailing, tanpa saiz kedudukan yang bergantung pada PnL berjalan. Tambah stop-loss — ciri paling biasa yang boleh dibayangkan — dan keluar pada bar ii mengubah kemasukan mana yang wujud pada bar j>ij > i, status memberi maklum balas kepada laluan, dan bentuk tertutup itu wap. Kebanyakan kernel produksi hidup di sisi yang salah bagi garis itu.

Kedua, ini adalah anak tangga di mana ketepatan pergi mati. Kerja-buku indeks terbalikan (+1 di sini, [:-1] di sana, penyemaian arah-pertama) adalah tepat jenis kod yang menghasilkan pepijat pelaksanaan off-by-one — spesies pepijat yang sama yang ditunjukkan oleh taksonomi look-ahead kami boleh menghasilkan Sharpe 15 daripada bunyi hingar. Get kesetaraan bukan formaliti pada anak tangga ini; ia adalah satu-satunya sebab untuk mempercayainya. Penulisan semula vektor yang bijak tanpa pemeriksaan kesetaraan terhadap pelaksanaan rujukan yang bodoh adalah bagaimana enjin hanyut daripada strategi yang mereka dakwa uji.

Anak Tangga M2: numba — susun gelung yang sebenarnya anda mahu tulis — 1.98 s, 35.3x

Gelung peristiwa Python melalui penyusun JIT numba dan muncul sebagai kod mesin ketat: logik bar-demi-bar bercabang yang sama, disusun dan bukan ditafsir

Anak tangga M2 mengambil falsafah bertentangan: bukannya memutar-belit algoritma untuk sesuai dengan primitif vektor, tulis gelung naif — dan susun ia. Numba (Lam, Pitrou & Seibert, 2015) menyusun-JIT subset berangka Python melalui LLVM ke dalam kod mesin:

@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)

Gelung peristiwa di dalam nb_sweep adalah secara teks gelung M0. Cabang, continue, status disimpan dalam pembolehubah tempatan — kesemuanya. Di bawah @njit, pembolehubah tempatan itu tinggal dalam daftar, cabang adalah arahan lompat sebenar, dan kos per-lelaran turun daripada mikrosaat penghantaran jurubahasa kepada nanosaat.

1.98 s — 35.3x berbanding pandas, tetapi hanya kira-kira 1.6x berbanding numpy (terbitan: 3.07/1.98). Langkah sederhana itu sendiri bersifat mendidik: gelung dalaman numpy sudah pun disusun, jadi kemenangan numba pada matematik ciri terhad kepada melangkau pewujudan tetingkap dan tatasusunan perantaraan. Bahagian yang transformatif ada di tempat lain:

  1. Gelung peristiwa itu percuma sekarang — dan "percuma" adalah terukur, bukan retorik. M1 menghabiskan kebijaksanaannya menjadikan logik dagangan boleh divektorkan. M2 menjadikan kebijaksanaan itu tidak perlu — gelung naif, boleh diaudit, mudah diubah suai berjalan pada kelajuan mesin. Memasa peringkat ciri secara berasingan daripada gelung dagangan di dalam kernel yang disusun ini memperuntukkan 99.3% masanya kepada matematik ciri WMA dan hanya 0.7% kepada gelung peristiwa berstatus. Anda boleh menambah stop-loss esok tanpa projek penyelidikan — dan simpan pembahagian itu; ia menentukan semula hujah GPU di bawah.
  2. Ia membuka kunci dua anak tangga seterusnya. Kernel yang disusun, melepaskan GIL, ringan peruntukan adalah unit kerja yang diperlukan oleh orkestrasi selari. Anda tidak boleh secara produktif menyelaraskan M0 — dua belas salinan perlahan tetap perlahan, cuma lebih panas.

Satu nota metodologi: numba menyusun pada panggilan pertama, dan penyusunan itu (ratusan milisaat) mesti tidak berada dalam pemasa — harness memanaskan JIT pada hirisan 500-bar sebelum mengukur, dan cache=True mengekalkan kernel yang disusun merentasi pelancaran proses. Penanda aras yang "lupa" perincian ini menghasilkan nombor numba yang sama ada tidak adil buruk (penyusunan sejuk termasuk) atau tidak boleh diulang.

Anak Tangga M3: prange — keselarian yang sudah anda miliki — 0.32 s, 217.6x

Lapan puluh kombo parameter bebas dikipaskan merentasi dua belas teras CPU: teras prestasi dan kecekapan menarik panjang tetingkap yang tidak sama secara selari

Berikut pemerhatian yang menjadikan carian parameter besar-besaran istimewa: 80 kombo itu sepenuhnya bebas. Tiada status kongsi, tiada susunan, tiada komunikasi. Ini adalah kerja selari-secara-memalukan yang dijalankan oleh anak tangga M0–M2 pada satu teras daripada dua belas, semata-mata kerana kebiasaan.

Numba menjadikan pembetulan itu hampir sintaksis — tukar range gelung kombo kepada 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

Kerana nb_sweep disusun nopython, ia tidak memegang GIL, dan lapisan pembebenangan numba mengipaskan lelaran merentasi kesemua 12 teras. Tatasusunan close baca-sahaja dikongsi oleh semua bebenang pada kos sifar.

0.32 s — 217.6x berbanding pandas, 248.9 kombo sesaat. Langkah berbanding M2 bebenang-tunggal adalah kira-kira 6.2x pada 12 teras (terbitan: 1.98/0.32), dan kekurangan daripada "12x unggul" patut diakui secara jujur dan bukan disembunyikan: 12 teras M2 Max adalah 8 teras prestasi + 4 teras kecekapan, jadi siling nominal tidak pernah 12x; 80 kombo itu mempunyai kos yang sangat tidak sama (HMA panjang-6 jauh lebih murah daripada yang panjang-200), jadi bebenang selesai secara tidak sekata; dan setiap panggilan kernel memperuntukkan tatasusunan perantaraannya daripada peruntuk yang dikongsi. Kelajuan selari pada mesin sebenar kelihatan seperti ini. Sesiapa yang menyebut Nx-pada-N-teras yang bersih untuk tugas heterogen sedang mengukur sesuatu yang sintetik.

Anak Tangga M4: kumpulan proses untuk sepertiga terakhir — 0.23 s, 297.9x

Anak tangga terakhir menggantikan bebenang dengan proses — kernel disusun 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 berbanding pandas, 340.9 kombo sesaat. Baca daya pemprosesan itu sekali lagi: laptop ini kini menjalankan kira-kira 340 backtest 150,000-bar penuh sesaat, setiap satu mengira tujuh purata bergerak berpemberat dan mensimulasikan puluhan ribu dagangan berstatus.

Kelebihan berbanding prange adalah nyata tetapi sederhana — kira-kira 1.4x (terbitan: 0.32/0.23) — dan mekanik yang munasabah adalah penjadualan dan pengasingan memori: dengan chunksize=1 kumpulan itu mengagihkan kombo satu demi satu, jadi campuran tidak sekata tetingkap murah dan mahal mengimbangi beban secara dinamik merentasi teras asimetri, dan setiap proses pekerja mendapat peruntuknya sendiri, mengelakkan pertikaian pada sementara per-kombo. Kami melaporkan ini sebagai mekanik yang konsisten dengan pengukuran, bukan sebagai fakta yang dibuktikan secara berasingan.

Proses tidak percuma, dan harness membayar kosnya secara jujur di luar pemasa di mana ia adalah kos sekali sahaja (permulaan pekerja, penghantaran close kepada setiap pekerja melalui initializer, pemanasan JIT per-pekerja) — kerana dalam carian sebenar kos itu diamortisasi merentasi beribu-ribu kombo, bukan lapan puluh. Panduan umum yang jujur: prange lebih mudah dan biasanya mencukupi; kumpulan proses menang apabila tugas bersaiz besar, grid besar, atau kerja per-kombo anda memegang GIL di suatu tempat yang tidak dapat dicapai oleh numba.

Dan dengan itu, tangga itu terurai menjadi ringkasan yang bersih. Daripada M0 kepada M2 — enjin: 35.3x pada satu teras, daripada memindahkan lelaran keluar daripada jurubahasa. Daripada M2 kepada M4 — orkestrasi: satu lagi 8.4x (terbitan: 1.98/0.23), daripada menggunakan teras yang sudah pun ada. Didarab: 298x. Tiada perkakasan baharu, keputusan identik. Dan diukur daripada garis dasar M1 yang cekap dan bukan yang naif, enjin siap masih berdiri kira-kira 13x lebih tinggi (terbitan: 3.07/0.23) — tangga ini bukan artifak memilih titik permulaan yang perlahan.

Mengapa Bukan GPU — Versi Jujur

GPU duduk terbiar di sebelah CPU tepu: matematik purata bergerak yang boleh dikelompokkan ditinggalkan pada CPU kerana sapuan lapan puluh kombo dan suku saat terlalu sempit dan terlalu pendek untuk membayar perjalanan itu

"Cuma port ke GPU" adalah respons paling biasa kepada sapuan parameter yang perlahan, jadi eksperimen ini mengukur dua nombor yang patut memulakan perbualan itu — dan tiada satu pun menyokong versi malas mana-mana jawapan.

Model roofline (Williams, Waterman & Patterson, 2009) mengklasifikasikan kernel mengikut keamatan aritmetik — FLOP setiap byte dipindahkan. Untuk timbunan ciri WMA dalam sapuan ini, mengira 2p2p FLOP per bar per tetingkap panjang pp berbanding satu bacaan 8-byte per bar, keseluruhan sapuan 80-kombo terhasil kira-kira 6.2 GFLOP merentasi 576 MB yang dialirkan:

I=6.21×109 FLOP5.76×108 bytes10.78 FLOPbyteI = \frac{6.21 \times 10^9\ \text{FLOP}}{5.76 \times 10^8\ \text{bytes}} \approx 10.78\ \frac{\text{FLOP}}{\text{byte}}

(Itu adalah kiraan diidealkan merentasi enam tetingkap WMA berbeza per kombo; mengira tujuh laluan seperti yang sebenarnya dilaksanakan memberikan 11.07 FLOP/byte. Kesimpulan sama sahaja.)

Nombor itu penting kerana apa yang ia singkirkan: dakwaan popular bahawa matematik backtest adalah "terikat memori, jadi GPU tidak boleh membantu" adalah palsu di sini. Pada ~10.8 FLOP/byte matematik ciri itu jelas cenderung-pengiraan — jauh melepasi titik rabung di mana perkakasan biasa berhenti terhad lebar jalur. GPU benar-benar boleh mengelompokkan 80 kombo × 7 laluan WMA menjadi beberapa kernel besar dan mengunyah aritmetik itu. Jika timbunan ciri itu adalah keseluruhan masalah, kes GPU akan terhormat.

Nombor kedua yang terukur membunuh jawapan malas yang lain — yang akan kami capai sendiri. Memasa peringkat ciri secara berasingan daripada gelung dagangan di dalam kernel yang disusun memberikan pembahagian 99.3% ciri, 0.7% gelung peristiwa. Hujah yang menggoda — "backtest mempunyai gelung peristiwa berstatus, bercabang, dan itulah yang menyekat GPU" — adalah salah secara kuantitatif di sini: CPU menghabiskan hampir semua masanya tepat pada bahagian yang GPU boleh kelompokkan. Susun semula 80 kombo × 7 laluan WMA sebagai konvolusi berkelompok besar dan anda mempunyai beban kerja tensor yang munasabah. Jadi soalan jujur bukan sama ada kerja itu boleh pergi ke GPU — kebanyakannya boleh. Soalannya ialah sama ada perjalanan itu berbaloi, dan untuk sapuan ini ia tidak, atas dua sebab khusus:

1. Lebar yang boleh dieksploitasi adalah 80 kombo — dan GPU adalah mesin lebar. Satu paksi selari yang jujur dalam sapuan parameter adalah grid itu sendiri: dalam satu kombo, laluan 150,000-bar adalah berurutan. GPU mahukan puluhan ribu item kerja bebas untuk mengisi lorongnya dan menyembunyikan latensi; sapuan ini menawarkan lapan puluh. Dua belas teras CPU sudah pun menepu lebar itu — itulah yang secara literal diukur oleh anak tangga M3–M4. Bagi bilangan kombo di mana lebar GPU baru mula terlibat, tangga CPU sudah pun menghantar beratus-ratus backtest penuh sesaat.

2. Keseluruhan kerja adalah 0.23 saat. Pada kelajuan M4, satu kombo berkos kira-kira 2.9 ms (terbitan: 0.23 s / 80). Berbanding bajet itu, latensi pelancaran kernel dan titik penyegerakan peranti bukan ralat pembundaran yang boleh diamortisasi — ia adalah pecahan bahan daripada kerja itu. (Pada mesin Apple memori bersatu ini, pemindahan hos-ke-peranti adalah kebimbangan kecil; pada kotak CUDA GPU diskret ia turut menyertai bil.) Kemenangan GPU klasik mengamortisasi overhead tetap merentasi kumpulan kerja yang besar; sapuan sub-saat tidak pernah menghasilkan satu.

Dan gelung peristiwa itu? Ia adalah satu-satunya bahagian yang tidak akan dikelompokkan — berurutan, bercabang, bergantung laluan, kebergantungan dibawa-gelung sepanjang 150,000 bar yang tiada perkakasan boleh selarikan dalam satu kombo, dengan tepat cabang divergen yang dibenci lorong SIMT. Port GPU akan meninggalkannya pada CPU atau menjalankannya satu lorong per kombo. Tetapi pada 0.7% kernel, ia adalah istilah Amdahl yang terlalu kecil untuk memutuskan apa-apa. Ia adalah bahagian yang tidak akan pergi; ia bukan sebab untuk tidak pergi. (Ingat daripada anak tangga M1 bahawa untuk kernel bebas-maklum-balas, gelung itu boleh malah divektorkan secara analitikal — penulisan semula yang anda hilang sebaik sahaja strategi itu tumbuh stop.)

Satu nota kaki platform untuk kelengkapan: pada mesin ini (Apple Silicon) laluan GPU akan menjadi MLX atau PyTorch-MPS, bukan CUDA — cupy dan ekosistem CUDA hanya tidak terpakai — dan mana-mana satu memerlukan penulisan semula laluan panas dalam dialek tensor hanya untuk mencuba eksperimen itu. Itu adalah kos sebenar dengan, mengikut analisis di atas, tiada ganjaran yang dikenal pasti untuk bentuk sapuan ini. Perbincangan GPU di sini adalah analitikal, berasaskan keamatan aritmetik terukur dan pembahagian ciri/gelung terukur, dan kami melabelkannya sedemikian: tiada larian CUDA dijalankan kerana tiada satu pun mungkin pada perkakasan yang didedahkan.

Ayat ringkasan yang akan kami pertahankan dalam ulasan: hampir semua kerja ini boleh pergi ke GPU; sapuan ini terlalu sempit dan terlalu pendek untuk perjalanan itu berbaloi. Dan baca itu dalam kedua-dua arah — ia bukan penulisan-tolak. Penyusunan semula "matriks-besar" berkelompok — menyusun semula sapuan itu sebagai operasi tensor besar merentasi beribu-ribu kombo sekaligus, atau kernel bebas-maklum-balas yang benar-benar mengelompokkan hujung ke hujung — adalah arah sebenar dan menjanjikan yang layak mendapat kajian khusus, bukan penolakan. Pada 80 kombo dan 0.23 saat, ia hanya belum lagi memperoleh tiketnya. Jika beban kerja anda mempunyai lebar itu, aritmetik itu berubah, dan anda patut mengulanginya, bukan memetik kami.

Di Mana Kesesakan Sebenar Berada: Enjin dan Orkestrasi

Kesesakan sebenar didedahkan: jam pasir di mana enjin dan orkestrasi beribu-ribu kombo parameter menyekat aliran, bukan perkakasan di bawahnya

Lapan puluh kombo adalah grid demonstrasi. Carian parameter sebenar adalah di mana faktor-faktor ini berhenti bersifat akademik, kerana grid berkembang secara pendaraban: empat parameter pada sepuluh nilai setiap satu adalah 10410^4 kombo; tambah pengesahan walk-forward dengan selusin lipatan dan anda berada pada 1.2×1051.2 \times 10^5 backtest penuh sebelum anda meneroka apa-apa. Ini adalah kutukan dimensi, dan itulah sebabnya strategi carian — Optuna, coordinate descent, Sobol — mendapat begitu banyak perhatian: carian yang lebih bijak melawat lebih sedikit titik.

Tetapi tangga ini mendedahkan separuh lain, kurang dibincangkan, bagi persamaan itu: kos setiap titik yang dilawati. Mengekstrapolasi daya pemprosesan terukur secara linear (kombo adalah bebas, jadi ini adalah aritmetik, bukan pemodelan):

Saiz grid Pada M0 (1.1 kombo/s) Pada M4 (340.9 kombo/s)
10,000 kombo ~2.4 jam ~30 saat
100,000 kombo ~24 jam ~5 minit

Eksperimen yang sama yang merupakan tugas kelompok semalaman pada enjin naif adalah pertanyaan interaktif pada enjin yang ditala. Perbezaan itu berganda dengan cara yang jadual masa-dinding tidak nampak: pada 5 minit setiap sapuan anda berulang — anda menjalankan semula dengan kebocoran yang dibetulkan, anda menambah lipatan, anda melebarkan grid, anda menguji idea yang terlintas semasa makan tengah hari. Pada 24 jam setiap sapuan, anda tidak. Kelajuan enjin menetapkan tempo gelung penyelidikan, dan tempo gelung penyelidikan adalah produk sebenar.

Terdapat juga bacaan hukum Amdahl bagi keseluruhan tangga ini:

S=1(1p)+p/sS = \frac{1}{(1 - p) + p / s}

Mempercepatkan mana-mana satu peringkat pp dengan faktor ss dibatasi oleh segala-galanya yang lain yang anda tinggalkan perlahan. Tangga ini menghormati susunan itu: keuntungan enjin 35.3x menyerang istilah yang menguasai (lelaran ditafsir, dalam timbunan ciri dan gelung sama-sama), keuntungan orkestrasi 8.4x menyerang istilah yang menguasai selepas itu (sebelas teras terbiar). Pembahagian ciri/gelung adalah pengajaran yang sama dalam bentuk mini — kami tidak dapat menamakan bentuk sebenar hujah GPU tanpa mengukur di mana masa sebenarnya pergi. Profil, kemudian optimumkan — dalam susunan itu. Logik yang sama mengawal lapisan data di hulu enjin: penanda aras Polars vs pandas kami menemui corak yang sama tepat (10–3500x pada saluran paip bergulir berkumpulan) untuk separuh muat-dan-transformasi tindanan itu, dan kesimpulan hibrid yang sama — enjin kolum untuk saluran paip, kernel disusun untuk simulasi bergantung-laluan.

Dua nota kejujuran untuk menutup gelung keumuman. Pertama, eksperimen ini sengaja mandiri dan sintetik — data disemai, satu kernel, satu mesin yang didedahkan — supaya sesiapa boleh mengulangi fenomena itu secara deterministik; nombor wall-clock akan berbeza pada perkakasan anda, tetapi kesetaraan dan arah tangga itu tidak akan. Kedua, fenomena itu bukan artifak persediaan sintetik: penanda aras enjin HMA produksi kami (bench_param_sweep.py, dijalankan pada data bursa sebenar dengan model yuran dan pengisian produksi penuh) menunjukkan bentuk tangga yang sama, dengan laluan numba mendarat kira-kira 100–200x di atas profil pandas naif. Eksperimen mandiri itu wujud supaya anda tidak perlu mempercayai nombor produksi kami secara buta.

Kesimpulan

  1. Tangga itu adalah 298x, dan ia terurai: 35.3x enjin × 8.4x orkestrasi. Memindahkan lelaran keluar daripada jurubahasa (pandas → numba) dan menyebarkan kombo bebas merentasi teras (satu → dua belas) mendarab menjadi kelajuan hampir tiga-order-magnitud pada laptop yang tidak berubah. 69.92 s → 0.23 s; 1.1 → 340.9 kombo/s. Dan ia bukan artifak garis dasar perlahan: berbanding pelaksanaan numpy vektor yang cekap, enjin siap masih ~13x.
  2. Tuntut kesetaraan sebelum mengagumi kelajuan. Setiap anak tangga di sini menghasilkan PnL per-kombo dan bilangan dagangan yang identik, di-get secara automatik pada kesemua 80 kombo (toleransi mutlak 10610^{-6} pada PnL, tepat pada dagangan). Enjin pantas yang mengira sesuatu yang sedikit berbeza bukan pantas — ia salah pada daya pemprosesan tinggi, dan penulisan semula vektor adalah tempat kesalahan itu biasanya menyelinap masuk.
  3. @njit mengalahkan vektorisasi bijak untuk logik berstatus. Anak tangga numpy memerlukan bentuk tertutup khusus-strategi yang mati sebaik sahaja anda menambah stop-loss. Anak tangga numba menyusun gelung naif yang boleh diaudit — kelas kelajuan yang sama, tiada kerapuhan, dan ia adalah unit yang berjaya diselarikan.
  4. Jawapan GPU adalah "tidak untuk sapuan ini" — atas sebab yang patut anda mampu namakan. Matematik ciri cenderung-pengiraan (10.78 FLOP/byte) dan ia adalah 99.3% kernel yang disusun, jadi tiada "backtest terikat memori" mahupun "gelung berstatus menguasai" bertahan pengukuran. Sebab jujur adalah lebar dan bajet: 80 kombo keselarian boleh eksploit yang sudah ditepu oleh 12 teras CPU, dan kerja jumlah 0.23 s yang overhead pelancaran dan penyegerakan akan makan. Penyusunan semula matriks-besar berkelompok pada lebar sebenar kekal arah menjanjikan, bukan yang disangkal.
  5. Kelajuan enjin adalah tempo penyelidikan. Pada daya pemprosesan enjin naif, carian 100,000-backtest adalah sehari; pada daya pemprosesan puncak tangga ia adalah lima minit. Sebelum membeli perkakasan atau menyewa kluster, semak sama ada kesesakan anda adalah silikon sama sekali — milik kami adalah lambda di dalam rolling.apply dan sebelas teras terbiar.

Eksperimen penuh — kesemua lima pelaksanaan, harness kesetaraan, pengiraan roofline, dan setiap nombor dalam artikel ini boleh dijana semula daripada satu skrip deterministik — berada dalam kertas kajian rakan di speed-ladder.marketmaker.cc, dengan kod dan data di github.com/suenot/backtest-speed-ladder.

Sapuan yang mengambil masa tujuh puluh saat mengambil masa suku daripada satu saat. Dagangan sama, PnL sama, laptop sama. GPU yang anda hampir mahu minta boleh menunggu; gelung jurubahasa yang anda hampir mahu hantar tidak boleh.

Penafian: Maklumat yang disediakan dalam artikel ini adalah untuk tujuan pendidikan dan maklumat sahaja dan bukan merupakan nasihat kewangan, pelaburan, atau dagangan. Dagangan mata wang kripto melibatkan risiko kerugian yang ketara.

Pengarang

Eugen Soloviov
Eugen Soloviov

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.

Newsletter

Kekal Mendahului Pasaran

Langgan surat berita kami untuk pandangan dagangan AI eksklusif, analisis pasaran, dan kemas kini platform.

Kami menghormati privasi anda. Berhenti melanggan pada bila-bila masa.