Бэктест жылдамдығының сатысы: ноутбук CPU-да 298 есе, соңғы транзакцияға дейін бірдей PnL
"Иллюзиясыз бэктестер" сериясының бір бөлігі.
📄 Бұл мақала зерттеу жұмысына айналды. Бір жол-тәуелді бэктест ядросы бес түрлі жолмен іске асырылған — қарапайым pandas-тан параллельді numba ядросына дейін — әр саты комбинация бойынша бірдей PnL беретіні қайта тексерілген, сондықтан жалғыз айырмашылық — жылдамдық. Жұмысты онлайн оқыңыз (интерактивті нұсқа + PDF): speed-ladder.marketmaker.cc, код пен деректер: github.com/suenot/backtest-speed-ladder.
Жетпіс секунд. Қарапайым эталондық имплементация бір жылжымалы орташа стратегияның 80 параметр комбинациясын 150,000 бар бойынша сканерлеуге дәл сонша уақыт кетіреді: индикаторлар үшін pandas rolling().apply(), транзакциялар үшін жай Python циклі. Бұл — нақты дүниедегі зерттеу кодының үлкен бөлігі жұмыс істейтін профиль, өйткені стратегияны айқын жолмен жазғанда осы профиль табиғи түрде шығады.
Дәл сол сканерлеу, дәл сол ноутбукте, әр комбинация үшін соңғы транзакцияға дейін дәл сол PnL беретіні: 0.23 секунд.
Осы екі санның арасындағы алшақтық — өлшенген 298 есе — осы мақаланың тақырыбы. Оның бірде-бір пайызы жаңа аппараттан келмеді. Ешқандай GPU қатыспады (бұл машинада CUDA мағынасында ондай мүлде жоқ). Сатының әр сатысы — дәл сол стратегия, дәл сол деректер, дәл сол комиссиялар, дәл сол транзакция саны, кез келген имплементацияның комбинация бойынша нәтижелері ажырап кетсе бүкіл бенчмаркты құлататын эквиваленттілік қақпасымен тексерілген. Өзгергені тек — жұмыс қалай өрнектелетіні: интерпретаторда не жұмыс істейді, компиляцияланғанда не жұмыс істейді, параллельде не жұмыс істейді. Ал әдейі баяу базис кез келген тақырыптық санды әдемілендіретіндіктен, тағы бір сан алдын ала: тіпті жарамды векторланған numpy имплементациясына қарсы — күшті numpy программисі жеткізетін кодқа — дайын қозғалтқыш әлі шамамен 13 есе жылдамырақ.
Параметр іздеуі баяу болғанда, рефлекс — үлкенірек аппаратқа жүгіну: GPU, кластер, бұлттық бюджет. Осы тәжірибенің өлшенген шындығы әлдеқайда даңқсыз жерге меңзейді: тар жер қозғалтқыш болды (терезе сайын Python шақыруларын жасайтын интерпретацияланған ішкі цикл) және оркестрация (тәуелсіз комбинацияларды бір ядрода тізбектей орындау). Екеуін де сіз бұрыннан иеленетін машинада, нәтижеге нөлдік өзгеріспен, бір түс уақытында түзетуге болады.
Міне бүкіл саты алдын ала. Төмендегінің бәрі — әр қадамның анатомиясы.
| Саты | Имплементация | Нақты уақыт | Жеделдету | Комбинация/с |
|---|---|---|---|---|
| M0 | pandas: rolling.apply + Python бар циклі |
69.92 s | 1.0x | 1.1 |
| M1 | numpy: sliding-window WMA + векторланған транзакциялар | 3.07 s | 22.7x | 26.0 |
| M2 | numba: @njit WMA + @njit оқиға циклі |
1.98 s | 35.3x | 40.4 |
| M3 | numba prange: комбинациялар бойынша ағындар |
0.32 s | 217.6x | 248.9 |
| M4 | процесс пулы + numba: комбинациялар бойынша процестер | 0.23 s | 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 мен транзакция санын беретіні тексерілген.
Бір ядро, бес имплементация

