← К списку статей
July 1, 2026
5 мин. чтения

Лесенка скорости бэктест-движка: 298x на CPU ноутбука, идентичный PnL до последней сделки

Лесенка скорости бэктест-движка: 298x на CPU ноутбука, идентичный PnL до последней сделки
#алготрейдинг
#бэктест
#производительность
#numba
#векторизация
#оптимизация

Статья из серии "Бэктесты без иллюзий".

📄 Эта статья выросла в исследовательскую работу. Одно path-dependent ядро бэктеста реализовано пятью способами — от наивного 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, кластер, облачный бюджет. Измеренная реальность этого эксперимента указывает на нечто гораздо менее эффектное: узким местом были движок (интерпретируемый внутренний цикл, делающий Python-вызовы на каждое окно) и оркестрация (независимые комбинации, запускаемые последовательно на одном ядре). И то и другое чинится за один вечер, на той машине, что у вас уже есть, без единого изменения в результатах.

Вот вся лесенка целиком сразу. Все, что ниже, — анатомия каждой ступени.

Ступень Реализация Время Ускорение Комбинаций/с
M0 pandas: rolling.apply + Python-цикл по барам 69.92 с 1.0x 1.1
M1 numpy: sliding-window 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-бейзлайн — замерены целиком и проверены на идентичный PnL и число сделок по каждой комбинации на всех 80 комбинациях.

Одно ядро, пять реализаций

Пять ступеней одной лесенки: одно и то же ядро бэктеста поднимается от 70-секундного pandas-бейзлайна до 0.23-секундного параллельного numba-прогона, каждый шаг проверен на идентичность PnL

Чтобы сравнение скорости вообще что-то значило, нужно точно зафиксировать что именно вычисляется, и доказать, что каждая реализация вычисляет именно это. Поэтому эксперимент фиксирует одно стратегическое ядро и держит его неизменным на всех пяти ступенях.

Ядро — это пересечение HMA/HMA3, система stop-and-reverse на двух скользящих средних в стиле Hull. Строительный блок — взвешенная скользящая средняя (WMA):

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

Hull Moving Average комбинирует три такие средние, чтобы срезать лаг:

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

Торговое правило намеренно и полезно stateful (зависит от состояния): направление лонг, когда HMA ниже HMA3, и шорт в противном случае; позиция открывается на первом определенном направлении; на каждом пересечении позиция закрывается, фиксируется PnL минус комиссия 0.09% за круговой рейс (round-trip), и направление разворачивается. Позиция переносится между барами — то, что происходит на баре ii, зависит от состояния, накопленного с последнего пересечения. Эта зависимость от пути (path dependence) — весь смысл эксперимента: именно это свойство отличает бэктесты от обычных dataframe-пайплайнов, и (как мы измерим) оно усложняет вопрос про GPU — хотя, как выясняется, не так, как гласит фольклор.

Остальная часть постановки эксперимента — чтобы вы могли сами оценить цифры:

  • Данные: 150,000 баров синтетического геометрического броуновского движения, с фиксированным сидом (seed=42). Производительность здесь ограничена размером массива и длинами окон, а не тем, какой именно ценовой путь вы скормите движку — а синтетический ряд делает весь эксперимент детерминированным и воспроизводимым для кого угодно.
  • Сетка: 80 различных длин HMA, разбросанных по [6,200][6, 200] — так что перебор содержит и дешевые комбинации с коротким окном, и дорогие с длинным, как это бывает в реальной сетке.
  • Замер времени: по настенным часам (wall-clock), лучшее из 3 запусков на ступень, JIT-компиляция прогревается вне таймера, воркеры пула прогреваются до старта отсчета. Каждая ступень — включая pandas-бейзлайн — замеряется целиком на всех 80 комбинациях. BLAS (Accelerate от Apple) закреплен на одном потоке, так что однопоточные ступени действительно однопоточные: numpy-ступень не занимается тихим многопоточным умножением матрица-на-вектор у сравнения за спиной.
  • Шлюз эквивалентности: после замера времени вектор (PnL, число сделок) по каждой комбинации на каждой ступени сравнивается с референсом — число сделок должно совпадать точно, PnL — с точностью до абсолютных 10610^{-6} процентных пунктов. Закоммиченный прогон рапортует all_ok: true для каждой ступени, включая pandas-бейзлайн, на всех 80 комбинациях. Если этот шлюз не проходит — никакого бенчмарка нет: есть просто пять программ, вычисляющих пять разных вещей с пятью разными скоростями, а именно так тихо и устроена значительная часть заявлений "наш движок быстрее в 100 раз".

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

