← Makalelere geri dön
July 1, 2026
5 dakikalık okuma

Backtest Hız Merdiveni: Dizüstü CPU'da 298x, Son İşleme Kadar Birebir Aynı PnL

Backtest Hız Merdiveni: Dizüstü CPU'da 298x, Son İşleme Kadar Birebir Aynı PnL
#algotrading
#backtest
#performans
#numba
#vektörizasyon
#optimizasyon

"Yanılsamasız Backtestler" serisinin bir parçası.

📄 Bu makale bir araştırma makalesine dönüştü. Yol bağımlı tek bir backtest çekirdeği beş farklı biçimde uygulanıyor — naif pandas'tan paralel bir numba çekirdeğine kadar — her basamak, aynı kombinasyon başına PnL'i ürettiği çapraz kontrolden geçiriliyor, böylece farklılaşan tek şey hız oluyor. Makaleyi çevrimiçi olarak (interaktif sürüm + PDF) speed-ladder.marketmaker.cc adresinde, kod ve veriyi ise github.com/suenot/backtest-speed-ladder adresinde okuyabilirsiniz.

Yetmiş saniye. Naif referans uygulamasının, 150,000 bar üzerinde tek bir hareketli ortalama stratejisinin 80 parametre kombinasyonunu taraması bu kadar sürüyor: göstergeler için pandas rolling().apply(), işlemler için düz bir Python döngüsü. Bu, gerçek dünyadaki araştırma kodunun büyük bir kısmının çalıştığı profildir, çünkü stratejiyi en bariz şekilde yazmanın doğal sonucu budur.

Aynı tarama, aynı dizüstü bilgisayarda, her kombinasyon için son işleme kadar aynı PnL'i üreterek: 0.23 saniye.

Bu iki rakam arasındaki fark — ölçülen 298x — bu makalenin konusudur. Bunun bir yüzde puanı bile yeni donanımdan gelmedi. Hiçbir GPU işin içinde değildi (bu makinede CUDA anlamında bir GPU mevcut bile değil). Merdivenin her basamağı aynı strateji, aynı veri, aynı ücretler, aynı işlem sayısıdır; herhangi bir uygulamanın kombinasyon başına sonuçları sapıyorsa tüm benchmark'ı başarısız kılan bir eşdeğerlik kapısı tarafından doğrulanmıştır. Değişen tek şey işin nasıl ifade edildiğidir: neyin yorumlayıcıda çalıştığı, neyin derlenmiş olarak çalıştığı ve neyin paralel çalıştığı. Ve bilinçli olarak yavaş bir referans herhangi bir manşet rakamını şişirebileceği için, baştan bir rakam daha: yetkin bir vektörize edilmiş numpy uygulamasına karşı bile — güçlü bir numpy programcısının teslim edeceği kod — bitmiş motor hâlâ yaklaşık 13x daha hızlıdır.

Bir parametre araması yavaş olduğunda, refleks daha büyük donanıma yönelmektir — bir GPU, bir küme, bir bulut bütçesi. Bu deneyin ölçülen gerçekliği çok daha az göz alıcı bir yere işaret ediyor: darboğaz motordu (pencere başına Python çağrıları yapan yorumlanmış bir iç döngü) ve orkestrasyondu (bağımsız kombinasyonları tek bir çekirdekte seri olarak çalıştırmak). İkisi de bir öğleden sonrada, zaten sahip olduğunuz makinede, sonuçlarda sıfır değişiklikle düzeltilebilir.

İşte merdivenin tamamı baştan. Aşağıdaki her şey her adımın anatomisidir.

Basamak Uygulama Duvar saati Hızlanma Kombinasyon/s
M0 pandas: rolling.apply + Python bar döngüsü 69.92 s 1.0x 1.1
M1 numpy: kayan pencere WMA + vektörize edilmiş işlemler 3.07 s 22.7x 26.0
M2 numba: @njit WMA + @njit olay döngüsü 1.98 s 35.3x 40.4
M3 numba prange: kombinasyonlar arasında thread'ler 0.32 s 217.6x 248.9
M4 process pool + numba: kombinasyonlar arasında process'ler 0.23 s 297.9x 340.9

Apple M2 Max (12 çekirdek), Python 3.14.6, numpy 2.4.3, numba 0.64.0, tek thread'li basamakların gerçekten tek çekirdek olması için BLAS (Accelerate) tek bir thread'e sabitlendi. 150,000 bar × 80 kombinasyon, 3'ün en iyisi duvar saati süresi, JIT ısınması hariç tutuldu. Pandas referansı dahil tüm basamaklar eksiksiz olarak zamanlandı ve 80 kombinasyonun tamamında kombinasyon başına aynı PnL ve işlem sayısını ürettiği doğrulandı.

Tek çekirdek, beş uygulama

Bir merdivenin beş basamağı: aynı backtest çekirdeği 70 saniyelik bir pandas referansından 0.23 saniyelik paralel bir numba çalışmasına tırmanıyor, her adımın aynı PnL'i ürettiği doğrulanıyor