Жылдамдықты салыстыру бір нәрсе білдіру үшін, есептелетін зат дәл бекітілуі керек, әрі әр имплементация оны есептейтіні дәлелденуі керек. Сондықтан тәжірибе бір стратегия ядросын бекітіп, оны бес сатының барлығында тұрақты ұстайды.
Ядро — HMA/HMA3 қиылысы — екі Hull-тектес жылжымалы орташаға негізделген тоқтап-бұрылу жүйесі. Құрылыс блогы — салмақталған жылжымалы орташа:
Hull Moving Average кідірісті азайту үшін олардың үшеуін құрастырады:
ал HMA3 — шамамен , және WMA-лардан құрастырылған, тағы бір рет тегістелген жұмсағырақ туысы. Әр параметр комбинациясына бұл алты түрлі терезе ұзындығы бойынша жеті WMA өтуі — ойыншық емес, нағыз индикатор стегі.
Сауда ережесі әдейі, әрі пайдалы түрде күй сақтайтын: бағыт — HMA HMA3-тен төмен болғанда long, әйтпесе short; бірінші анықталған бағытта позиция ашылады; әр қиылыста позиция жабылады, PnL 0.09% толық айналым комиссиясын шегеріп есептеледі, әрі бұрылу орындалады. Позиция барлар арасында тасымалданады — барында не істейтіндігіңіз соңғы қиылыстан бері жинақталған күйге тәуелді. Осы жол-тәуелділік — тәжірибенің бүкіл мәні: дәл осы қасиет бэктестерді жалпы dataframe құбырларынан ерекшелендіреді, әрі (кейін өлшейтініміздей) GPU мәселесін күрделендіреді — бірақ, көрінгендей, аңыз айтқан жолмен емес.
Қалған баптау, сандарды бағалай алуыңыз үшін:
- Деректер: тұқымдалған (
seed=42) синтетикалық геометриялық Броундық қозғалыстың 150,000 бары. Мұндағы өнімділік массив өлшемі мен терезе ұзындықтарымен байланысты, қандай баға жолын бергеніңізбен емес — әрі синтетикалық қатар бүкіл тәжірибені детерминистік және кез келген адам қайталай алатын етеді. - Тор: аралығына жайылған 80 түрлі HMA ұзындығы — сондықтан сканерлеу арзан қысқа терезелі комбинацияларды да, қымбат ұзын терезелілерді де қамтиды, нағыз тор сияқты.
- Уақыт өлшеу: нақты сағат, саты сайын ең жақсы 3-тен, JIT компиляциясы таймердан тыс қыздырылған әрі пул жұмысшылары сағат басталмай тұрып қыздырылған. Әр саты — pandas базисін қоса — барлық 80 комбинация бойынша толығымен өлшенген. BLAS (Apple-дың Accelerate) бір ағынға бекітілген, сондықтан бір ағынды сатылар шынымен бір ядролы: numpy сатысы салыстырудың артында өз matvec-терін жасырын көп ағында орындамайды.
- Эквиваленттілік қақпасы: уақыт өлшеуден кейін әр сатының комбинация бойынша (PnL, транзакция саны) векторы эталонмен салыстырылады — транзакция сандары дәл сәйкес келуі керек, PnL абсолютті пайыздық пункт шегінде. Тіркелген іске қосу әр саты үшін, pandas базисін қоса, барлық 80 комбинацияда
all_ok: trueхабарлайды. Егер бұл қақпа құласа, бенчмарк жоқ — тек бес түрлі затты бес түрлі жылдамдықта есептейтін бес программа бар, ал "біздің қозғалтқыш 100 есе жылдам" деген талаптардың көбі осылай жасырын жұмыс істейді.
Эквиваленттілік блогынан бір сан бір сәтке шыншыл болуға тұрарлық: бірінші комбинацияның саусақ ізі — 57,029 транзакция бойынша −5165.58 пайыздық пункт PnL. Бұл ұялатын стратегия нәтижесі емес — бұл ең қысқа HMA ұзындығы (6) кездейсоқ серуеннің кез келген дерлік ауытқуында аударылып, әр рет 0.09% төлейді, дәл болуы тиісінше. Бұл — дұрыстық саусақ ізі, сатылатын бэктест емес. Онда альфа оқымаңыз; онда детерминизм оқыңыз — бес имплементацияның дәл сол 57,029 транзакцияға және алты ондыққа дейін дәл сол PnL-ге қонуы — "бірдей" деген осы жерде нені білдіреді.
Осы орнатылғаннан кейін, төмендегі әр жеделдету — таза жылдамдық. Ешнәрсе жуықтап тасталған жоқ.
M0 сатысы: қарапайым pandas профилі — 69.9 s

