← Макалаларга кайтуу
June 26, 2026
5 мүн окуу

Бэктест ылдамдык шаты: ноутбуктун CPU'сунда 298x, акыркы сделкага чейин бирдей PnL

Бэктест ылдамдык шаты: ноутбуктун CPU'сунда 298x, акыркы сделкага чейин бирдей PnL
#algotrading
#backtest
#performance
#numba
#vectorization
#optimization
Part 1 of 10 · Collection
High-Performance Backtest Engines

"Иллюзиясыз бэктесттер" сериясынын бир бөлүгү.

📄 Бул макала изилдөө макаласына айланды. Бир жол-көз көз каранды бэктест ядросу беш жол менен ишке ашырылды — жөнөкөй pandas'тан параллель numba ядросуна чейин — ар бир баскыч бирдей комбинация боюнча PnL берерин кайра-кайра текшерүү менен, ошондуктан жалгыз айырмаланган нерсе — ылдамдык. Макаланы онлайн окуу (интерактивдүү версия + PDF) үчүн speed-ladder.marketmaker.cc, код жана дайындар github.com/suenot/backtest-speed-ladder дарегинде.

Жетимиш секунд. Так ушунча убакыт жөнөкөй эталон ишке ашыруусуна бир жылчылык-орточо стратегиянын 80 параметр комбинациясын 150,000 бар боюнча сканирлөөгө кетет: индикаторлор үчүн pandas rolling().apply(), сделкалар үчүн жөнөкөй Python циклы. Бул чыныгы изилдөө кодунун чоң бөлүгү иштеген профиль, анткени бул стратегияны эң табигый жол менен жазганда автоматтык түрдө келип чыгуучу профиль.

Ошол эле сканирлөө, ошол эле ноутбукта, ар бир комбинация үчүн акыркы сделкага чейин бирдей PnL берип: 0.23 секунд.

Бул эки сан ортосундагы алшак — өлчөнгөн 298x — ушул макаланын темасы. Анын бир пайызы да жаңы аппараттан келген эмес. Эч бир GPU катышкан жок (бул машинада CUDA мааниси боюнча ал жок деле). Шаттын ар бир баскычы — так ошол эле стратегия, так ошол эле дайындар, так ошол эле комиссиялар, так ошол эле сделка саны, эквиваленттик текшерүү менен ырасталган — эгер кайсы бир ишке ашыруунун ар бир комбинация боюнча натыйжасы башкалардан алыс болсо, бүткүл бенчмарк "иштебей калат". Ал эми өзгөргөн жалгыз нерсе — иштин кантип туюндурулушу: эмне интерпретаторго иштейт, эмне компиляцияланган түрдө иштейт, жана эмне параллель иштейт. Атайын жай тандалган база каалаган баш кабарды жасалма көрсөтө алат, ошондуктан алдын ала дагы бир цифра: натыйжалуу вектордоштурулган numpy ишке ашыруусуна — күчтүү numpy программисти жиберчү кодго — салыштырганда деле бүткөрүлгөн машина болгону 13x тезирээк.

Параметр издөө жай болгондо, рефлекс — чоңураак аппаратка жетелейт: GPU, кластер, cloud бюджети. Бул эксперименттин өлчөнгөн чындыгы такыр башка, азыраак жалтырак нерсеге көрсөтөт: тыгыздык — машина (бар менен терезе боюнча Python чакырууларын аткарган интерпретацияланган ички цикл) жана оркестрация (өз алдынча комбинацияларды бир ядрода ырааттуу иштетүү). Экөө тең түшкө оңдоого мүмкүн — ал эми сизде мурунтан эле бар машинада, натыйжаларды өзгөртпөстөн.

Мына бүткүл шат алдын ала. Төмөндөгүнүн баары — ар бир баскычтын анатомиясы.

Баскыч Ишке ашыруу Иштин узактыгы Ылдамдоо Комбо/с
M0 pandas: rolling.apply + Python бар циклы 69.92 с 1.0x 1.1
M1 numpy: сырганоочу терезе WMA + вектордоштурулган сделкалар 3.07 с 22.7x 26.0
M2 numba: @njit WMA + @njit окуялар циклы 1.98 с 35.3x 40.4
M3 numba prange: комбинациялар боюнча агымдар 0.32 с 217.6x 248.9
M4 процесс-пул + numba: комбинациялар боюнча процесстер 0.23 с 297.9x 340.9

Apple M2 Max (12 ядро), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) бир агымга кадалган, ошондуктан жалгыз-агымдуу баскычтар чындыгында жалгыз-ядролуу. 150,000 бар × 80 комбо, 3 өлчөөдөн эң жакшысы, JIT "жылытуу" эсептелбейт. Бардык баскычтар — pandas базасын кошо алганда — толук өлчөнүп, 80 комбинациянын баарында бирдей комбинациялык PnL жана сделка санын берерин ырасталды.

Бир ядро, беш ишке ашыруу

Five rungs of one staircase: the same backtest kernel climbing from a 70-second pandas baseline to a 0.23-second parallel numba run, each step verified to produce identical PnL