Раз это установлено, каждое ускорение ниже — это чистая скорость. Ничего не было приближено или упрощено ради нее.

Ступень M0: наивный профиль на pandas — 69.9 с

Анатомия наивного pandas-бейзлайна: окно rolling.apply порождает вызов Python-лямбды на каждый из 150,000 баров, пока интерпретаторный цикл ползет где-то под этим

Этот бейзлайн — не соломенное чучело. Это код, который получается, если написать 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-callable и упаковывает (boxing) результат. Даже с raw=True (который хотя бы отдает лямбде голый ndarray вместо Series), накладные расходы интерпретатора на вызов на порядки превышают те ~десятки-сотни FLOP, которые окну реально нужны. Умножьте на семь проходов WMA на комбинацию — и один только стек индикаторов дает миллионы обращений туда-обратно к интерпретатору. Затем цикл по барам делает еще 150,000 интерпретируемых итераций на комбинацию, каждая с индексацией numpy-скаляров с проверкой границ, упаковкой float и динамической диспетчеризацией по типам, которую интерпретатор заново переоткрывает каждый раз.

Результат: 69.92 с на весь перебор, около 0.87 с на комбинацию, пропускная способность 1.1 комбинации в секунду. На сетке из 80 комбинаций вы просто пожимаете плечами и ждете минуту. Проблема в том, что никто долго не сидит на сетках по 80 комбинаций — а эта стоимость масштабируется линейно и без конца. Мы к этому еще вернемся.

Ступень M1: numpy — хватит звать Python в цикле — 3.07 с, 22.7x

Следующая ступень устраняет оба интерпретаторных цикла разом, и стоит разделить эти два трюка, потому что у них очень разная степень общности.

Индикаторная сторона — простая, полностью общая. Взвешенная скользящая средняя по всем окнам — это просто произведение матрицы на вектор над strided-представлением входа, без копирования, один вызов 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 вычисляет скалярное произведение для каждого окна в скомпилированном коде. Миллион вызовов лямбды превращаются в один вызов библиотеки.

Сторона сделок интереснее, потому что событийный цикл stateful — и тем не менее для этого ядра он векторизуется. Ключевое наблюдение: позиция на любом баре зависит только от знака 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-программист, и честная линейка для всего, что выше. Но у этой ступени есть две честные оговорки.

Во-первых, эта векторизация — специфичное для стратегии аналитическое переписывание, а не механическое преобразование. Оно существует, потому что ядро — stop-and-reverse без стопов, без трейлинг-выходов, без сайзинга позиции, зависящего от текущего PnL. Добавьте стоп-лосс — самую обычную функцию, какую только можно представить — и выход на баре ii начинает менять, какой вход существует на баре j>ij > i, состояние подпитывает путь обратной связью, и замкнутая форма испаряется. Большинство продакшен-ядер живут по неправильную сторону этой черты.

Во-вторых, это та ступень, где корректность идет умирать. Учет индексов переворотов (+1 тут, [:-1] там, посев первого направления) — это ровно тот код, который порождает баги исполнения off-by-one — тот же вид бага, который наша таксономия look-ahead показала способным сфабриковать Sharpe 15 из шума. Шлюз эквивалентности на этой ступени — не формальность, а единственная причина ей доверять. Именно так — через хитрые векторизованные переписывания без проверки эквивалентности против тупой референсной реализации — движки незаметно уплывают от стратегии, которую якобы тестируют.

Ступень M2: numba — компилируйте тот цикл, который вы и хотели написать — 1.98 с, 35.3x

Python-событийный цикл проходит через JIT-компилятор numba и выходит плотным машинным кодом: та же ветвистая логика по барам, но скомпилированная, а не интерпретируемая

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