Базис — сабан бөгет емес. Бұл — 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 (кем дегенде lambda-ға Series орнына жалаң ndarray беретін) болса да, шақыру сайынғы интерпретатор үстеме шығыны терезеге шынымен қажет ондаған-жүздеген FLOP-тан әлдеқайда асып түседі. Мұны комбинация сайын жеті WMA өтуіне көбейтіңіз, сонда индикатор стегінің өзі миллиондаған интерпретатор жүрістерінен тұрады. Одан кейін бар циклі комбинация сайын тағы 150,000 интерпретацияланған итерацияны орындайды, әрқайсысы numpy скалярларына шекара тексерілген индекстеу жасайды, float-тарды қорапшалайды, әрі интерпретатор әр жолы қайта ашатын типтерге динамикалық түрде диспетчерлейді.
Нәтиже: сканерлеуге 69.92 s, комбинацияға шамамен 0.87 s, секундына 1.1 комбинация өткізу қабілеті. 80 комбинациялы торда иығыңызды қиқаң еткізіп бір минут күтесіз. Мәселе — 80 комбинациялы торларды ешкім ұзаққа қоспайды — әрі бұл шығын мәңгі сызықты өседі. Бұған қайта ораламыз.
M1 сатысы: numpy — циклде Python шақыруды тоқтатыңыз — 3.07 s, 22.7x
Бірінші жоғарғы саты екі интерпретатор циклін де бір мезгілде жояды, әрі екі айла-әдісті бөліп қараған жөн, өйткені олардың жалпылығы мүлде әртүрлі.
Индикатор жағы — оңай, толық жалпы жақ. Барлық терезелер бойынша салмақталған жылжымалы орташа — кірістің страйдталған көрінісіне қарсы жай матрица–вектор көбейтіндісі — көшірмесіз, бір 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) көрінісін құрады, ал win @ w әр терезенің скаляр көбейтіндісін компиляцияланған кодта есептейді. Миллион lambda шақыруы бір кітапхана шақыруына айналады.
Транзакция жағы — қызық жағы, өйткені оқиға циклі күй сақтайды — әйтсе де, осы ядро үшін ол векторланады. Түйін — кез келген бардағы позиция тек 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 s, 22.7 еселік жеделдету, секундына 26.0 комбинация — бір ядрода, BLAS бір ағынға бекітілген. Бұл сатыға белгі лайық: бұл — жарамды базис, күшті numpy программисі жеткізетін имплементация, әрі одан жоғарғының бәріне әділ өлшем. Бірақ бұл сатымен бірге екі шыншыл ескерту жүреді.
Біріншіден, бұл векторлау — стратегияға тән аналитикалық қайта жазу, механикалық түрлендіру емес. Ол ядро тоқтап-бұрылатын, стоптары жоқ, ілеспелі шығулары жоқ, ағымдағы PnL-ге тәуелді позиция мөлшерлеуі жоқ болғандықтан бар. Стоп-лосс қосыңыз — елестетуге болатын ең қарапайым мүмкіндік — сонда барындағы шығу барында қандай кіру болатынын өзгертеді, күй жолға кері байланыс береді, әрі жабық түр буланып кетеді. Өндірістік ядролардың көбі сол сызықтың дұрыс емес жағында өмір сүреді.
Екіншіден, бұл — дұрыстық өлетін саты. Ауысу индексін есепке алу (мұнда +1, ана жерде [:-1], бірінші бағытты тұқымдау) — дәл off-by-one орындау бұзушылықтарын тудыратын код түрі — біздің look-ahead таксономиямыз шудан 15-тік Шарп жасай алатынын көрсеткен бұзушылықтардың дәл сол түрі. Эквиваленттілік қақпасы бұл сатыда формалдылық емес; оған сенудің жалғыз себебі — сол. Қарапайым эталондық имплементацияға қарсы эквиваленттілік тексеруінсіз ақылды векторланған қайта жазулар — қозғалтқыштар тексеретінін мәлімдейтін стратегиядан осылай ажырап кетеді.
M2 сатысы: numba — шынымен жазғыңыз келетін циклді компиляциялаңыз — 1.98 s, 35.3x

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 s — pandas-тан 35.3 есе, бірақ numpy-дан небәрі шамамен 1.6 есе (шығарылған: 3.07/1.98). Сол қарапайым қадамның өзі тағылымды: numpy-дың ішкі циклдері қазірдің өзінде компиляцияланған болатын, сондықтан numba-ның мүмкіндік математикасындағы ұтысы терезе материализациясын және аралық массивтерді өткізіп жіберумен шектеледі. Түрлендіргіш бөлік — басқа жерде:
- Оқиға циклі енді тегін — әрі "тегін" өлшенген, риторикалық емес. M1 өз ақылдылығын транзакция логикасын векторлануға жарамды етуге жұмсады. M2 сол ақылдылықты керексіз етеді — қарапайым, аудиттелетін, өзгертуге оңай цикл машина жылдамдығында жұмыс істейді. Осы компиляцияланған ядро ішінде мүмкіндік кезеңін транзакция циклінен бөлек өлшеу оның уақытының 99.3%-ын WMA мүмкіндік математикасына және небәрі 0.7%-ын күй сақтайтын оқиға цикліне жатқызады. Ертең зерттеу жобасынсыз стоп-лосс қоса аласыз — әрі сол бөлінуді есте ұстаңыз; ол төмендегі GPU дәлелін қайта шешеді.
- Ол келесі екі сатыны ашады. Компиляцияланған, GIL-босататын, аз-бөлінулі ядро — параллельді оркестрацияға қажет жұмыс бірлігі. M0-ны өнімді түрде параллельдей алмайсыз — баяудың он екі көшірмесі әлі баяу, жай жылырақ.
Бір әдіснамалық ескерту: numba бірінші шақыруда компиляцияланады, әрі сол компиляция (жүздеген миллисекунд) таймердың ішінде болмауы керек — жарнама (harness) өлшемес бұрын JIT-ті 500-барлы кесіндіде қыздырады, әрі cache=True компиляцияланған ядроларды процесс іске қосулары арасында сақтайды. Осы детальді "ұмытатын" бенчмарктер не әділетсіз нашар (суық компиляция қосылған), не қайталанбайтын numba сандарын шығарады.
M3 сатысы: prange — сізде бұрыннан бар параллелизм — 0.32 s, 217.6x