Ылдамдык салыштыруусу мааниге ээ болушу үчүн, эсептелип жаткан нерсе так аныкталышы керек, жана ар бир ишке ашыруунун аны эле эсептеп жатканы далилдениши керек. Ошондуктан эксперимент бир стратегия ядросун бекитип, аны беш баскычтын баарында өзгөрүшсүз калтырат.

Ядро — HMA/HMA3 кесилиши — эки Hull-стилиндеги жылчылык орточо боюнча "токто-жана-тескери бур" системасы. Курулуш блогу — салмактанган жылчылык орточо:

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 жылчылык орточосу артта калууну кыскартуу үчүн үчөөнү бириктирет:

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 — болжол менен n/6n/6, n/4n/4 жана n/2n/2 терезелериндеги WMA'лардан курулган, кайра бир жолу тегизделген жумшагыраак тууган. Ар бир параметр комбинациясы үчүн бул алты ар кыл терезе узундугундагы жети WMA өтүшү — чыныгы индикатор стеги, оюнчук эмес.

Соода эрежеси атайын, пайдалуу түрдө абалдуу: HMA HMA3'тен төмөн болсо — багыт лонг, эмесе — шорт; биринчи аныкталган багытта позиция ачылат; ар бир кесилиште, позиция жабылат, PnL 0.09% айландыруу комиссиясын алып салуу менен эсептелет, жана позиция тескерилейт. Позиция бардар аралыгы боюнча уланатii барда эмне кылышыңыз соңку кесилишен кийин топтолгон абалга көз каранды. Бул жол-көз көз карандылык эксперименттин негизги маңызы: бул бэктесттерди жалпы dataframe түтүктөрүнөн айырмалаган касиет, жана (биз өлчөгөндөй) ал GPU маселесин татаалдантат — бирок, алкактардан айырмаланып, ошол жол менен эмес.

Орнотуунун калган бөлүгү, ошондуктан сандарды баалай аласыз:

  • Дайындар: 150,000 бар синтетикалык геометриялык Броун кыймылынын дайыны, урук менен (seed=42). Мында натыйжалуулук массив өлчөмүнө жана терезе узундугуна көз каранды, кайсы баа жолун бересиз — ага эмес — жана синтетикалык катар бүткүл экспериментти детерминизацияланган жана ар кимге кайра чыгарылуучу кылат.
  • Тор: [6,200][6, 200] диапазонунда таралган 80 ар кыл HMA узундугу — ошондуктан сканирлөө арзан кыска-терезелүү комбинацияларды да, кымбат узун-терезелүү комбинацияларды да камтыйт, чыныгы тордогудай эле.
  • Убакытты өлчөө: wall-clock, ар бир баскыч үчүн 3 өлчөөдөн эң жакшысы, JIT компиляциясы таймердин сыртында жылытылат, ал эми пул жумушчулары саат башталганга чейин жылытылат. Ар бир баскыч — pandas базасын кошо алганда — 80 комбинациянын баарында толук өлчөнөт. BLAS (Apple'дын Accelerate'и) бир агымга кадалган, ошондуктан жалгыз-агымдуу баскычтар чындыгында жалгыз-ядролуу: numpy баскычы салыштыруунун артында матвекторлорун унутта көп агымдуу иштетип жаткан жок.
  • Эквиваленттик текшерүү: убакыт өлчөнгөндөн кийин, ар бир баскычтын комбинация боюнча (PnL, сделка саны) вектору эталон менен салыштырылат — сделка саны так дал келиши керек, PnL 10610^{-6} абсолюттук пайыздык пункт чегинде. Тапшырылган иштетүү ар бир баскыч үчүн, pandas базасын кошо алганда, 80 комбинациянын баарында all_ok: true берет. Эгер бул текшерүү иштебесе, бенчмарк жок болуп калат — бар болгону беш программа беш ар кыл нерсени беш ар кыл ылдамдыкта эсептеп жатат, "биздин машина 100x тезирээк" деген дооматтардын көбү дал ушундай тынч кырдаалда иштейт.

Эквиваленттик блоктон бир сан ак ниеттик учур болуп калат: биринчи комбинациянын изи — 57,029 сделка боюнча −5165.58 пайыздык пункттук PnL. Бул стратегия натыйжасы катары уяткандай сан эмес — бул эң кыска HMA узундугу (6) кокустук жүрүштүн дээрлик ар бир термелишинде "флип" болуп, ар бир жолу 0.09% төлөп жатат, дал ушундай болушу керек. Бул тагдырчылык изи, соода жасалуучу бэктест эмес. Мунда альфа издебеңиз; мунда детерминизмди окуп чыгыңыз — беш ишке ашыруу так ошол эле 57,029 сделкага жана PnL'ге алты ондук белгиге чейин дал келгендиги "бирдей" деген сөздүн бул жерде эмнени билдирерин көрсөтөт.

Бул белгиленгенден кийин, төмөндөгү ар бир ылдамдоо — таза ылдамдык. Эч нерсе жакындатылып ыргытылган жок.

M0 баскычы: жөнөкөй pandas профили — 69.9 с

Anatomy of the naive pandas baseline: a rolling.apply window spawning a Python lambda call for every one of 150,000 bars while the interpreter loop crawls beneath it

Бул база жасалма алсыз каршылаш эмес. Бул — WMA'ны pandas документтеринин сунуштаганындай жазып, окуялар циклин стратегия сүрөттөмөсү окулгандай жазганда чыккан код:

def pd_wma(s: pd.Series, period: int) -> np.ndarray:
    w = np.arange(1, period + 1, dtype=np.float64)
    w /= w.sum()
    return s.rolling(period).apply(lambda x: np.dot(x, w), raw=True).to_numpy()

def run_pandas_one(close, length):
    h, h3 = pd_hma(close, length), pd_hma3(close, length)  # 7 rolling.apply WMAs
    total, ntr, prev_dir, entry, pos = 0.0, 0, 0, 0.0, 0
    for i in range(len(close)):                            # Python bar loop
        if np.isnan(h[i]) or np.isnan(h3[i]):
            continue
        d = 1 if h[i] < h3[i] else -1
        if prev_dir == 0:
            prev_dir, pos, entry = d, d, close[i]
            continue
        if d != prev_dir:                                  # cross: close + reverse
            pnl = ((close[i] - entry) if pos == 1
                   else (entry - close[i])) / entry * 100 - FEE
            total += pnl
            ntr += 1
            pos, entry, prev_dir = d, close[i], d
    return total, ntr

Эмне үчүн бул жай? pandas "начар" болгону үчүн эмес — итерация кайда жашагандыгы үчүн. rolling(period).apply(lambda ...) — вектордоштурулган костюм кийген Python-деңгээлдеги цикл. 150,000 бардын ар бирине, pandas терезе жаратат, C/Python чек арасынан өтөт, Python чакырылуучусун чакырат, натыйжаны "боксолойт". raw=True менен деле (жок дегенде лямбдага Series эмес, тьм ndarray берет), ар бир чакыруунун интерпретатор чыгымы терезенин чын эле керек болгон ондогон-жүздөгөн FLOP'тарынан алда канча ашык. Ар бир комбинация үчүн жети WMA өтүшүнө көбөйтсөңүз, индикатор стеги жалгыз эле миллиондогон интерпретатор саякаттарын түзөт. Андан кийин бар циклы ар бир комбинация үчүн дагы 150,000 интерпретацияланган итерацияны иштетет, ар бири numpy скаляр боюнча чек текшерилген индекстөө, флоат "боксолоо" жана типтер боюнча ар бир жолу кайра-кайра табылган динамикалык диспетчерлештирүү жасайт.

Натыйжа: сканирлөө үчүн 69.92 с, комбинация башына болжол менен 0.87 с, өткөрүмдүүлүк секундасына 1.1 комбинация. 80-комбинациялуу тордо мунун баары — далай минута күтүп туруу. Көйгөй — эч ким узакка 80-комбинациялуу торлорду иштетпейт — ал эми бул чыгым ар дайым сызыктуу түрдө өсөт. Бизге ушуга кайра келебиз.

M1 баскычы: numpy — циклдеги Python чакырууларын токтотуу — 3.07 с, 22.7x

Биринчи жогорку баскыч эки интерпретатор циклин бир жолу жок кылат, жана эки амалды ажыратуу баалуу, анткени алардын жалпыланышы такыр башкача.

Индикатор жагы — жеңил жана толук жалпыланган. Бардык терезелер боюнча салмактанган жылчылык орточо — киргизилген дайыныдын striped view'у боюнча матрица-вектор көбөйтүндүсү — көчүрмө жок, бир гана BLAS чакыруу:

def vec_wma(x: np.ndarray, period: int) -> np.ndarray:
    w = np.arange(1, period + 1, dtype=np.float64)
    win = np.lib.stride_tricks.sliding_window_view(x, period)  # zero-copy view
    out = np.full(len(x), np.nan)
    out[period - 1:] = win @ w / w.sum()                       # one matvec
    return out

sliding_window_view ошол эле эсте (n − p + 1, p) view'ду курат, ал эми win @ w ар бир терезенин чекит көбөйтүндүсүн компиляцияланган кодто эсептейт. Миллион лямбда чакыруулары бир китепкана чакыруусуна айланат.

Сделка жагы кызыктуубу, анткени окуялар циклы абалдуу — жана ошондой болсо да, ушул ядро үчүн ал вектордоштурулат. Түшүнүк — каалаган бардагы позиция бир гана HMA − HMA3 белгисине көз каранды, эч бир сделка натыйжасына эмес. Абал чечимдерге кайра "тамактанбайт". Ошондуктан бүткүл цикл "белги секирүүлөрүн тап, ошол индекстерде баалар жый" деген формулага тыгылат:

d = np.where(h[idx] < h3[idx], 1, -1)             # direction per valid bar
flips = np.flatnonzero(np.diff(d) != 0) + 1       # bars where it crosses
cross = idx[np.concatenate(([0], flips))]         # entry/exit indices
side  = d[np.concatenate(([0], flips))]
entries, exits, s = close[cross[:-1]], close[cross[1:]], side[:-1]
pnl = np.where(s == 1, (exits - entries) / entries,
               (entries - exits) / entries) * 100 - FEE
return float(pnl.sum()), int(pnl.size)

3.07 с, 22.7x ылдамдоо, секундасына 26.0 комбинация — бир ядрода, BLAS бир агымга кадалган түрдө. Бул баскыч бир белгини татыктуу: бул натыйжалуу база — күчтүү numpy программисти жиберчү ишке ашыруу, жана жогорудагылардын баары үчүн адилет эталон. Бирок бул баскычка эки ак ниеттик эскертүү жабышкан.