Bir hız karşılaştırmasının bir anlam ifade edebilmesi için hesaplanan şeyin tam olarak sabitlenmesi ve her uygulamanın onu hesapladığının kanıtlanması gerekir. Bu yüzden deney tek bir strateji çekirdeğini sabitliyor ve onu beş basamağın tamamında değişmeden tutuyor.

Çekirdek bir HMA/HMA3 kesişimi — iki Hull tipi hareketli ortalama üzerinde bir dur-ve-tersine-dön (stop-and-reverse) sistemi. Yapı taşı, ağırlıklı hareketli ortalamadır:

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 Hareketli Ortalaması, gecikmeyi azaltmak için bunlardan üçünü birleştirir:

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)

HMA3 ise kabaca n/6n/6, n/4n/4 ve n/2n/2'deki WMA'lardan oluşturulan, bir kez daha yumuşatılan daha pürüzsüz bir kardeştir. Parametre kombinasyonu başına bu, altı farklı pencere uzunluğu üzerinde yedi WMA geçişi demektir — oyuncak değil, gerçek bir gösterge yığını.

İşlem kuralı bilinçli ve faydalı biçimde durum bağımlıdır (stateful): HMA, HMA3'ün altındayken yön long, aksi halde short'tur; ilk tanımlı yönde bir pozisyon açılır; her kesişimde pozisyon kapatılır, PnL %0.09'luk bir gidiş-dönüş ücreti düşülerek kaydedilir ve pozisyon tersine döndürülür. Pozisyon barlar arasında taşınır — bar ii'de ne yapacağınız, son kesişimden bu yana biriken duruma bağlıdır. Bu yol bağımlılığı deneyin tüm amacıdır: backtestleri genel dataframe pipeline'larından ayıran özellik budur ve (ölçeceğimiz gibi) GPU sorusunu karmaşıklaştırır — ama görüldüğü üzere, halk bilgeliğinin söylediği şekilde değil.

Rakamları değerlendirebilmeniz için kurulumun geri kalanı:

  • Veri: 150,000 barlık, tohumlanmış (seed=42) sentetik geometrik Brown hareketi. Buradaki performans, hangi fiyat yolunu beslediğinize değil, dizi boyutuna ve pencere uzunluklarına bağlıdır — ve sentetik bir seri, deneyin tamamını herkes tarafından deterministik ve tekrarlanabilir kılar.
  • Izgara: [6,200][6, 200] aralığına yayılmış 80 farklı HMA uzunluğu — böylece tarama, gerçek bir ızgarada olduğu gibi hem ucuz kısa pencereli hem de pahalı uzun pencereli kombinasyonlar içerir.
  • Zamanlama: duvar saati, basamak başına 3'ün en iyisi; JIT derlemesi zamanlayıcının dışında ısıtıldı ve pool worker'ları saat başlamadan önce ısıtıldı. Pandas referansı dahil her basamak, 80 kombinasyonun tamamında eksiksiz olarak zamanlandı. BLAS (Apple'ın Accelerate'i) tek bir thread'e sabitlendi, böylece tek thread'li basamaklar gerçekten tek çekirdektir: numpy basamağı, karşılaştırmanın arkasında sessizce matvec'lerini çoklu thread'e taşımıyor.
  • Eşdeğerlik kapısı: zamanlamadan sonra, her basamağın kombinasyon başına (PnL, işlem sayısı) vektörü referansla karşılaştırılır — işlem sayıları tam olarak eşleşmeli, PnL ise mutlak 10610^{-6} yüzde puanı içinde olmalıdır. Kaydedilen çalıştırma, pandas referansı dahil her basamak için 80 kombinasyonun tamamında all_ok: true bildiriyor. Bu kapı başarısız olursa ortada bir benchmark yoktur — beş farklı hızda beş farklı şeyi hesaplayan beş program vardır, ki "motorumuz 100x daha hızlı" iddialarının çoğu sessizce böyle işler.

Eşdeğerlik bloğundan bir rakam bir anlık dürüstlüğe değer: ilk kombinasyonun parmak izi, 57,029 işlem boyunca −5165.58 yüzde puanlık bir PnL'dir. Bu, utanılacak bir strateji sonucu değil — en kısa HMA uzunluğunun (6) rastgele yürüyüşün neredeyse her kıpırdanışında dönmesi ve her seferinde tam olarak gerektiği gibi %0.09 ödemesidir. Bu bir doğruluk parmak izidir, ticarete uygun bir backtest değil. İçinden alfa okumayın; determinizm okuyun — beş uygulamanın aynı 57,029 işleme ve altı ondalık basamağa kadar aynı PnL'e ulaşması, burada "aynı" ile kastedilen şeydir.

Bu belirlendikten sonra, aşağıdaki her hızlanma saf hızdır. Hiçbir şey yaklaşıklamayla es geçilmedi.

Basamak M0: naif pandas profili — 69.9 s

Naif pandas referansının anatomisi: 150,000 barın her biri için bir Python lambda çağrısı doğuran bir rolling.apply penceresi, altında yorumlayıcı döngüsü sürünürken