Міне жаппай параметр іздеуін ерекше ететін бақылау: 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 s — pandas-тан 217.6 есе, секундына 248.9 комбинация. Бір ағынды M2-ден асқан қадам 12 ядрода шамамен 6.2 есе (шығарылған: 1.98/0.32), әрі "идеал 12 есеге" жетпей қалуын жасырудың орнына шыншыл болған жөн: M2 Max-тың 12 ядросы — 8 өнімділік + 4 тиімділік ядросы, сондықтан номиналды шек ешқашан 12 есе болмаған; 80 комбинацияның шығындары мейлінше тең емес (6-ұзындықты HMA 200-ұзындықтыдан әлдеқайда арзан), сондықтан ағындар біркелкі емес аяқтайды; әрі әр ядро шақыруы өз аралық массивтерін ортақ бөлгіштен бөледі. Нақты машиналардағы параллель жеделдетулер осылай көрінеді. Гетерогенді тапсырмалар үшін N-ядрода таза N-есе келтіретін кез келген адам синтетикалық бірдеңені өлшеп жатыр.
M4 сатысы: соңғы үштен бірге процесс пулы — 0.23 s, 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 s — pandas-тан 297.9 есе, секундына 340.9 комбинация. Сол өткізу қабілетін қайта оқыңыз: бұл ноутбук енді секундына шамамен 340 толық 150,000-барлы бэктест жүргізіп жатыр, әрқайсысы жеті салмақталған жылжымалы орташаны есептейді әрі ондаған мың күй сақтайтын транзакцияны имитациялайды.
prange-тен артықшылық нақты, бірақ қарапайым — шамамен 1.4 есе (шығарылған: 0.32/0.23) — әрі ықтимал механикасы — жоспарлау мен жад оқшаулауы: chunksize=1 болғанда пул комбинацияларды бір-бірлеп таратады, сондықтан арзан және қымбат терезелердің біркелкі емес қоспасы асимметриялы ядролар бойынша динамикалық түрде жүктеме теңгеріледі, әрі әр жұмысшы процесс өз бөлгішін алады, комбинация сайынғы уақытшаларға таласты айналып өтеді. Бұларды өлшеммен үйлесімді механика ретінде хабарлаймыз, бөлек дәлелденген фактілер ретінде емес.
Процестер тегін емес, әрі жарнама олардың шығындарын таймердан тыс, олар бір реттік шығын болатын жерде шыншыл түрде төлейді (жұмысшыны іске қосу, close-ты әр жұмысшыға инициализатор арқылы жеткізу, жұмысшы сайынғы JIT қыздыру) — өйткені нағыз іздеуде сол шығындар сексен емес, мыңдаған комбинация бойынша амортизацияланады. Шыншыл жалпы нұсқаулық: prange қарапайымырақ әрі әдетте жеткілікті; процесс пулы тапсырмалар ірі, тор үлкен, немесе комбинация сайынғы жұмысыңыз GIL-ді numba жете алмайтын бір жерде ұстағанда ұтады.
Осымен саты таза қорытындыға факторланады. M0-дан M2-ге — қозғалтқыш: бір ядрода 35.3 есе, итерацияны интерпретатордан шығарудан. M2-ден M4-ке — оркестрация: тағы 8.4 есе (шығарылған: 1.98/0.23), бұрыннан бар ядроларды пайдаланудан. Көбейтілген: 298 есе. Жаңа аппарат жоқ, бірдей нәтижелер. Ал қарапайымның орнына жарамды M1 базисінен өлшенгенде, дайын қозғалтқыш әлі шамамен 13 есе жоғары тұр (шығарылған: 3.07/0.23) — саты баяу бастапқы нүктені таңдаудың артефакті емес.
Неге GPU емес — шыншыл нұсқа