Биринчиден, бул вектордоштуруу — стратегияга тиешелүү аналитикалык кайра жазуу, механикалык өзгөртүү эмес. Ал ядронун стоп жок, тартылуучу чыгуу жок, иштеп жаткан PnL'ге көз каранды позиция өлчөө жок "токто-жана-тескери бур" болгону үчүн бар. Стоп-лосс кошуп көрүңүз — сыйынган эң кадимки функция — жана ii бардагы чыгуу j>ij > i бардагы кайсы кирүү бар экенин өзгөртөт, абал жол боюнча кайра тамактанат, жана жабык формула эрип кетет. Көпчүлүк өндүрүштүк ядролор бул сызыктын туура эмес жагында жашайт.

Экинчиден, бул — туура эместик өлүп калуучу баскыч. Флип-индекс китеп жүргүзүү (бул жердеги +1, тигил жердеги [:-1], биринчи-багыт "уруктоо") дал off-by-one аткаруу катачылыктарын жаратуучу код түрү — биздин look-ahead таксономиясы көрсөткөн, шоокумдан Sharpe 15'ти жасай алган ошол эле түрдөгү катачылык. Эквиваленттик текшерүү бул баскычта формалдуулук эмес; ага ишенүүнүн жалгыз себеби. Жөнөкөй эталон ишке ашыруусуна карата эквиваленттик текшерүүсүз айлакер вектордоштурулган кайра жазуулар — машиналар изилдеп жаткан деп ырастаган стратегиядан кантип алыстап кетишинин жолу.

M2 баскычы: numba — чын эле жазгыңыз келген циклди компиляциялаңыз — 1.98 с, 35.3x

A Python event loop passing through the numba JIT compiler and emerging as tight machine code: the same branchy bar-by-bar logic, compiled instead of interpreted

M2 баскычы карама-каршы философияны кармайт: алгоритмди вектордоштурулган примитивдерге бейимденгендин ордуна, жөнөкөй циклдерди жазыңыз — жана аларды компиляциялаңыз. Numba (Lam, Pitrou & Seibert, 2015) Python'дун сандык жеринки топтомун LLVM аркылуу машина кодуна JIT-компиляциялайт:

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

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

nb_sweep ичиндеги окуялар циклы — M0 циклынын так текст түрдөгү копиясы. Тармактар, continue, локалдарда сакталган абал — баары. @njit алдында бул локалдар регистрлерде жашайт, тармактар — чыныгы секирүү нускамалары, ал эми ар бир итерациянын баасы интерпретатор диспетчерлештирүүнүн микросекундаларынан наносекундаларга чейин түшөт.

1.98 с — pandas'ка салыштырмалуу 35.3x, бирок numpy'ге салыштырмалуу болгону болжол менен 1.6x (алынган: 3.07/1.98). Бул кичине кадам өзү эле маанилүү сабак: numpy'дин ички циклдери мурунтан эле компиляцияланган болчу, ошондуктан numba'нын функция математикасындагы утушу терезе материалдаштыруу жана аралык массивдерди өткөрүп жиберүү менен чектелген. Ал эми чыныгы өзгөрүү башка жерде:

  1. Окуялар циклы азыр акысыз — жана "акысыз" деген сөз риториялык эмес, өлчөнгөн. M1 бүткүл айлакерлигин сделка логикасын вектордоштурулуучу кылууга сарптады. M2 бул айлакерликти кереги жок кылат — жөнөкөй, текшерилүүчү, оңой өзгөртүлүүчү цикл машина ылдамдыгында иштейт. Ушул компиляцияланган ядронун ичинде функция стадиясын сделка циклынан бөлүп өлчөө убакыттын 99.3%'ин WMA функция математикасына, ал эми болгону 0.7%'ин абалдуу окуялар циклына таандык кылат. Стоп-лоссту эртеч изилдөө долбоорусуз кошсоңуз болот — жана бул бөлүнүштү эсиңизде сактаңыз; ал төмөндөгү GPU доомотун кайра чечет.
  2. Ал кийинки эки баскычты ачат. Компиляцияланган, GIL'ди бошотуучу, аз аллокациялоочу ядро — параллель оркестрация керектеген иш бирдиги. M0'ди натыйжалуу параллелдештире албайсыз — он эки жай көчүрмө деле жай бойдон калат, бир аз жылуураак болсо да.

Бир методологиялык эскертме: numba биринчи чакырууда компиляцияланат, жана бул компиляция (жүздөгөн миллисекунд) таймердин ичинде болбошу керек — гарнесс JIT'ти өлчөөгө чейин 500-бардык кесиндиде жылытат, ал эми cache=True компиляцияланган ядроларды процесс иштетүүлөрүнүн ортосунда сактайт. Бул деталь "унутулган" бенчмарктар numba сандарын же адилетсиз начар (муздак компиляция кошулган), же кайра чыгарылбас кылат.

M3 баскычы: prange — мурунтан эле бар параллелизм — 0.32 с, 217.6x

Eighty independent parameter combos fanned out across twelve CPU cores: performance and efficiency cores pulling unequal window lengths in parallel