Bu referans bir saman adam değil. pandas dokümantasyonunun önerdiği şekilde bir WMA yazdığınızda ve strateji açıklamasının okunduğu şekilde bir olay döngüsü yazdığınızda elde ettiğiniz koddur:

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

Bu neden yavaş? pandas "kötü" olduğu için değil — iterasyonun nerede yaşadığı yüzünden. rolling(period).apply(lambda ...), vektörize edilmiş bir kostüm giyen Python düzeyinde bir döngüdür. 150,000 barın her biri için pandas bir pencere somutlaştırır, C/Python sınırını geçer, bir Python çağrılabilirini (callable) çağırır ve sonucu kutular (box). raw=True ile bile (bu en azından lambda'ya bir Series yerine çıplak bir ndarray verir), çağrı başına yorumlayıcı ek yükü, pencerenin gerçekte ihtiyaç duyduğu ~onlarca-yüzlerce FLOP'u gölgede bırakır. Kombinasyon başına yedi WMA geçişiyle çarpın, gösterge yığınının tek başına milyonlarca yorumlayıcı gidiş-dönüşü olduğunu görürsünüz. Ardından bar döngüsü, kombinasyon başına bir 150,000 yorumlanmış iterasyon daha çalıştırır; her biri numpy skalerleri üzerinde sınır kontrollü indeksleme yapar, float'ları kutular ve yorumlayıcının her seferinde yeniden keşfettiği türler üzerinde dinamik olarak dispatch eder.

Sonuç: tarama için 69.92 s, kombinasyon başına yaklaşık 0.87 s, saniyede 1.1 kombinasyonluk bir verim. 80 kombinasyonluk bir ızgarada omuz silker ve bir dakika beklersiniz. Sorun şu ki kimse uzun süre 80 kombinasyonluk ızgaralar çalıştırmaz — ve bu maliyet sonsuza kadar doğrusal olarak ölçeklenir. Buna geri döneceğiz.

Basamak M1: numpy — döngü içinde Python çağırmayı bırakın — 3.07 s, 22.7x

Bir üstteki basamak her iki yorumlayıcı döngüsünü de aynı anda ortadan kaldırır ve bu iki hileyi ayırmaya değer, çünkü genellikleri çok farklıdır.

Gösterge tarafı kolay ve tamamen genel olandır. Tüm pencereler üzerinde ağırlıklı hareketli ortalama, girdinin adımlı (strided) bir görünümüne karşı yalnızca bir matris–vektör çarpımıdır — kopya yok, tek bir BLAS çağrısı:

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, aynı belleğin bir (n − p + 1, p) görünümünü oluşturur ve win @ w, her pencerenin nokta çarpımını derlenmiş kodda hesaplar. Milyonlarca lambda çağrısı tek bir kütüphane çağrısına dönüşür.

İşlem tarafı ilginç olandır, çünkü olay döngüsü durum bağımlıdır (stateful) — ve yine de bu çekirdek için vektörize edilebilir. Buradaki içgörü, herhangi bir bardaki pozisyonun yalnızca HMA − HMA3'ün işaretine bağlı olması, herhangi bir işlem sonucuna bağlı olmamasıdır. Durum hiçbir zaman kararlara geri beslenmez. Böylece tüm döngü "işaret dönüşlerini bul, bu indekslerdeki fiyatları topla" haline çöker:

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, 22.7x'lik bir hızlanma, saniyede 26.0 kombinasyon — tek bir çekirdekte, BLAS tek bir thread'e sabitlenmiş halde. Bu basamak bir etiketi hak ediyor: bu yetkin referans (competent baseline), güçlü bir numpy programcısının teslim edeceği uygulama ve üzerindeki her şey için adil bir ölçüttür. Ama bu basamakla birlikte iki dürüst uyarı da geliyor.

Birincisi, bu vektörizasyon mekanik bir dönüşüm değil, stratejiye özgü analitik bir yeniden yazımdır. Var olma nedeni, çekirdeğin stop'suz, trailing exit'siz, çalışan PnL'e bağlı pozisyon boyutlandırması olmayan bir dur-ve-tersine-dön sistemi olmasıdır. Bir stop-loss ekleyin — akla gelebilecek en sıradan özellik — ve bar ii'deki çıkış, bar j>ij > i'de hangi girişin var olduğunu değiştirir, durum yola geri beslenir ve kapalı form buharlaşır. Çoğu üretim çekirdeği bu çizginin yanlış tarafında yaşar.

İkincisi, bu doğruluğun ölmeye gittiği basamaktır. Dönüş-indeksi muhasebesi (burada +1, orada [:-1], ilk yön tohumlaması), tam olarak off-by-one yürütme hatalarını üreten türden koddur — look-ahead taksonomimizin gürültüden 15'lik bir Sharpe üretebildiğini gösterdiği hatayla aynı türden. Eşdeğerlik kapısı bu basamakta bir formalite değildir; ona güvenmenin tek nedenidir. Aptal bir referans uygulamasına karşı bir eşdeğerlik kontrolü olmadan yapılan zekice vektörize yeniden yazımlar, motorların test ettiğini iddia ettikleri stratejiden nasıl uzaklaştığının yoludur.

Basamak M2: numba — gerçekten yazmak istediğiniz döngüyü derleyin — 1.98 s, 35.3x

numba JIT derleyicisinden geçen ve sıkı makine koduna dönüşen bir Python olay döngüsü: aynı dallanmalı bar-bar mantığı, yorumlanmak yerine derlenmiş

Basamak M2 tam tersi bir felsefe benimser: algoritmayı vektörize edilmiş ilkellere sığdırmak için çarpıtmak yerine naif döngüleri yazın — ve onları derleyin. Numba (Lam, Pitrou & Seibert, 2015), Python'un sayısal bir alt kümesini LLVM üzerinden makine koduna JIT-derler:

@njit(cache=True)
def nb_wma(x, period):
    n = x.shape[0]
    out = np.full(n, np.nan)
    wsum = period * (period + 1) / 2.0
    for i in range(period - 1, n):        # the "slow" loop, now machine code
        s = 0.0
        for j in range(period):
            s += x[i - period + 1 + j] * (j + 1)
        out[i] = s / wsum
    return out

@njit(cache=True)
def nb_sweep(close, half, full, sq, p3, p2, pi, fee):
    h  = nb_wma(2.0 * nb_wma(close, half) - nb_wma(close, full), sq)
    a  = 3.0 * nb_wma(close, p3) - nb_wma(close, p2) - nb_wma(close, pi)
    h3 = nb_wma(a, pi)

nb_sweep içindeki olay döngüsü metinsel olarak M0 döngüsünün aynısıdır. Dallanmalar, continue, yerel değişkenlerde taşınan durum — hepsi. @njit altında bu yerel değişkenler register'larda yaşar, dallanmalar gerçek atlama (jump) komutlarıdır ve iterasyon başına maliyet, mikrosaniyelik yorumlayıcı dispatch'ten nanosaniyelere düşer.

1.98 s — pandas'a göre 35.3x, ama numpy'a göre yalnızca yaklaşık 1.6x (türetilmiş: 3.07/1.98). Bu mütevazı adım kendi içinde öğreticidir: numpy'ın iç döngüleri zaten derlenmişti, bu yüzden numba'nın gösterge matematiğindeki kazancı pencere somutlaştırmasını ve ara dizileri atlamakla sınırlıdır. Dönüştürücü olan kısım başka bir yerde:

  1. Olay döngüsü artık bedava — ve "bedava" ölçülmüş bir şey, retorik değil. M1 zekasını işlem mantığını vektörize edilebilir kılmaya harcadı. M2 bu zekayı gereksiz kılıyor — naif, denetlenebilir, değiştirmesi kolay döngü makine hızında çalışıyor. Bu derlenmiş çekirdek içinde gösterge aşamasını işlem döngüsünden ayrı zamanlamak, zamanının %99.3'ünü WMA gösterge matematiğine, yalnızca %0.7'sini durum bağımlı olay döngüsüne atfediyor. Yarın bir araştırma projesi olmadan bir stop-loss ekleyebilirsiniz — ve bu ayrımı aklınızda tutun; aşağıdaki GPU tartışmasını yeniden belirliyor.
  2. Sonraki iki basamağın kilidini açıyor. Derlenmiş, GIL bırakan, tahsisatı hafif bir çekirdek, paralel orkestrasyonun ihtiyaç duyduğu iş birimidir. M0'ı verimli biçimde paralelleştiremezsiniz — yavaşın on iki kopyası yine yavaştır, sadece daha sıcak.

Bir yöntemsel not: numba ilk çağrıda derlenir ve bu derleme (yüzlerce milisaniye) zamanlayıcının içinde olmamalıdır — harness, ölçmeden önce JIT'i 500 bar'lık bir dilim üzerinde ısıtır ve cache=True, derlenmiş çekirdekleri process başlatmaları arasında kalıcı kılar. Bu ayrıntıyı "unutan" benchmark'lar, ya haksız yere kötü (soğuk derleme dahil edilmiş) ya da tekrarlanamaz numba rakamları üretir.

Basamak M3: prange — zaten sahip olduğunuz paralellik — 0.32 s, 217.6x

On iki CPU çekirdeğine yayılan seksen bağımsız parametre kombinasyonu: performans ve verimlilik çekirdekleri paralel olarak eşit olmayan pencere uzunluklarını çekiyor

Kitlesel parametre aramasını özel kılan gözlem şudur: 80 kombinasyon tamamen bağımsızdır. Paylaşılan durum yok, sıralama yok, iletişim yok. Bu, M0–M2 basamaklarının sırf alışkanlıktan on iki çekirdekten birinde çalıştırdığı utanç verici derecede paralel (embarrassingly parallel) bir iştir.

Numba bu düzeltmeyi neredeyse sözdizimsel hale getirir — kombinasyon döngüsünün range'ini prange ile değiştirin:

@njit(parallel=True, cache=True)
def nb_sweep_all(close, params, fee):
    N = params.shape[0]
    totals = np.empty(N, dtype=np.float64)
    ntrs = np.empty(N, dtype=np.int64)
    for k in prange(N):                    # threads across combos
        t, ntr = nb_sweep(close, params[k, 0], params[k, 1], params[k, 2],
                          params[k, 3], params[k, 4], params[k, 5], fee)
        totals[k] = t
        ntrs[k] = ntr
    return totals, ntrs

nb_sweep nopython-derlendiği için GIL tutmaz ve numba'nın thread katmanı iterasyonları 12 çekirdeğin tamamına yayar. Salt okunur close dizisi, tüm thread'ler tarafından sıfır maliyetle paylaşılır.

0.32 s — pandas'a göre 217.6x, saniyede 248.9 kombinasyon. Tek thread'li M2'ye göre adım, 12 çekirdekte yaklaşık 6.2x'tir (türetilmiş: 1.98/0.32) ve "ideal 12x"ten sapmayı gizlemek yerine dürüstçe kabul etmeye değer: M2 Max'in 12 çekirdeği 8 performans + 4 verimlilik çekirdeğidir, yani nominal tavan hiçbir zaman 12x olmadı; 80 kombinasyonun maliyetleri son derece eşitsizdir (uzunluğu 6 olan bir HMA, uzunluğu 200 olandan çok daha ucuzdur), bu yüzden thread'ler düzensiz biçimde biter; ve her çekirdek çağrısı ara dizilerini paylaşılan bir allocator'dan tahsis eder. Gerçek makinelerdeki paralel hızlanmalar böyle görünür. Heterojen görevler için temiz N-çekirdekte-Nx rakamları veren herkes sentetik bir şey ölçüyordur.

Basamak M4: son üçte bir için bir process pool — 0.23 s, 297.9x

Son basamak thread'leri process'lerle değiştirir — aynı derlenmiş çekirdek, bir ProcessPoolExecutor tarafından orkestre edilir:

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 — pandas'a göre 297.9x, saniyede 340.9 kombinasyon. O verimi bir daha okuyun: bu dizüstü bilgisayar artık saniyede kabaca 340 tam 150,000 bar'lık backtest çalıştırıyor, her biri yedi ağırlıklı hareketli ortalama hesaplıyor ve on binlerce durum bağımlı işlemi simüle ediyor.

prange'e göre üstünlük gerçek ama mütevazı — yaklaşık 1.4x (türetilmiş: 0.32/0.23) — ve makul mekanizmalar zamanlama ve bellek izolasyonudur: chunksize=1 ile pool kombinasyonları birer birer dağıtır, böylece ucuz ve pahalı pencerelerin düzensiz karışımı asimetrik çekirdekler arasında dinamik olarak yük dengelenir ve her worker process'i kendi allocator'ını alır, kombinasyon başına geçici nesnelerdeki çekişmeyi (contention) atlatır. Bunları ölçümle tutarlı mekanizmalar olarak bildiriyoruz, ayrı ayrı kanıtlanmış olgular olarak değil.

Process'ler bedava değildir ve harness onların maliyetlerini dürüstçe zamanlayıcının dışında, tek seferlik maliyetler olarak öder (worker başlatma, close'u initializer aracılığıyla her worker'a gönderme, worker başına JIT ısınması) — çünkü gerçek bir aramada bu maliyetler seksen değil binlerce kombinasyona yayılarak amorti edilir. Dürüst genel tavsiye: prange daha basittir ve genellikle yeterlidir; bir process pool, görevler iri taneli olduğunda, ızgara büyük olduğunda veya kombinasyon başına işiniz numba'nın erişemediği bir yerde GIL tuttuğunda kazanır.

Ve böylece merdiven temiz bir özete ayrışır. M0'dan M2'ye — motor: iterasyonu yorumlayıcının dışına taşımaktan gelen, tek bir çekirdekte 35.3x. M2'den M4'e — orkestrasyon: zaten orada olan çekirdekleri kullanmaktan gelen, ek bir 8.4x (türetilmiş: 1.98/0.23). Çarpıldığında: 298x. Yeni donanım yok, aynı sonuçlar. Ve naif olan yerine yetkin M1 referansından ölçüldüğünde, bitmiş motor hâlâ yaklaşık 13x daha yüksek duruyor (türetilmiş: 3.07/0.23) — merdiven, yavaş bir başlangıç noktası seçmenin bir eseri değildir.

Neden bir GPU değil — dürüst versiyon

Doymuş bir CPU'nun yanında boşta duran bir GPU: seksen kombinasyonluk ve çeyrek saniyelik bir taramanın yolculuğun bedelini ödeyemeyecek kadar dar ve kısa olması nedeniyle CPU'da bırakılan toplu işlenebilir hareketli ortalama matematiği

"Sadece bir GPU'ya taşı" yavaş bir parametre taramasına verilen en yaygın yanıttır, bu yüzden bu deney o konuşmanın başlaması gereken iki rakamı ölçer — ve ikisi de her iki yanıtın tembel versiyonunu da desteklemez.

Roofline modeli (Williams, Waterman & Patterson, 2009), bir çekirdeği aritmetik yoğunluğuna göre sınıflandırır — taşınan bayt başına FLOP. Bu taramadaki WMA gösterge yığını için, uzunluğu pp olan bir pencerede bar başına 2p2p FLOP'u bar başına bir 8 baytlık okumaya karşı sayarsak, 80 kombinasyonluk taramanın tamamı, akıtılan 576 MB üzerinde yaklaşık 6.2 GFLOP'a denk gelir:

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}}