"Оны жай GPU-ға көшір" — баяу параметр сканерлеуіне ең жиі жауап, сондықтан бұл тәжірибе сол әңгіме басталуы тиіс екі санды өлшейді — әрі екеуінің де жалқау нұсқасын ешқайсысы қолдамайды.
Roofline моделі (Williams, Waterman & Patterson, 2009) ядроны оның арифметикалық интенсивтілігі бойынша жіктейді — жылжытылған байт сайынғы FLOP. Осы сканерлеудегі WMA мүмкіндік стегі үшін бар сайын ұзындықты терезе сайын FLOP-ты бар сайын бір 8-байтты оқуға қарсы санағанда, бүкіл 80 комбинациялы сканерлеу 576 MB ағызылған бойынша шамамен 6.2 GFLOP шығады:
(Бұл — комбинация сайынғы алты түрлі 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 ms тұрады (шығарылған: 0.23 s / 80). Сол бюджетке қарсы ядро-іске қосу кідірістері мен құрылғы синхрондау нүктелері амортизацияланатын дөңгелектеу қателіктері емес — олар жұмыстың елеулі бөлігі. (Осы біріктірілген жадты Apple машинасында хосттан-құрылғыға тасымалдау — шағын мәселе; дискретті-GPU CUDA қорабында ол да есепке қосылады.) Классикалық GPU ұтысы тұрақты үстеме шығындарды орасан жұмыс топтары бойынша амортизациялайды; секундтан аз сканерлеу ешқашан ондай тудырмайды.
Ал оқиға циклі ше? Ол — топтаспайтын жалғыз бөлік — тізбектей, тармақталған, жол-тәуелді, комбинация ішінде ешбір аппарат параллельдей алмайтын 150,000 бар ұзындықты цикл-тасымал тәуелділігі, SIMT жолдары жек көретін дәл сол дивергентті тармақтармен. GPU порты оны CPU-да қалдырар еді немесе комбинация сайын бір жолда жүргізер еді. Бірақ ядроның 0.7%-ында ол — ешнәрсе шешуге тым кіші Амдал мүшесі. Ол — бармайтын бөлік; ол — бармаудың себебі емес. (M1 сатысынан еске түсіріңіз: кері байланыссыз ядролар үшін цикл тіпті аналитикалық түрде векторлануы мүмкін — стратегия стоп өсіргенде жоғалтатын қайта жазу.)
Толықтық үшін бір платформа сілтемесі: осы машинада (Apple Silicon) GPU жолы MLX немесе PyTorch-MPS болар еді, CUDA емес — cupy мен CUDA экожүйесі жай қолданылмайды — әрі кез келгені тәжірибені байқап көру үшін ыстық жолды тензор диалектінде қайта жазуды талап етер еді. Бұл — жоғарыдағы талдау бойынша осы сканерлеу пішіні үшін анықталған төлемі жоқ нақты шығын. Мұндағы GPU талқысы аналитикалық, өлшенген арифметикалық интенсивтілік пен өлшенген мүмкіндік/цикл бөлінуіне негізделген, әрі оны солай белгілейміз: ешбір CUDA іске қосу орындалмады, өйткені ашылған аппаратта ешбірі мүмкін болмады.
Тексеруде қорғайтын қорытынды сөйлеміміз: осы жұмыстың дерлік бәрі GPU-ға бара алар еді; бұл сканерлеу сапар өтелу үшін тым тар әрі тым қысқа. Әрі оны екі бағытта да оқыңыз — бұл — есептен шығару емес. Топтастырылған "үлкен-матрица" қайта тұжырымдауы — сканерлеуді бір мезгілде мыңдаған комбинация бойынша үлкен тензор операциялары ретінде қайта құру, немесе шетінен шетіне топтастыратын шынымен кері байланыссыз ядро — бас тарту емес, арнайы зерттеуге лайық нақты әрі перспективалы бағыт. 80 комбинация мен 0.23 секундта ол әзірше билетті жай еңбегімен алмады. Егер жұмыс жүктемеңізде сол ен болса, арифметика өзгереді, әрі оны бізден дәйексөз алмай, қайта жасауыңыз керек.
Нағыз тар жер қайда: қозғалтқыш пен оркестрация

Сексен комбинация — демонстрациялық тор. Нағыз параметр іздеуі — осы факторлар академиялық болуын тоқтататын жер, өйткені торлар көбейтіндіде өседі: он мәннен төрт параметр — комбинация; оған ондаған қатпары бар walk-forward валидациясын қосыңыз, сонда ештеңе зерттемей тұрып толық бэктестке жетесіз. Бұл — өлшемділік қарғысы, әрі сол себепті іздеу стратегиясы — Optuna, координаттық түсу, Sobol — соншама назарға ие болады: ақылдырақ іздеу азырақ нүктеге барады.
Бірақ саты теңдеудің басқа, азырақ талқыланатын жартысын әшкерелейді: барған нүкте сайынғы шығын. Өлшенген өткізу қабілеттерін сызықты экстраполяциялап (комбинациялар тәуелсіз, сондықтан бұл — модельдеу емес, арифметика):
| Тор өлшемі | M0-де (1.1 комбинация/с) | M4-те (340.9 комбинация/с) |
|---|---|---|
| 10,000 комбинация | ~2.4 сағат | ~30 секунд |
| 100,000 комбинация | ~24 сағат | ~5 минут |
Қарапайым қозғалтқышта түнгі топтамалық жұмыс болатын дәл сол тәжірибе бапталғанда интерактивті сұрау болады. Сол айырмашылық нақты-сағат кестелері толық жеткізбейтін жолмен қорланады: сканерлеуге 5 минутта сіз итерациялайсыз — түзетілген ағумен қайта қосасыз, қатпар қосасыз, торды кеңейтесіз, түскі ас кезінде келген идеяны сынайсыз. Сканерлеуге 24 сағатта — жасамайсыз. Қозғалтқыштың жылдамдығы зерттеу циклінің қарқынын белгілейді, ал зерттеу циклінің қарқыны — нақты өнім.
Бүкіл сатының Амдал заңы бойынша оқылуы да бар:
Кез келген жеке кезеңін факторына жеделдету — баяу қалдырған қалғанының бәрімен шектелген. Саты сол ретті құрметтеді: 35.3 еселік қозғалтқыш ұтысы басым мүшеге шабуылдады (интерпретацияланған итерация, мүмкіндік стегінде де, циклде де), әрі 8.4 еселік оркестрация ұтысы одан кейін басым болған мүшеге шабуылдады (он бір бос ядро). Мүмкіндік/цикл бөлінуі — дәл сол сабақ шағын түрде — уақыт нақты қайда кеткенін өлшемей, GPU дәлелінің нағыз пішінін атай алмас едік. Профильдеңіз, содан кейін оптимизациялаңыз — дәл сол ретте. Дәл сол логика қозғалтқыштан жоғарыдағы деректер қабатын басқарады: біздің Polars vs pandas бенчмарктеріміз стектің жүктеу-және-түрлендіру жартысы үшін бірдей үлгіні тапты (топтастырылған жылжымалы құбырларда 10–3500 есе), әрі дәл сол гибридті қорытынды — құбыр үшін бағаналы қозғалтқыштар, жол-тәуелді имитация үшін компиляцияланған ядро.
Жалпылық туралы циклді жабатын екі шыншылдық ескертуі. Біріншіден, бұл тәжірибе әдейі өзіндік-тұйықталған әрі синтетикалық — тұқымдалған деректер, бір ядро, бір ашылған машина — сондықтан кез келген адам құбылысты детерминистік түрде қайталай алады; нақты-сағат сандары сіздің аппаратыңызда өзгеше болады, бірақ эквиваленттілік пен сатының бағыты өзгермейді. Екіншіден, құбылыс синтетикалық баптаудың артефакті емес: біздің өндірістік HMA қозғалтқышының бенчмаркі (bench_param_sweep.py, нақты биржа деректерінде толық өндірістік комиссия мен толтыру моделімен іске қосылған) дәл сол саты пішінін көрсетеді, numba жолы қарапайым pandas профилінен шамамен 100–200 есе жоғары қонады. Өзіндік-тұйықталған тәжірибе — біздің өндірістік сандарымызды сеніммен қабылдамауыңыз үшін бар.
Түйіндер
- Саты — 298 есе, әрі ол факторланады: 35.3 есе қозғалтқыш × 8.4 есе оркестрация. Итерацияны интерпретатордан шығару (pandas → numba) және тәуелсіз комбинацияларды ядролар бойынша тарату (бір → он екі) өзгермеген ноутбукте үш реттік-шамаға-жақын жеделдетуге көбейді. 69.92 s → 0.23 s; 1.1 → 340.9 комбинация/с. Әрі бұл — баяу-базис артефакті емес: жарамды векторланған numpy имплементациясына қарсы дайын қозғалтқыш әлі ~13 есе.
- Жылдамдыққа таңданбай тұрып эквиваленттілікті талап етіңіз. Мұндағы әр саты комбинация бойынша бірдей PnL мен транзакция сандарын береді, барлық 80 комбинацияда автоматты түрде қақпаланған (PnL-ге абсолютті төзімділік, транзакцияларға дәл). Аздап басқа нәрсені есептейтін жылдам қозғалтқыш жылдам емес — ол жоғары өткізу қабілетінде қате, әрі векторланған қайта жазулар — қателік әдетте кіріп кететін жер.
@njitкүй сақтайтын логика үшін ақылды векторлаудан асып түседі. numpy сатысы стоп-лосс қосқан сәтте өлетін стратегияға тән жабық түрді қажет етті. numba сатысы қарапайым, аудиттелетін циклді компиляциялайды — бірдей жылдамдық класы, сынғыштықтың ешбірі жоқ, әрі ол — параллельденетін бірлік.- GPU жауабы — "бұл сканерлеу үшін емес" — атай алуыңыз тиіс себептермен. Мүмкіндік математикасы есептеу-тектес (10.78 FLOP/byte) әрі ол компиляцияланған ядроның 99.3%-ы, сондықтан "бэктестер жад-байланысты" да, "күй сақтайтын цикл басым" да өлшеуден аман өтпейді. Шыншыл себептер — ен мен бюджет: 12 CPU ядросы қазірдің өзінде қанықтыратын 80 комбинациялы пайдаланылатын параллелизм, әрі іске қосу мен синхрондау үстеме шығыны жеп қоятын 0.23 s жалпы жұмыс. Нақты еніндегі топтастырылған үлкен-матрица қайта тұжырымдауы — теріске шығарылған емес, перспективалы бағыт болып қалады.
- Қозғалтқыш жылдамдығы — зерттеу қарқыны. Қарапайым-қозғалтқыш өткізу қабілетінде 100,000-бэктестік іздеу — бір күн; саты-шыңы өткізу қабілетінде — бес минут. Аппарат сатып алмай немесе кластер жалдамай тұрып, тар жеріңіз мүлде силикон ба екенін тексеріңіз; біздікі —
rolling.applyішіндегіlambdaмен он бір бос ядро болды.
Толық тәжірибе — барлық бес имплементация, эквиваленттілік жарнамасы, roofline есептеуі, әрі осы мақаладағы әр сан бір детерминистік скрипттен қайта жасалатын — серіктес жұмыста: speed-ladder.marketmaker.cc, код пен деректер: github.com/suenot/backtest-speed-ladder.
Жетпіс секунд алған сканерлеу біреуінің төрттен бірін алады. Дәл сол транзакциялар, дәл сол PnL, дәл сол ноутбук. Реквизиция жасамақ болған GPU күте алады; жеткізбек болған интерпретатор циклі — жоқ.
Authors
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.