Массалык параметр издөөнү өзгөчө кылган байкоо мына: 80 комбинация — толук көз карандысыз. Ортоктош абал жок, тартип жок, байланыш жок. Бул — өтө оңой параллелдешүүчү иш, аны M0–M2 баскычтары он эки ядронун бир гана ядросунда, таза адат менен иштетип жатышкан.

Numba бул оңдоону дээрлик синтаксистик кылат — комбинация циклынын range'ин prange'ге алмаштырыш керек:

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

nb_sweep nopython-компиляцияланган болгондуктан, ал эч бир GIL кармабайт, ал эми numba'нын агым катмары итерацияларды 12 ядронун баарына жайылтат. Окуу-гана close массиви бардык агымдарга нөл баада ортоктошот.

0.32 с — pandas'ка салыштырмалуу 217.6x, секундасына 248.9 комбинация. Жалгыз-агымдуу M2'ге салыштырмалуу кадам 12 ядрода болжол менен 6.2x (алынган: 1.98/0.32), ал эми "идеалдуу 12x"'тен жетишпей калган бөлүгү жашырылбай ак ниеттик түрдө түшүндүрүлүшү керек: M2 Max'тын 12 ядросу 8 өндүрүмдүү + 4 натыйжалуулук ядросу, ошондуктан номиналдык шек эч качан 12x болгон эмес; 80 комбинациянын баасы бирдей эмес (узундугу 6 болгон HMA узундугу 200 болгондон алда канча арзан), ошондуктан агымдар бирдей эмес учурда бүтөт; жана ар бир ядро чакыруусу өз аралык массивин ортоктош аллокатордон бөлүп алат. Чыныгы машиналарда параллель ылдамдоолор дал ушундай көрүнөт. Гетерогендик тапшырмалар үчүн N ядродо таза Nx-ты дооматтагандардын баары синтетикалык нерсени өлчөп жатышат.

M4 баскычы: акыркы үчтен бир бөлүк үчүн процесс-пул — 0.23 с, 297.9x

Акыркы баскыч агымдарды процесстерге алмаштырат — ошол эле компиляцияланган ядро, ProcessPoolExecutor менен оркестрацияланган:

with ProcessPoolExecutor(max_workers=12, initializer=_init_worker,
                         initargs=(close,)) as ex:          # ship data ONCE
    list(ex.map(_warmup_worker, range(12 * 3)))             # JIT-warm every worker
    results = list(ex.map(_run_one_combo, grid, chunksize=1))

0.23 с — pandas'ка салыштырмалуу 297.9x, секундасына 340.9 комбинация. Бул өткөрүмдүүлүктү кайра окуп чыгыңыз: бул ноутбук азыр секундасына болжол менен 340 толук 150,000-бардык бэктестти иштетип жатат, ар бири жети салмактанган жылчылык орточо эсептеп, ондогон миңдеген абалдуу сделканы моделдейт.

prange'ге салыштырмалуу артыкчылык чыныгы, бирок кичине — болжол менен 1.4x (алынган: 0.32/0.23) — жана мыкты түшүнүктүү механикасы жоспорлоо жана эс-тутум изоляциясы: chunksize=1 менен пул комбинацияларды бирден таратат, ошондуктан арзан жана кымбат терезелердин бирдей эмес аралашмасы асимметриялуу ядролор боюнча динамикалык түрдө жүктү теңдештирет, жана ар бир жумушчу процесс өзүнүн аллокаторун алат, комбинациялык убактылуу маалыматтар үчүн атаандаштыкты айланып өтөт. Биз буларды өлчөө менен шайкеш келүүчү механикалар катары билдиребиз, өзүнчө далилденген факт катары эмес.