@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 с — 35.3x относительно pandas, но лишь около 1.6x относительно numpy (вывод: 3.07/1.98). Этот скромный шаг сам по себе показателен: внутренние циклы numpy были уже скомпилированы, так что выигрыш numba на математике признаков ограничен только пропуском материализации окон и промежуточных массивов. Трансформирующая часть — в другом месте:

  1. Событийный цикл теперь бесплатен — и "бесплатен" измерено, а не риторически. M1 потратил всю свою хитрость на то, чтобы сделать логику сделок векторизуемой. M2 делает эту хитрость ненужной — наивный, легко проверяемый и легко модифицируемый цикл работает на машинной скорости. Замер времени стадии признаков отдельно от цикла сделок внутри этого скомпилированного ядра относит 99.3% времени на математику признаков WMA и лишь 0.7% — на stateful событийный цикл. Стоп-лосс можно добавить завтра, без отдельного исследовательского проекта — и запомните это разбиение: оно еще переопределит аргумент про GPU ниже.
  2. Он открывает дорогу к следующим двум ступеням. Скомпилированное, отпускающее GIL, скупое на аллокации ядро — это та единица работы, которая нужна параллельной оркестрации. M0 нельзя продуктивно распараллелить — двенадцать копий медленного все равно медленно, просто теплее.

Одно методологическое замечание: numba компилирует при первом вызове, и эта компиляция (сотни миллисекунд) не должна попадать внутрь таймера — харнесс прогревает JIT на срезе в 500 баров перед замером, а cache=True сохраняет скомпилированные ядра между запусками процесса. Бенчмарки, которые "забывают" эту деталь, выдают цифры numba либо несправедливо плохими (с учетом холодной компиляции), либо невоспроизводимыми.

Ступень M3: prange — параллелизм, который у вас уже был — 0.32 с, 217.6x

Восемьдесят независимых комбинаций параметров разлетаются по двенадцати ядрам CPU: производительные и энергоэффективные ядра параллельно тянут неравные по длине окна

Вот наблюдение, которое делает массовый поиск параметров особенным: 80 комбинаций полностью независимы. Никакого общего состояния, никакого порядка, никакой коммуникации. Это embarrassingly parallel работа (тривиально параллелизуемая), которую ступени 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 с — 217.6x относительно pandas, 248.9 комбинаций в секунду. Шаг относительно однопоточного M2 — около 6.2x на 12 ядрах (вывод: 1.98/0.32), и о недоборе до "идеальных 12x" честнее сказать прямо, чем прятать: 12 ядер M2 Max — это 8 производительных + 4 энергоэффективных, так что номинальный потолок никогда не был 12x; 80 комбинаций стоят дичайше по-разному (HMA длины 6 намного дешевле HMA длины 200), так что потоки финишируют вразнобой; и каждый вызов ядра выделяет свои промежуточные массивы из общего аллокатора. Вот как выглядят параллельные ускорения на реальных машинах. Всякий, кто цитирует чистое Nx-на-N-ядрах для гетерогенных задач, измеряет что-то синтетическое.

Ступень 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 с — 297.9x относительно pandas, 340.9 комбинаций в секунду. Перечитайте эту пропускную способность еще раз: этот ноутбук сейчас гоняет примерно 340 полных 150,000-барных бэктестов в секунду, каждый из которых вычисляет семь взвешенных скользящих средних и симулирует десятки тысяч stateful сделок.

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

Процессы не бесплатны, и харнесс честно платит за них вне таймера, там, где это одноразовые затраты (старт воркеров, отправка close каждому воркеру через инициализатор, прогрев JIT на воркер), — потому что в реальном поиске эти затраты амортизируются на тысячах комбинаций, а не на восьмидесяти. Честная общая рекомендация: prange проще и обычно достаточен; пул процессов выигрывает, когда задачи крупнозернистые, сетка большая, или ваша работа на комбинацию держит GIL там, куда numba не дотягивается.

На этом лесенка раскладывается на чистую сводку. От M0 до M2 — движок: 35.3x на одном ядре, за счет выноса итерации из интерпретатора. От M2 до M4 — оркестрация: еще 8.4x (вывод: 1.98/0.23), за счет использования ядер, которые уже были в наличии. Перемножено: 298x. Никакого нового железа, идентичные результаты. А если считать от добротного бейзлайна M1 вместо наивного, готовый движок все равно стоит примерно на 13x выше (вывод: 3.07/0.23) — лесенка не артефакт выбора медленной стартовой точки.

Почему не GPU — честная версия

GPU простаивает рядом с загруженным до предела CPU: батчуемая математика скользящих средних осталась на CPU, потому что перебор из восьмидесяти комбинаций за четверть секунды слишком узок и слишком короток, чтобы окупить поездку

"Просто перепишите это на GPU" — самый частый отклик на медленный перебор параметров, так что этот эксперимент измеряет два числа, с которых должен начинаться такой разговор, — и ни одно из них не поддерживает ленивую версию ни того, ни другого ответа.

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

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/байт. Вывод тот же в обоих случаях.)