(Bu, kombinasyon başına altı farklı WMA penceresi üzerindeki idealize edilmiş sayımdır; yedi geçişi fiilen yürütüldüğü gibi sayarsak 11.07 FLOP/bayt çıkar. Her iki durumda da aynı sonuç.)

Bu rakam neyi devre dışı bıraktığı için önemlidir: backtest matematiğinin "bellek sınırlı olduğu, dolayısıyla GPU'ların yardımcı olamayacağı" yönündeki popüler iddia burada yanlıştır. ~10.8 FLOP/bayt'ta gösterge matematiği kesinlikle hesaplama ağırlıklıdır (compute-ish) — tipik donanımın bant genişliği sınırlı olmaktan çıktığı sırt noktasının (ridge point) çok ötesinde. Bir GPU, 80 kombinasyon × 7 WMA geçişini bir avuç büyük çekirdeğe toplu işleyip aritmetiği kesinlikle çiğneyebilirdi. Gösterge yığını tüm sorun olsaydı, GPU durumu saygın olurdu.

İkinci ölçülen rakam ise diğer tembel yanıtı öldürür — bizim de kendiliğimizden başvuracağımız yanıtı. Derlenmiş çekirdek içinde gösterge aşamasını işlem döngüsünden ayrı zamanlamak, %99.3 gösterge, %0.7 olay döngüsü biçiminde bir ayrım verir. Cazip argüman — "backtestlerin durum bağımlı, dallanmalı bir olay döngüsü var ve GPU'yu engelleyen odur" — burada niceliksel olarak yanlıştır: CPU, zamanının hemen hemen tamamını tam olarak bir GPU'nun toplu işleyebileceği kısımda geçirir. 80 kombinasyon × 7 WMA geçişini büyük toplu convolution'lar olarak yeniden kurgularsanız, tamamen makul bir tensör iş yükü elde edersiniz. Yani dürüst soru, işin bir GPU'ya gidip gidemeyeceği değildir — çoğu gidebilirdi. Soru, yolculuğun karşılığını verip vermeyeceğidir ve bu tarama için vermez, iki özel nedenle:

1. Kullanılabilir genişlik 80 kombinasyondur — ve bir GPU bir genişlik makinesidir. Bir parametre taramasındaki tek dürüst paralellik ekseni ızgaranın kendisidir: bir kombinasyon içinde, 150,000 bar'lık yol sıralıdır (sequential). Bir GPU, şeritlerini (lane) doldurmak ve gecikmeyi (latency) gizlemek için on binlerce bağımsız iş öğesi ister; bu tarama seksen tane sunuyor. On iki CPU çekirdeği bu genişliği zaten doyuruyor — M3–M4 basamaklarının tam olarak ölçtüğü şey bu. Bir GPU'nun genişliğinin devreye girmeye başlayacağı kombinasyon sayılarında, CPU merdiveni zaten saniyede yüzlerce tam backtest sunuyor.

2. İşin tamamı 0.23 saniyedir. M4 hızında bir kombinasyonun maliyeti yaklaşık 2.9 ms'dir (türetilmiş: 0.23 s / 80). Bu bütçeye karşı, çekirdek başlatma (kernel-launch) gecikmeleri ve cihaz senkronizasyon noktaları amorti edilebilir yuvarlama hataları değildir — işin maddi bir kesridir. (Bu birleşik bellekli (unified-memory) Apple makinesinde host-to-device aktarımı küçük bir endişedir; ayrık GPU'lu bir CUDA kutusunda bu da hesaba eklenir.) Klasik GPU kazancı, sabit ek yükleri devasa iş yığınlarına yayarak amorti eder; saniyenin altındaki bir tarama bunu asla üretmez.