Процесстер акысыз эмес, жана гарнесс алардын баасын ак ниеттик түрдө таймердин сыртында төлөйт (жумушчуну иштетүү, close'ду ар бир жумушчуга initializer аркылуу жиберүү, ар бир жумушчунун JIT жылытуусу) — анткени чыныгы издөөдө бул чыгымдар сексен эмес, миңдеген комбинацияга бөлүнүп кетет. Ак ниеттик жалпы кеңеш: prange жөнөкөй жана дээрлик дайыма жетиштүү; процесс-пул тапшырмалар "чоңдоо" болгондо, тор чоң болгондо, же комбинация башына иштин бир бөлүгү numba жете албай турган жерде GIL кармаганда утат.

Ошону менен, шат таза жыйынтыкка бөлүнөт. M0'дон M2'ге — машина: жалгыз ядрода 35.3x, итерацияны интерпретатордон чыгаруудан. M2'дон M4'кө — оркестрация: дагы 8.4x (алынган: 1.98/0.23), мурунтан эле бар ядроларды колдонуудан. Көбөйтсөк: 298x. Жаңы аппарат жок, натыйжалар бирдей. Ал эми жөнөкөй эмес, натыйжалуу M1 базасынан өлчөгөндө, бүткөрүлгөн машина дагы деле болжол менен 13x бийик турат (алынган: 3.07/0.23) — шат жай баштапкы чекитти тандаган жасалма нерсе эмес.

Эмне үчүн GPU эмес — ак ниеттик версия

A GPU sitting idle beside a saturated CPU: batchable moving-average math left on the CPU because a sweep of eighty combos and a quarter of a second is too narrow and too short to pay for the trip

"Жөн эле GPU'га которуп кой" — жай параметр сканирлөөгө эң кеңири таралган жооп, ошондуктан бул эксперимент ошол сүйлөшүү башталышы керек болгон эки санды өлчөйт — жана экөө тең эки жооптун да жалкоо версиясын колдобойт.

Roofline модели (Williams, Waterman & Patterson, 2009) ядрону анын арифметикалык интенсивдүүлүгү боюнча классификациялайт — байт башына FLOP. Бул сканирлөөдөгү WMA функция стеги үчүн, ар бир бардагы, ар бир pp узундуктагы терезедеги 2p2p FLOP'ту, бар башына бир 8-байттык окуу менен эсептесек, бүткүл 80-комбинациялуу сканирлөө болжол менен 576 MB агылган 6.2 GFLOP'ко туура келет:

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

(Бул — ар бир комбинациядагы алты ар кыл WMA терезеси боюнча идеалдаштырылган сан; жети өтүштү иш жүзүндө аткарылгандай эсептесе, 11.07 FLOP/byte чыгат. Кайсынысы болсо да, жыйынтык бирдей.)

Бул сан эмнени четке кагарын эске алганда мааниге ээ: бэктест математикасы "эс-тутумга көз каранды, ошондуктан GPU жардам бере албайт" деген популярдуу доомот бул жерде жалган. ~10.8 FLOP/byte'та функция математикасы чыныгы эсептеш-мүнөздүү — типтүү аппараттар байт-мүнөздүү чектөөдөн чыга турган жон чекитинен алда канча алдыда. GPU 80 комбинация × 7 WMA өтүшүн бир нече чоң ядрого топтоп, арифметиканы кыйла жей ала алат. Эгер функция стеги бүткүл маселе болсо, GPU варианты сыймыктуу болмок.

Экинчи өлчөнгөн сан башка жалкоо жоопту өлтүрөт — өзүбүз да жетелей турган жооп. Функция стадиясын компиляцияланган ядронун ичинде сделка циклынан бөлүп өлчөө 99.3% функциялар, 0.7% окуялар циклы деген бөлүнүштү берет. Сыйынган аргумент — "бэктесттерде абалдуу, тармактанган окуялар циклы бар, жана дал ошол GPU'ну бөгөйт" — бул жерде сандык жактан туура эмес: CPU убактысынын дээрлик баарын так GPU топтой ала турган бөлүгүнө сарптайт. 80 комбинация × 7 WMA өтүшүн чоң топтолгон конволюциялар катары кайра формулировкалаңыз, жана толук нормалдуу тензор жумушу чыгат. Ошондуктан ак ниеттик суроо — иш GPU'га которула алабы эмес — көбүнчө которула алат. Суроо — саякат акталабы, жана бул сканирлөө үчүн эки конкреттүү себептен улам ал акталбайт:

1. Пайдаланылуучу кеңдик — 80 комбинация — жана GPU кеңдик машинасы. Параметр сканирлөөдөгү жалгыз ак ниеттик параллелизм огу — тордун өзү: бир комбинация ичинде, 150,000-бардык жол ырааттуу. GPU лазерлерин толтуруу жана кечигүүнү жашыруу үчүн ондогон миңдеген көз карандысыз иш бирдигин каалайт; бул сканирлөө сексен гана берет. Он эки CPU ядросу мурунтан эле ошол кеңдикти толтурат — так ушуну M3–M4 баскычтары өлчөгөн. GPU'дун кеңдиги дегеле байланыша баштагыдай комбинация санынан баштап, CPU шаты мурунтан эле секундасына жүздөгөн толук бэктест берип жатат.

2. Бүткүл жумуш 0.23 секунд. M4 ылдамдыгында бир комбинация болжол менен 2.9 мс турат (алынган: 0.23 с / 80). Бул бюджетке салыштырмалуу, ядро-иштетүү кечигүүлөрү жана түзмөк синхрондоштуруу чекиттери — амортизацияланбаган тегеректөө катачылыктары эмес — булар жумуштун олуттуу бөлүгү. (Бул унификацияланган эс-тутуктуу Apple машинасында, host-to-device которуу анча маанилүү эмес; ажыратылган-GPU CUDA машинасында ал да чотко кошулат.) Классикалык GPU утушу катталган чыгымдарды эбегейсиз көп жумуш пакеттери боюнча амортизациялайт; секунддан аз сканирлөө муну эч качан жаратпайт.

Ал эми окуялар циклычы? Бул — топтолбой турган жалгыз бөлүк — ырааттуу, тармактанган, жол-көз көз каранды, бир комбинация ичинде эч бир аппарат параллелдештире албай турган, SIMT лазерлери жаман көрчү дал ошол ажырама тармактары бар, 150,000 бар узундугундагы циклдин-алып жүрчү көз карандылык. GPU которуусу аны CPU'до калтырмак же комбинация башына бир лазерди иштетмек. Бирок ядронун 0.7%'и болгондуктан, бул эч нерсени чечкидей эмес Amdahl термини. Бул — которула албай турган бөлүк; которбоо себеби эмес. (M1 баскычынан эстеңиз — тескери байланышы жок ядролор үчүн цикл аналитикалык жол менен дагы деле вектордоштурула алат — стратегия стоп-лосс менен чоңойгон замат жоголуучу кайра жазуу.)

Толуктук үчүн бир платформалык эскертме: бул машинада (Apple Silicon) GPU жолу — MLX же PyTorch-MPS болмок, CUDA эмес — cupy жана CUDA экосистемасы жөн эле колдонулбайт — жана экөө тең экспериментти аракет кылуу үчүн эле ысык жолду тензор диалектине кайра жазууну талап кылмак. Бул — жогорудагы анализ боюнча бул сканирлөөнүн формасы үчүн эч бир аныкталган пайдасы жок, чыныгы баа. Бул жердеги GPU талкуусу аналитикалык, өлчөнгөн арифметикалык интенсивдүүлүккө жана өлчөнгөн функция/цикл бөлүнүшүнө негизделген, жана биз аны ушундай деп белгилейбиз: эч бир CUDA иштетилген жок, анткени ачык эмес аппаратта эч бирине мүмкүн болгон эмес.

Кароодо коргой турган корутунду сүйлөм: бул иштин дээрлик баары GPU'га которула алат; бул сканирлөө саякат акталышы үчүн өтө тар жана өтө кыска. Жана муну эки багытта окуп чыгыңыз — бул четке кагуу эмес. Топтолгон "чоң-матрица" кайра формулировкасы — сканирлөөнү миңдеген комбинация боюнча бир жолу чоң тензор операциялары катары кайра түзүү, же чыныгы тескери-байланышсыз, аягынан аягына чейин топтолгон ядро — четке кагылчу эмес, өзүнчө изилдөөгө татыктуу чыныгы жана убадалуу багыт. 80 комбинацияда жана 0.23 секундда, ал жөн эле бул билетти дагы алган жок. Эгер сиздин жумушуңуз ошол кеңдикке ээ болсо, арифметика өзгөрөт, жана муну сиз кайра жасашыңыз керек, бизди эмес, келтирип.

Чыныгы тыгыздык кайда: машина жана оркестрация

The real bottleneck revealed: an hourglass where the engine and the orchestration of thousands of parameter combos choke the flow, not the hardware underneath

Сексен комбинация — көрсөтмө тор. Чыныгы параметр издөө — бул факторлор академиялык болбой калган жер, анткени торлор көбөйтмө түрдө өсөт: ар бирине он маани болгон төрт параметр — 10410^4 комбинация; walk-forward валидациясын он эки катмар менен кошсоңуз, эч нерсени изилдебей туруп 1.2×1051.2 \times 10^5 толук бэктестке жетесиз. Бул — өлчөмдүүлүк каргышы, жана дал ошондуктан издөө стратегиясы — Optuna, координата боюнча түшүү, Sobol — ушунчалык көп көңүл бурат: акылдуу издөө азыраак чекитти зыярат кылат.

Бирок шат теңдиктин дагы бир, азыраак талкууланган жарымын ачат: зыярат кылынган чекит башына баа. Өлчөнгөн өткөрүмдүүлүктөрдү сызыктуу түрдө экстраполяциялап (комбинациялар көз карандысыз, ошондуктан бул моделдөө эмес, арифметика):

Тор көлөмү M0'до (1.1 комбо/с) M4'до (340.9 комбо/с)
10,000 комбинация ~2.4 саат ~30 секунд
100,000 комбинация ~24 саат ~5 мүнөт

Жөнөкөй машинада түн бою пакеттик жумуш болгон эксперимент, тюндалган машинада интерактивдик сурам болуп калат. Бул айырма wall-clock таблицасы билдиргенден да кыйла эффект берет: 5 мүнөттүк сканирлөөдо сиз кайталайсыз — оңдолгон агып чыгуу менен кайра иштетесиз, катмар кошосуз, торду кеңейтесиз, түшкү тамактан келген идеяны текшересиз. 24 сааттык сканирлөөдо — жок. Машинанын ылдамдыгы изилдөө циклынын темпин белгилейт, ал эми изилдөө циклынын темпи чыныгы продукт.

Бүткүл шаттын Amdahl мыйзамы окулушу да бар:

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

Каалаган бир pp стадиясын ss эсеге ылдамдатуу калгандардын баары жай калтырылгандыгы менен чектелет. Шат бул тартипти сактады: 35.3x машина утушу үстөмдүк кылган терминге чабуулду жасады (функция стегиндеги жана циклдеги интерпретацияланган итерация), ал эми 8.4x оркестрация утушу мунадан кийин үстөмдүк кылган термин (он бир бош ядро) чабуулду жасады. Функция/цикл бөлүнүшү дал ушул сабактын кичине версиясы — убакыт чыныгы кайда кеткенин өлчөбөй туруп, GPU аргументинин чыныгы формасын атай алмак эмеспиз. Адегенде профилдеш, андан кийин оптимизациялаш — ушул тартипте. Ошол эле логика машинадан жогору жаткан дайын катмарын да башкарат: биздин Polars vs pandas бенчмарктарыбыз стектин жүктөө-жана-өзгөртүү жарымы үчүн так ошол эле үлгүнү тапты (топтолгон rolling түтүктөрдө 10–3500x), жана ошол эле гибриддик корутунду — түтүк үчүн колонналуу машиналар, жол-көз көз каранды симуляция үчүн компиляцияланган ядро.

Жалпылыктын жабылышы үчүн эки ак ниеттик эскертме. Биринчиден, бул эксперимент атайын өз алдынча жана синтетикалык — урук берилген дайын, бир ядро, бир ачык аппарат — ошондуктан ар ким муну детерминизацияланган түрдө кайра чыгара алат; wall-clock сандары сиздин аппаратыңызда башкача болот, бирок эквиваленттик жана шаттын багыты өзгөрбөйт. Экинчиден, бул кубулуш синтетикалык орнотуунун жасалмасы эмес: биздин өндүрүштүк HMA машинабыздын бенчмаркы (bench_param_sweep.py, чыныгы биржа дайыны менен, толук өндүрүштүк комиссия жана толтуруу модели менен иштетилген) ошол эле шат формасын көрсөтөт, numba жолу жөнөкөй pandas профилинен болжол менен 100–200x бийик турат. Өз алдынча эксперимент бар болушунун себеби — биздин өндүрүштүк сандарга ишенип отурбашыңыз керек эместиги.

Корутундулар

  1. Шат — 298x, жана ал: 35.3x машина × 8.4x оркестрация болуп бөлүнөт. Итерацияны интерпретатордон чыгаруу (pandas → numba) жана көз карандысыз комбинацияларды ядролор боюнча жайылтуу (бир → он эки) көбөйтүлүп, өзгөрбөгөн ноутбукта үч даражага жакын ылдамдоону берди. 69.92 с → 0.23 с; 1.1 → 340.9 комбо/с. Жана бул жай база жасалмасы эмес: натыйжалуу вектордоштурулган numpy ишке ашыруусуна салыштырмалуу, бүткөрүлгөн машина дагы деле ~13x.
  2. Ылдамдыкка суктанганга чейин эквиваленттикти талап кылыңыз. Мында ар бир баскыч бирдей комбинациялык PnL жана сделка санын берет, 80 комбинациянын баарында автоматтык түрдө текшерилген (PnL боюнча 10610^{-6} абсолюттук чектөө, сделкалар боюнча так). Кыйла нерсени эсептечү тез машина тез эмес — ал жогорку өткөрүмдүүлүктө туура эмес, жана вектордоштурулган кайра жазуулар — туура эместик көп жашырынуучу жер.
  3. Абалдуу логика үчүн @njit айлакер вектордоштуруудан жогору. numpy баскычы стоп-лосс кошулган замат өлүп калуучу стратегияга тиешелүү жабык формула талап кылды. numba баскычы жөнөкөй, текшерилүүчү циклди компиляциялайт — ошол эле ылдамдык классы, эч бир начарлык жок, жана бул — параллелдешчү бирдик.
  4. GPU жообу — "бул сканирлөө үчүн эмес" — эске сакталчу себептер менен. Функция математикасы эсептеш-мүнөздүү (10.78 FLOP/byte) жана ал компиляцияланган ядронун 99.3%'и, ошондуктан "бэктесттер эс-тутумга көз каранды" деген доомот да, "абалдуу цикл үстөмдүк кылат" деген доомот да өлчөөдөн аман калбайт. Ак ниеттик себептер — кеңдик жана бюджет: 12 CPU ядросу мурунтан эле толтурган 80 пайдаланылуучу параллелизм комбинациясы, жана иштетүү жана синхрондоштуруу чыгымы жей турган 0.23 с толук жумуш. Чыныгы кеңдиктеги топтолгон чоң-матрица кайра формулировкасы убадалуу багыт бойдон калат, четке кагылган нерсе эмес.
  5. Машина ылдамдыгы — изилдөө темпи. Жөнөкөй машинанын өткөрүмдүүлүгүндө, 100,000-бэктесттик издөө бир күн; шаттын учундагы өткөрүмдүүлүктө — беш мүнөт. Аппарат сатып алганга же кластер ижарага алганга чейин, тыгыздык дегеле силикон эмеспи текшериңиз — биздики rolling.apply ичиндеги lambda жана он бир бош ядро болчу.

Бүткүл эксперимент — беш ишке ашыруунун баары, эквиваленттик гарнесс, roofline эсептөө, жана бул макаладагы ар бир сан бир детерминизацияланган скрипттен кайра чыгарылуучу — speed-ladder.marketmaker.cc дарегиндеги коштоочу макалада, код жана дайындар github.com/suenot/backtest-speed-ladder дарегинде.

Жетимиш секунд алган сканирлөө азыр анын төрттөн бирин алат. Ошол эле сделкалар, ошол эле PnL, ошол эле ноутбук. Сиз сатып алганы жаткан GPU күтө алат; сиз жеткиргени жаткан интерпретатор циклы — жок.

blog.disclaimer

Authors

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

Рынктан бир кадам алдыда болуңуз

AI соода аналитикасы, рынок талдоолору жана платформа жаңылыктары үчүн биздин жаңылыктар бюллетенине жазылыңыз.

Биз сиздин купуялыгыңызды урматтайбыз. Каалаган убакта жазылымдан чыга аласыз.