Это число важно тем, что оно исключает: популярное утверждение "математика бэктеста memory-bound, поэтому GPU не поможет" здесь ложно. При ~10.8 FLOP/байт математика признаков вполне определенно compute-ish (упирается в вычисления) — далеко за той точкой перегиба (ridge point), после которой типичное железо перестает быть ограничено пропускной способностью памяти. GPU абсолютно мог бы объединить 80 комбинаций × 7 проходов WMA в горстку крупных ядер и прожевать эту арифметику. Если бы стек признаков был всей проблемой, аргумент за GPU выглядел бы вполне уважительно.

Второе измеренное число убивает другой ленивый ответ — тот, за который мы бы сами и схватились. Замер стадии признаков отдельно от цикла сделок внутри скомпилированного ядра дает разбиение 99.3% признаки, 0.7% событийный цикл. Соблазнительный аргумент — "у бэктестов stateful, ветвистый событийный цикл, и именно он блокирует 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 с unified memory перенос host-to-device — незначительная забота; на дискретной CUDA-машине это тоже ложится в счет.) Классический выигрыш GPU амортизирует фиксированные накладные расходы на огромных батчах работы; перебор короче секунды никогда не порождает такого батча.

А событийный цикл? Это как раз та часть, которая не батчилась бы — последовательная, ветвистая, path-dependent, loop-carried зависимость длиной 150,000 баров, которую никакое железо не может распараллелить внутри одной комбинации, да еще с теми самыми расходящимися ветвлениями, которые ненавидят SIMT-дорожки. Порт на GPU оставил бы его на CPU либо гонял бы по одной дорожке на комбинацию. Но при 0.7% ядра это слагаемое Амдала слишком мало, чтобы что-то решать. Это та часть, которая не поехала бы; но это не причина не ехать. (Вспомните из ступени M1, что для ядер без обратной связи цикл можно даже аналитически векторизовать — переписывание, которое вы теряете в момент, когда у стратегии появляется стоп.)

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

Итоговое предложение, которое мы бы отстояли на ревью: почти вся эта работа могла бы уйти на GPU; этот перебор слишком узок и слишком короток, чтобы поездка окупилась. И читайте это в обе стороны — это не списание со счетов. Батчевая переформулировка "big-matrix" — переформулирование перебора как крупных тензорных операций сразу по тысячам комбинаций, или по-настоящему свободное от обратной связи ядро, батчуемое от начала до конца, — это реальное и многообещающее направление, заслуживающее отдельного исследования, а не отмахивания. При 80 комбинациях и 0.23 секунды оно просто еще не заработало себе билет. Если у вашей нагрузки есть такая ширина — арифметика меняется, и вам стоит пересчитать это самим, а не цитировать нас.

Где на самом деле узкое место: движок и оркестрация

Настоящее узкое место в кадре: песочные часы, где поток душат движок и оркестрация тысяч комбинаций параметров, а не железо под ними

Восемьдесят комбинаций — это демонстрационная сетка. Реальный поиск параметров — там, где эти факторы перестают быть академическими, потому что сетки растут мультипликативно: четыре параметра по десять значений каждый — это уже 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 минут

Один и тот же эксперимент, который на наивном движке — batch-задача на ночь, на настроенном движке — интерактивный запрос. Эта разница накапливается сильнее, чем показывают таблицы с временем на часах: при 5 минутах на перебор вы итерируете — перезапускаете с исправленной утечкой, добавляете фолд, расширяете сетку, проверяете идею, которая пришла в голову за обедом. При 24 часах на перебор — нет. Скорость движка задает темп исследовательского цикла, а темп исследовательского цикла и есть настоящий продукт.

У всей этой лесенки есть еще и прочтение через закон Амдала:

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

Ускорение любой отдельной стадии pp в ss раз ограничено всем остальным, что вы оставили медленным. Лесенка уважала этот порядок: выигрыш движка в 35.3x атаковал слагаемое, которое доминировало (интерпретируемая итерация — что в стеке признаков, что в цикле), а выигрыш оркестрации в 8.4x атаковал слагаемое, которое доминировало после этого (одиннадцать простаивающих ядер). Разбиение признаки/цикл — тот же урок в миниатюре: мы не смогли бы назвать реальную форму аргумента про GPU, не измерив, куда на самом деле уходило время. Сначала профилировать, потом оптимизировать — именно в таком порядке. Та же логика управляет слоем данных выше движка: наши бенчмарки Polars против pandas обнаружили идентичный паттерн (10–3500x на группированных rolling-пайплайнах) для половины стека, отвечающей за загрузку и трансформацию, и тот же гибридный вывод — колоночные движки для пайплайна, скомпилированное ядро для path-dependent симуляции.