Peki ya olay döngüsü? Bu, toplu işlenemeyecek olan tek parçadır — seri, dallanmalı, yol bağımlı, hiçbir donanımın bir kombinasyon içinde paralelleştiremeyeceği 150,000 bar uzunluğunda döngü taşımalı bir bağımlılık, tam olarak SIMT şeritlerinin nefret ettiği türden ıraksak dallanmalarla. Bir GPU taşıması onu ya CPU'da bırakır ya da kombinasyon başına bir şerit çalıştırır. Ama çekirdeğin %0.7'sinde, hiçbir şeye karar veremeyecek kadar küçük bir Amdahl terimidir. Gitmeyecek olan parça budur; gitmeme nedeni değildir. (Basamak M1'den hatırlayın: geri beslemesiz çekirdekler için döngü analitik olarak bile vektörize edilebilir — strateji bir stop kazandığı anda kaybedeceğiniz yeniden yazım.)

Tamlık için bir platform dipnotu: bu makinede (Apple Silicon), GPU yolu CUDA değil MLX veya PyTorch-MPS olurdu — cupy ve CUDA ekosistemi basitçe uygulanabilir değil — ve her ikisi de deneyi denemek için bile sıcak yolu bir tensör dilinde yeniden yazmayı gerektirirdi. Bu, yukarıdaki analize göre bu taramanın şekli için tespit edilmiş bir getirisi olmayan gerçek bir maliyettir. Buradaki GPU tartışması analitiktir, ölçülen aritmetik yoğunluğa ve ölçülen gösterge/döngü ayrımına dayanır ve bunu böyle etiketliyoruz: açıklanan donanımda mümkün olmadığı için hiçbir CUDA çalıştırması yapılmadı.

İncelemede savunacağımız özet cümle: bu işin neredeyse tamamı bir GPU'ya gidebilirdi; bu tarama yolculuğun karşılığını vermesi için çok dar ve çok kısa. Ve bunu her iki yönde de okuyun — bu bir yazıp silme (write-off) değil. Toplu "büyük matris" yeniden formülasyonu — taramayı binlerce kombinasyon üzerinde aynı anda büyük tensör işlemleri olarak yeniden kurgulamak, ya da uçtan uca toplu işlenebilen gerçekten geri beslemesiz bir çekirdek — reddedilecek değil, özel bir çalışmayı hak eden gerçek ve umut verici bir yöndür. 80 kombinasyon ve 0.23 saniyede, bu basitçe henüz biletini kazanmamıştır. Sizin iş yükünüz bu genişliğe sahipse aritmetik değişir ve bunu bizi alıntılamak yerine kendiniz yeniden yapmalısınız.

Gerçek darboğaz nerede: motor ve orkestrasyon

Gerçek darboğaz ortaya çıkıyor: binlerce parametre kombinasyonunun motorunun ve orkestrasyonunun akışı boğduğu, altındaki donanımın değil, bir kum saati

Seksen kombinasyon bir gösteri ızgarasıdır. Gerçek parametre araması, bu faktörlerin akademik olmaktan çıktığı yerdir, çünkü ızgaralar çarpımsal olarak büyür: her biri on değer alan dört parametre 10410^4 kombinasyon demektir; bir düzine fold ile walk-forward doğrulaması ekleyin ve daha hiçbir şey keşfetmeden 1.2×1051.2 \times 10^5 tam backteste ulaşırsınız. Bu boyutluluk lanetidir ve arama stratejisinin — Optuna, koordinat inişi, Sobol — bu kadar ilgi görmesinin nedeni budur: daha akıllı arama daha az noktayı ziyaret eder.

Ama merdiven denklemin diğer, daha az konuşulan yarısını ortaya çıkarır: ziyaret edilen nokta başına maliyet. Ölçülen verimleri doğrusal olarak dışa dönük tahmin edersek (kombinasyonlar bağımsızdır, bu yüzden bu bir modelleme değil aritmetiktir):

Izgara boyutu M0'da (saniyede 1.1 kombinasyon) M4'te (saniyede 340.9 kombinasyon)
10,000 kombinasyon ~2.4 saat ~30 saniye
100,000 kombinasyon ~24 saat ~5 dakika

Naif motorda bir gecelik toplu iş olan aynı deney, ayarlanmış olanda etkileşimli bir sorguya dönüşür. Bu fark, duvar saati tablolarının hafife bıraktığı bir biçimde katlanarak büyür: tarama başına 5 dakikada iterasyon yaparsınız — düzeltilmiş bir sızıntıyla yeniden çalıştırırsınız, bir fold eklersiniz, ızgarayı genişletirsiniz, öğle yemeğinde aklınıza gelen fikri test edersiniz. Tarama başına 24 saatte bunu yapmazsınız. Motorun hızı araştırma döngüsünün temposunu belirler ve araştırma döngüsünün temposu asıl üründür.

Merdivenin tamamının bir Amdahl yasası okuması da var:

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

Herhangi bir tek aşama pp'yi ss faktörüyle hızlandırmak, yavaş bıraktığınız her şey tarafından sınırlanır. Merdiven bu sıralamaya saygı gösterdi: 35.3x'lik motor kazancı baskın olan terime saldırdı (gösterge yığınında ve döngüde aynı şekilde yorumlanmış iterasyon), 8.4x'lik orkestrasyon kazancı ise ondan sonra baskın olan terime saldırdı (on bir boşta çekirdek). Gösterge/döngü ayrımı aynı dersin küçük ölçekte tekrarıdır — zamanın gerçekte nereye gittiğini ölçmeden GPU argümanının gerçek şeklini adlandıramazdık. Önce profille, sonra optimize et — bu sırayla. Aynı mantık motorun yukarısındaki veri katmanını da yönetir: Polars vs pandas benchmark'larımız, yığının yükle-ve-dönüştür yarısı için aynı örüntüyü buldu (gruplanmış rolling pipeline'larda 10–3500x) ve aynı hibrit sonucu — pipeline için kolonsal (columnar) motorlar, yol bağımlı simülasyon için derlenmiş bir çekirdek.

Genellik konusundaki döngüyü kapatmak için iki dürüstlük notu. Birincisi, bu deney bilinçli olarak kendi kendine yeterli (self-contained) ve sentetiktir — tohumlanmış veri, tek bir çekirdek, açıklanmış tek bir makine — böylece herkes olguyu deterministik olarak tekrarlayabilir; duvar saati rakamları sizin donanımınızda farklı olacaktır, ama eşdeğerlik ve merdivenin yönü olmayacaktır. İkincisi, olgu sentetik kurulumun bir eseri değildir: üretim HMA motorumuzun benchmark'ı (bench_param_sweep.py, tam üretim ücret ve fill modeliyle gerçek borsa verisi üzerinde çalıştırıldı), numba yolunun naif pandas profilinin yaklaşık 100–200x üzerinde kaldığı aynı merdiven şeklini gösteriyor. Kendi kendine yeterli deney, üretim rakamlarımıza körü körüne inanmak zorunda kalmamanız için var.

Çıkarımlar

  1. Merdiven 298x'tir ve şu şekilde ayrışır: 35.3x motor × 8.4x orkestrasyon. İterasyonu yorumlayıcının dışına taşımak (pandas → numba) ve bağımsız kombinasyonları çekirdeklere yaymak (bir → on iki), değişmeyen bir dizüstü bilgisayarda üç kat büyüklük mertebesine yakın bir hızlanmaya çarpıldı. 69.92 s → 0.23 s; saniyede 1.1 → 340.9 kombinasyon. Ve bu yavaş-referans eseri değildir: yetkin vektörize edilmiş numpy uygulamasına karşı bile bitmiş motor hâlâ ~13x'tir.
  2. Hıza hayran olmadan önce eşdeğerlik talep edin. Buradaki her basamak, 80 kombinasyonun tamamında otomatik olarak kapılanan (PnL'de mutlak 10610^{-6} tolerans, işlemlerde tam eşleşme), kombinasyon başına aynı PnL ve işlem sayısını üretir. İnce bir şekilde farklı bir şey hesaplayan hızlı bir motor hızlı değildir — yüksek verimde yanlıştır ve yanlışlık genellikle vektörize yeniden yazımlarda sızar.
  3. Durum bağımlı mantık için @njit, zekice vektörizasyonu yener. numpy basamağı, bir stop-loss eklediğiniz anda ölen, stratejiye özgü bir kapalı form gerektirdi. numba basamağı ise naif, denetlenebilir döngüyü derler — aynı hız sınıfı, kırılganlık yok ve paralelleştirilebilen birim odur.
  4. GPU yanıtı "bu tarama için değil" — ve bunun nedenlerini adlandırabilmelisiniz. Gösterge matematiği hesaplama ağırlıklıdır (10.78 FLOP/bayt) ve derlenmiş çekirdeğin %99.3'üdür, bu yüzden ne "backtestler bellek sınırlıdır" ne de "durum bağımlı döngü baskındır" ölçümden sağ çıkar. Dürüst nedenler genişlik ve bütçedir: 12 CPU çekirdeğinin zaten doyurduğu 80 kombinasyonluk kullanılabilir paralellik ve başlatma ile senkronizasyon ek yükünün yiyip bitireceği 0.23 s'lik toplam bir iş. Gerçek genişlikte toplu büyük-matris yeniden formülasyonu, çürütülmüş değil umut verici bir yön olmaya devam ediyor.
  5. Motor hızı araştırma temposudur. Naif motor veriminde 100,000 backtestlik bir arama bir gündür; merdivenin zirvesindeki verimde beş dakikadır. Donanım satın almadan veya bir küme (cluster) kiralamadan önce, darboğazınızın gerçekten silikon olup olmadığını kontrol edin — bizimki rolling.apply içindeki bir lambda ve on bir boşta çekirdekti.

Tam deney — beş uygulamanın tamamı, eşdeğerlik harness'i, roofline hesaplaması ve bu makaledeki her rakamın tek bir deterministik betikten yeniden üretilebilmesi — speed-ladder.marketmaker.cc adresindeki eşlik eden makalede, kod ve veri ise github.com/suenot/backtest-speed-ladder adresinde.

Yetmiş saniye süren tarama artık bir saniyenin çeyreğini alıyor. Aynı işlemler, aynı PnL, aynı dizüstü bilgisayar. Talep etmek üzere olduğunuz GPU bekleyebilir; teslim etmek üzere olduğunuz yorumlayıcı döngüsü bekleyemez.

Sorumluluk Reddi: Bu makalede sağlanan bilgiler yalnızca eğitim ve bilgilendirme amaçlıdır ve finansal, yatırım veya ticaret tavsiyesi niteliği taşımaz. Kripto para ticareti önemli bir kayıp riski içerir.

Yazarlar

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

Piyasanın Önünde Olun

Özel yapay zeka ticaret içgörüleri, piyasa analizi ve platform güncellemeleri için bültenimize abone olun.

Gizliliğinize saygı duyuyoruz. İstediğiniz zaman abonelikten çıkabilirsiniz.