Две честные оговорки, чтобы замкнуть вопрос об обобщаемости. Во-первых, этот эксперимент намеренно самодостаточен и синтетичен — данные с фиксированным сидом, одно ядро, одна раскрытая машина — так что кто угодно может детерминированно воспроизвести этот феномен; числа по настенным часам будут отличаться на вашем железе, но эквивалентность и направление лесенки — нет. Во-вторых, феномен — не артефакт синтетической постановки: бенчмарк нашего продакшен-движка HMA (bench_param_sweep.py, запущенный на реальных биржевых данных с полной продакшен-моделью комиссий и исполнения) показывает ту же форму лесенки, с numba-путем, приземляющимся примерно на 100–200x выше наивного профиля на pandas. Самодостаточный эксперимент существует именно для того, чтобы вам не пришлось верить нашим продакшен-числам на слово.

Выводы

  1. Лесенка дает 298x, и она раскладывается: 35.3x движок × 8.4x оркестрация. Вынос итерации из интерпретатора (pandas → numba) и распределение независимых комбинаций по ядрам (одно → двенадцать) перемножились в ускорение, примыкающее к трем порядкам величины, на неизменном ноутбуке. 69.92 с → 0.23 с; 1.1 → 340.9 комбинаций/с. И это не артефакт медленного бейзлайна: против добротной векторизованной numpy-реализации готовый движок все равно дает ~13x.
  2. Требуйте эквивалентности прежде, чем восхищаться скоростью. Каждая ступень здесь дает идентичный PnL и число сделок по каждой комбинации, автоматически проверяемые на всех 80 комбинациях (абсолютный допуск 10610^{-6} по PnL, точное совпадение по сделкам). Быстрый движок, вычисляющий что-то едва заметно иное, — не быстрый, а неправильный на высокой пропускной способности, и векторизованные переписывания — то самое место, куда эта неправильность обычно и просачивается.
  3. @njit побеждает хитрую векторизацию для stateful-логики. Numpy-ступени понадобилась специфичная для стратегии замкнутая форма, которая умирает в момент добавления стоп-лосса. Numba-ступень компилирует наивный, легко проверяемый цикл — тот же класс скорости, никакой хрупкости, и именно это та единица, которая параллелится.
  4. Ответ про GPU — "не для этого перебора" — и причины этого стоит уметь назвать. Математика признаков compute-ish (10.78 FLOP/байт), и она составляет 99.3% скомпилированного ядра, так что ни "бэктесты memory-bound", ни "доминирует stateful-цикл" не переживают замера. Честные причины — ширина и бюджет: 80 комбинаций эксплуатируемого параллелизма, которые уже насыщают 12 ядер CPU, и суммарная работа в 0.23 с, которую съели бы накладные расходы на запуск и синхронизацию. Батчевая big-matrix переформулировка при реальной ширине остается многообещающим направлением, а не опровергнутым.
  5. Скорость движка — это темп исследования. На пропускной способности наивного движка поиск из 100,000 бэктестов — это день; на пропускной способности вершины лесенки — пять минут. Прежде чем покупать железо или арендовать кластер, проверьте, действительно ли ваше узкое место — в кремнии: наше было в lambda внутри rolling.apply и одиннадцати простаивающих ядрах.

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

Перебор, который занимал семьдесят секунд, теперь занимает четверть одной. Те же сделки, тот же PnL, тот же ноутбук. GPU, который вы уже собирались заказать, может подождать; интерпретаторный цикл, который вы уже собирались отгрузить в продакшен, — нет.

Дисклеймер: Информация в этой статье предоставлена исключительно в образовательных и ознакомительных целях и не является финансовым, инвестиционным или торговым советом. Торговля криптовалютами сопряжена с высоким риском убытков.

Авторы

Eugen Soloviov
Eugen Soloviov

Инженер торговых систем

Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.

Newsletter

Будьте в курсе событий

Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.

Мы уважаем вашу конфиденциальность. Отписаться можно в любой момент.