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

IPC-налог: спрячьте бэктест-движок за сокетом и потеряйте 13% — при этом сокет почти ни при чем

IPC-налог: спрячьте бэктест-движок за сокетом и потеряйте 13% — при этом сокет почти ни при чем
#алготрейдинг
#бэктест
#производительность
#ipc
#rust
#архитектура
Часть 4 из 4 · Подборка
Быстрые движки бэктеста

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

📄 Эта статья выросла в исследовательскую работу. Одно path-dependent ядро бэктеста перенесено построчно с numba на Rust и вызывается через границу процесса/языка четырьмя способами, с проверкой эквивалентности, подтверждающей идентичный PnL по каждой комбинации — плюс изолированные замеры чистой кривой латентности IPC, налога на сериализацию и стоимости spawn. Читайте статью онлайн (интерактивная версия + PDF) на ipc-tax.marketmaker.cc, код и данные — на github.com/suenot/ipc-tax.

Каждый бэктест-движок, который становится быстрым, рано или поздно провоцирует один и тот же разговор. Наш начался по расписанию. Лесенка скорости только что довела перебор 80 комбинаций параметров с 69.9 секунды на pandas до примерно 2 секунд на однопоточном numba, и естественным следующим порывом стало: почему останавливаться на Python JIT? Переписать ядро на Rust. Сделать из него настоящий сервис движка — один скомпилированный бинарник за сокетом, вызываемый из любого исследовательского скрипта, любого языка, и живым трейдером тоже. Одно ядро, одна истина, никакой дублированной логики.

А затем, тоже по расписанию, приходит контраргумент: в момент, когда вы покидаете процесс, IPC вас съедает. Данные нужно сериализовать, переслать через границу, десериализовать; каждый вызов платит syscall'ами и переключениями контекста; ваше прекрасное Rust-ядро проведет жизнь в ожидании на пайпе. Оставайтесь in-process. Это знают все.

Эта статья измеряет то, что знают все, и измерение оказывается интереснее, чем любая из сторон спора. Народное поверье — "более быстрый кросс-языковой движок проигрывает in-process numba, потому что IPC убивает" — оказывается неверным в общем случае и верным только при определенных условиях. Пересечь границу один раз, сырыми байтами, стоит около 2 миллисекунд на двухсекундной задаче: погрешность округления. Налог не в границе. Он в том, как вы ее пересекаете — и три способа, которыми обычно разворачивают сервисы движка в реальности (JSON API, вызов на единицу работы, spawn процесса на вызов), каждый измеримо составляет кусочек той катастрофы, которую предсказывает фольклор.

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

Архитектура Что пересекает границу за один перебор Wall time vs in-process
in-process numba ничего — прямой вызов 2.010 s 1.00x
Rust-сервер, пакетный (batched) (Unix socket) один round-trip: весь ряд + все 80 наборов параметров 2.276 s 1.13x
Rust-сервер, пакетный, ядро get_unchecked тот же единственный round-trip — вариант ядра без проверок границ (см. вердикт) 2.337 s 1.16x
Rust-сервер, болтливый (chatty) (Unix socket) 80 round-trip'ов: ряд пересылается заново на каждую комбинацию 2.383 s 1.19x
Rust spawn (stdin/stdout) spawn процесса + один переданный по pipe запрос 2.300 s 1.14x

Apple M2 Max, Python 3.14.6, numpy 2.4.3, numba 0.64.0, rustc 1.94.0 (release-сборка, ноль внешних crates). 150,000 баров × 80 комбинаций, комиссия round-trip 0.09%, seed 42; close-ряд весит 1,200,000 байт (1.2 MB) на проводе. Медиана по 10 прогонам на архитектуру; разброс min-max держится в пределах ~2%. Все пять выполняют один и тот же перебор HMA/HMA3 stop-and-reverse, и проверка эквивалентности подтверждает, что результаты (PnL, число сделок) по каждой комбинации для обоих вариантов Rust-ядра точно совпадают с numba — контрольный PnL −5165.58 на 57,029 сделках, побайтово идентично numba-ядру из исследования лесенки скорости на том же seed. Мы сравниваем границы, а не реализации.

Внимательно прочитайте пакетную строку, потому что она несет весь тезис. Архитектура Rust-через-сокет на 1.13x медленнее in-process numba — отстает на 266 мс на всем переборе (получено: 2.276 − 2.010). Народная история говорит, что эти миллисекунды — IPC. Это не так. Около 2 мс этого разрыва — граница: весь close-ряд в 1.2 MB, пересланный внутрь, результаты, пересланные обратно, измеренные напрямую. Остальные ~264 мс — это то, что наше наивное Rust-ядро просто вычисляет перебор примерно на 13% медленнее, чем numba-ядро (получено: 2.276 с минус ~2 мс границы ≈ 2.274 с вычислений Rust, против 2.010 с у numba). Rust-язык не проиграл Python-языку; один скалярный LLVM-скомпилированный цикл проиграл гонку кодогенерации другому — и мы даже не смогли повесить проигрыш на очевидного подозреваемого: сборка того же ядра get_unchecked без проверок границ оказалась не быстрее (2.337 с; раздел про вердикт препарирует это). Сокет почти не имел к этому никакого отношения.

Держите в голове обе половины этого предложения. Граница почти бесплатна при правильном пересечении — а "перепишите на Rust" покупает вам границу деплоя, а не автоматический выигрыш в вычислениях. Оба факта идут против расхожей интуиции, и оба — в таблице.

Одно ядро, два языка, четыре границы

Нагрузка намеренно та же самая, что зафиксировала лесенка скорости, чтобы оба исследования были привязаны друг к другу. Ядро — это HMA/HMA3 cross: stop-and-reverse система на двух скользящих средних в стиле Hull, семь проходов взвешенного скользящего среднего на комбинацию параметров плюс stateful событийный цикл по барам, который несет позицию, фиксирует PnL минус комиссия 0.09% за round-trip на каждом пересечении и разворачивается. Данные — 150,000 баров синтетического геометрического броуновского движения с фиксированным seed (seed=42); сетка — 80 длин HMA, разбросанных по [6,200][6, 200]. In-process референс — это однопоточная numba-ступень из лесенки, переизмеренная для этого исследования: 1.98 с там, 2.010 с здесь — то же ядро, та же машина, обнадеживающе скучно.

Кросс-языковой движок — это построчный порт того же numba-ядра на Rust: те же циклы, та же обработка NaN, та же арифметика комиссий — скомпилированный в режиме release без внешних crates, так что весь эксперимент остается без зависимостей и воспроизводимым. Он говорит на намеренно минимальном бинарном протоколе: один фрейм с префиксом длины в каждую сторону, все little-endian.

request:  [u32 body_len][body]
body:     [u8 opcode][u32 n_bars][u32 n_combos]
          [n_bars × f64 close][n_combos × 6 × i64 params]

opcode 0 = sweep : reply = [n_combos × f64 pnl][n_combos × i64 trades]
opcode 1 = echo  : reply = the close array, verbatim

Опкод echo — это скальпель исследования: round-trip управляемого размера, который ничего не вычисляет, так что чистую стоимость границы можно измерить изолированно — сериализация, syscall'ы, транзит через сокет, десериализация, и больше ничего.

Пять измеренных архитектур — четыре паттерна пересечения границы плюс один вариант ядра:

  • in_process — вызов numba-ядра напрямую. Никакой границы. Референс.
  • rust_batch_unix — персистентный Rust-сервер на Unix domain socket. Один round-trip пересылает весь close-ряд плюс все 80 наборов параметров; Rust вычисляет каждую комбинацию; приходит один ответ. Пакетный (chunky) вызов.
  • rust_batch_unchecked — та же пакетная граница, но ядро индексирует через get_unchecked (без проверок границ в горячем пути). Существует, чтобы проверить конкретную гипотезу о разрыве в вычислениях; вердикт тратит его именно на это.
  • rust_chatty_unix — тот же сервер, но один round-trip на комбинацию, ряд в 1.2 MB пересылается заново каждый раз. Наивная архитектура RPC на единицу работы.
  • rust_spawn_stdin — запуск бинарника на каждый перебор и передача запроса через stdin. Паттерн "вызвать CLI-движок из shell"; платит за создание процесса.

И проверка эквивалентности (equivalence gate), без которой все это не имело бы смысла: после замера времени вектор (PnL, число сделок) по каждой комбинации для каждого варианта на Rust сравнивается с numba — число сделок точно, PnL с точностью до абсолютных 10610^{-6}. Зафиксированный прогон отдает all_ok: true и для safe-indexing, и для get_unchecked-сборки. Контрольное значение первой комбинации — PnL −5165.58 процентных пунктов на 57,029 сделках — совпадает с numba-ядром из исследования лесенки скорости цифра в цифру, что привязывает обе статьи к одному и тому же ядру на одном и том же seed. Именно в кросс-языковых портах любит прятаться тихое расхождение (комиссия, примененная до вместо после перевода в проценты, сравнение NaN, ветвящееся по-другому, off-by-one в окне — тот же вид бага, который наша таксономия look-ahead показала способным сфабриковать Sharpe 15 из шума). Бенчмарк двух движков, вычисляющих разные вещи, — это не бенчмарк; это гонка двух несвязанных программ.

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

Что на самом деле стоит пересечение: кривая echo

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

Начнем со скальпеля. Операция echo делает round-trip payload из nn float через Rust-сервер — Python строит фрейм, сервер парсит все nn float, кодирует их заново и отправляет обратно. Оба направления платят за сериализацию, syscall'ы и транзит через сокет. Вот измеренная кривая (медианы по 10 прогонам):

Payload (floats) Байт в каждую сторону Round-trip
1 8 14.1 µs
100 800 16.4 µs
1,000 8,000 18.1 µs
10,000 80,000 192.5 µs
100,000 800,000 1,367.3 µs
150,000 1,200,000 2,043.4 µs

В этой таблице живут два структурных факта.

Во-первых, порог (floor). Round-trip, несущий практически ничего — 8 байт — стоит 14 мкс. Это несократимая цена самого факта сделать вызов через этот транспорт: два syscall'а write, два syscall'а read, машинерия сокета в ядре ОС, пробуждения планировщика. Обратите внимание, насколько плоская кривая слева: от 1 float до 1,000 float стоимость почти не двигается (14.1 → 18.1 мкс). Ниже примерно 8 КБ вы платите за сам вызов, а не за байты. Это число — порог задержки — единственная самая важная константа во всем исследовании, и ниже мы построим на нем арифметику break-even.

Во-вторых, наклон. После ~10,000 float кривая упирается в пропускную способность и становится примерно линейной. Полный ряд в 1.2 MB — 2.4 MB, перемещенных суммарно туда и обратно, включая полный парсинг и перекодирование 150,000 float на стороне Rust — стоит 2,043.4 мкс. Это дает эффективные ~1.2 GB/s через весь наивный стек (получено: 2.4 MB / 2.04 мс) — Unix domain socket с фреймами с префиксом длины и побайтовым парсером float, без zero-copy трюков, без shared memory, ничего хитрого.

Разумная модель одного пересечения, с обеими измеренными константами:

Tcall(b)    14 μsfloor  +  2b1.2 GB/spayload, both waysT_{\text{call}}(b) \;\approx\; \underbrace{14\ \mu\text{s}}_{\text{floor}} \;+\; \underbrace{\frac{2b}{1.2\ \text{GB/s}}}_{\text{payload, both ways}}

Теперь поместим заголовочное число в контекст. Весь перебор занимает 2.010 с in-process. Переслать весь его датасет через границу и обратно стоит ~2.0 мс — около 0.1% работы (получено: 2.0434 мс / 2.010 с). Если пересекать границу один раз, сырыми байтами, граница — это погрешность округления. Это та половина народного поверья, которая умирает первой: страх никогда не был про что-то настолько дешевое.

Rust-сторона этого пересечения настолько неброская, насколько вообще бывает системный код — адаптировано из engine/src/main.rs:

fn read_frame<R: Read>(r: &mut R) -> Option<Vec<u8>> {
    let mut len_buf = [0u8; 4];
    r.read_exact(&mut len_buf).ok()?;
    let len = u32::from_le_bytes(len_buf) as usize;
    let mut body = vec![0u8; len];
    r.read_exact(&mut body).ok()?;
    Some(body)
}

fn write_frame<W: Write>(w: &mut W, body: &[u8]) {
    w.write_all(&(body.len() as u32).to_le_bytes()).unwrap();
    w.write_all(body).unwrap();
    w.flush().unwrap();
}

// the server is a loop: read frame -> compute -> write frame
for stream in listener.incoming() {
    serve_stream(stream.unwrap());
}

Одна честная оговорка о рамках исследования, прежде чем двигаться дальше: все числа про границу в этом исследовании — это Unix domain socket на одном хосте. Движок также умеет TCP (с TCP_NODELAY), но мы это не измеряли; loopback TCP сидит несколько выше этих порогов, а настоящий сетевой хоп — это совершенно другой режим: миллисекунды порога, а не микросекунды. Так что все здесь — это почти лучший случай для пересечения границы таким способом. Что делает измеренные далее налоги еще более обличающими: это то, что вы платите сверх этого, по собственному выбору.

Налог на сериализацию: 1348x за выбор JSON

Две кодировки одного и того же массива из 150,000 float бок о бок: raw-bytes memcpy, измеренный в микросекундах, против JSON текстовой кодировки, возвышающейся на три порядка выше

Вот где народное поверье про "накладные расходы IPC" оказывается неправильным ярлыком. Мы измерили стоимость кодирования одного и того же close-ряда из 150,000 float тремя способами — тот самый payload, который пересылает каждая архитектура выше:

Кодировка Время кодирования 1.2 MB float vs raw
raw bytes (.tobytes()) 49.1 µs 1.0x
pickle 29.8 µs 0.6x
JSON (json.dumps(close.tolist())) 66,243 µs 1348x

Raw-путь — это memcpy, притворяющийся вызовом функции:

def build_request(opcode, close, params):
    body = bytes([opcode]) + struct.pack("<II", len(close), len(params))
    body += close.astype("<f8").tobytes()      # 150,000 floats -> 1.2 MB in 49 µs
    body += np.asarray(params, dtype="<i8").reshape(-1).tobytes()
    return struct.pack("<I", len(body)) + body  # length-prefixed frame

(Pickle оказывается даже чуть дешевле нашего raw-пути, потому что astype платит за копию при конвертации dtype, даже когда dtype уже совпадает; оба относятся к классу memcpy, и оба — погрешность округления. Весь бинарный класс в целом живет на три порядка ниже текстового.)

А текстовый путь — это то, что на самом деле пересылает почти каждый деплой в духе "давайте сделаем из движка микросервис":

body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})

Шестьдесят шесть миллисекунд. Только на кодирование. json.dumps(close.tolist()) упаковывает каждый float в объект Python, затем рендерит каждый как десятичный текст — 150,000 аллокаций в куче и 150,000 конверсий float-в-строку там, где raw-путь делал одно блочное копирование. И payload на проводе тоже раздувается (float64 стоит 8 байт в бинарном виде и примерно в два-три раза больше как десятичный текст — мы даже не выставили счет за дополнительный транзит).

Теперь масштабируем это так, как это делает реальный деплой. Эти 66 мс — это одно кодирование, одна сторона, один вызов. JSON-сервис платит за encode и decode, на обеих сторонах границы, на каждом вызове. Один пакетный вызов через JSON сжег бы ~3.3% всего вычислительного бюджета перебора только на кодирование на стороне клиента (получено: 66 мс / 2.010 с). Поставьте JSON под болтливую (chatty) архитектуру — один вызов на комбинацию, паттерн ниже — и одно только клиентское кодирование стоит 80 × 66 мс = 5.3 с: больше чем в два с половиной раза больше всей полезной работы (получено), еще до того, как переместился хоть один байт, и до того, как сервер хоть что-то распарсил.

Это и есть настоящий "IPC-налог", который большинство команд измерили в продакшене, сами того не зная. Это никогда не было межпроцессным взаимодействием. Это была текстовая сериализация числовых массивов — самонанесенные 1348x на самом дешевом компоненте границы. Мир колоночных форматов усвоил этот урок много лет назад, и это тот же урок, в который упиралось наше исследование Polars vs pandas со стороны data-пайплайна: форматы вроде Arrow существуют именно для того, чтобы данные массивов могли пересекать границы процессов и языков как сырые колоночные байты, а не как текст. Если ваш сервис движка говорит на JSON для ценовых массивов, никакая настройка сокета вас не спасет — узкое место и есть протокол.

Болтливый против пакетного: закон Фаулера, измеренный

Пакетная архитектура, пересылающая один большой фреймированный payload через границу один раз, рядом с болтливой архитектурой, делающей восемьдесят маленьких round-trip'ов, каждый из которых тащит за собой весь датасет

Первый закон распределенного проектирования объектов Мартина Фаулера — "не распределяйте свои объекты" — идет со следствием, которое он сформулировал тут же: если границу все-таки нужно пересечь, интерфейс должен быть крупнозернистым, потому что удаленный вызов стоит на порядки больше локального. Каждый ветеран распределенных систем кивает в ответ. Почти ни у кого нет числа для собственной нагрузки. Вот наше.

Пакетная и болтливая архитектуры используют тот же сервер, тот же протокол, те же данные — отличается только гранулярность вызовов:

srv.call(0, close, params)

[srv.call(0, close, [params[k]]) for k in range(n)]

Пакетная: 2.276 s (1.13x). Болтливая: 2.383 s (1.19x) — на 107 ms медленнее (получено: 2.383 − 2.276). Чтобы быть точным насчет того, чем эта дельта является и не является: кривая echo дает для нее наивное предсказание — 79 дополнительных пересылок полного ряда, каждая примерно вполовину от 2,043 µs полного round-trip, около 81 ms — что оказывается примерно на 25% ниже измеренных 107 ms; остаток — это построение запроса и фрейминг на стороне Python для каждого вызова, что предсказание echo не включает. Так или иначе, это выходит на ~1.4 ms за дополнительное пересечение (получено: 107 / 79); ответы пренебрежимо малы — 16 bytes на комбинацию.

У этих 107 мс два прочтения, и оба важны.

Снисходительное прочтение: это всего ~4.5% от wall time, не катастрофа. Правда — и стоит понять, почему катастрофа фольклора здесь не материализовалась. Каждый болтливый вызов все еще несет 25,130 µs реальных вычислений (столько стоит одна комбинация — измеренная in-process стоимость на комбинацию), так что накладные расходы границы на вызов ~1.4 ms остаются на порядок ниже работы на вызов. Болтливые архитектуры не фатальны, когда каждый вызов действительно тяжелый. Они становятся фатальными по мере сокращения гранулярности — это и есть тема всего раздела про break-even.

Обличающее прочтение: этот налог был полностью добровольным, и он масштабируется как число вызовов × payload. Болтливый паттерн пересылает датасет заново на каждом вызове по одной-единственной причине: сервис stateless (не хранит состояние), так что каждый запрос обязан нести весь контекст. Это форма по умолчанию для наивного "sweep endpoint" — и, по сути, для каждого REST-микросервиса, когда-либо нарисованного на доске. Stateful сервер (хранящий состояние) — загрузить ряд один раз, затем слать 48-byte фреймы параметров — поставил бы каждый вызов на комбинацию у ближнего к крошечным payload конца кривой echo: около 16 µs на вызов, примерно 1.3 ms на все 80 (получено из порога echo; аналитически, отдельно не измерялось). Болтливый штраф не сократился бы — он бы исчез. Урок точен: проблема не в том, чтобы делать много вызовов — проблема в пересылке состояния заново, потому что протокол притворяется, будто каждый вызов первый.

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

Стоимость spawn: аренда движка на каждый вызов

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

Третий паттерн деплоя — самый старый: вообще никакого сервера. Запустить бинарник движка (spawn), передать один запрос через stdin, прочитать ответ из stdout, дать ему умереть. Инстинкт каждого shell-скриптера, каждая интеграция в духе "просто вызовем CLI из Python", каждый hyperparameter-фреймворк, настроенный запускать бинарник на каждый trial.

Измерено: 2.300 s (1.14x) — примерно на 24 ms больше пакетного варианта с персистентным сервером (получено: 2.300 − 2.276). Эти 24 миллисекунды покупают fork/exec, динамический загрузчик, настройку pipe и разбор процесса. И заметьте: то, что здесь измерено, близко к порогу для этого паттерна — маленький нативный бинарник без зависимостей, теплый в page cache. Запуск чего-либо с рантаймом — JVM, интерпретатор Python с импортами — стоит намного больше; мы это здесь не измеряли, но направление не вызывает сомнений.

Важна структура этого налога: он фиксирован на вызов, независимо от того, сколько работы несет вызов. Амортизированные по всему переборе из 80 комбинаций, 24 ms — это около 1% — шум. Перезапускайте на каждую комбинацию, и та же константа становится 80 × ~24 ms ≈ 1.9 s — по сути, вся полезная работа сожжена на создание процессов (получено; аналитически). Перезапускайте на каждый бар — и эту арифметику даже не стоит записывать.

Фиксированная стоимость, тонкая гранулярность: выбирайте что-то одно. Паттерн, который платит за spawn, разумен только тогда, когда spawn редкий, а payload за ним огромный — именно как в нашем замере с одним spawn на перебор, и совершенно не так, как в итоге используются архитектуры per-symbol-subprocess по мере роста числа символов.

Арифметика break-even: порог — это барьерная ставка

Арифметика break-even на весах: четырнадцать микросекунд порога границы на одной чаше против вычислений, которые несет каждый вызов, с вызовами на комбинацию далеко над водой и вызовами на бар, утонувшими на дне

Все измеренное до сих пор сжимается в одно правило проектирования, и это правило — арифметика, а не мнение.

Каждое пересечение границы стоит как минимум порог задержки — здесь это 14 µs, round-trip echo с крошечным payload, и это близко к лучшему, что предлагает этот транспорт. Этот порог — барьерная ставка (hurdle rate): вызов через границу имеет смысл делать, только если вычисления, которые он пересылает, превышают барьер с комфортным запасом. Определим коэффициент гранулярности

G  =  Tcompute per callTfloorG \;=\; \frac{T_{\text{compute per call}}}{T_{\text{floor}}}

и доля границы в вашем wall time — примерно 1/(1+G)1/(1+G) — плюс транзит payload сверху, если вызов также несет данные.

Теперь пропустим через это числа нашего перебора. Измеренная in-process стоимость одной комбинации — 25,130 µs. При гранулярности на комбинацию:

G  =  25,130 μs14 μs    1795G \;=\; \frac{25{,}130\ \mu\text{s}}{14\ \mu\text{s}} \;\approx\; 1795

Вызовы на комбинацию сидят примерно в 1,795x выше порога — граница забирает заметно меньше десятой доли процента на вызов. Именно поэтому даже болтливая архитектура потеряла только 107 ms: при гранулярности этой нагрузки любой паттерн пересечения, который не пересылает данные заново и не говорит текстом, безопасно амортизируется. Вызовы уровня комбинации, уровня фолда, уровня перебора — все глубоко в дешевой зоне.

Теперь перейдем к противоположной крайности. Это иллюстративная экстраполяция на другую нагрузку — не вариант нашего перебора, а форма нагрузки, которая реально существует в дикой природе: к движку обращаются на каждый бар. Live-style сервис движка на каждый тик; поток сигналов gRPC-на-бар; "strategy server", опрашиваемый по разу на каждый из 150,000 баров. Полезные вычисления на бар в этом ядре — 25,130 µs / 150,000 ≈ 0.17 µs (получено) — каждый вызов нес бы примерно 1/84 своей собственной стоимости границы в виде полезной работы (получено: порог 14.05 µs на 0.168 µs вычислений). Итог хуже, чем звучит соотношение:

150,000 calls×14 μs    2.1 s of pure IPC150{,}000 \ \text{calls} \times 14\ \mu\text{s} \;\approx\; \mathbf{2.1\ s\ of\ pure\ IPC}

— больше, чем вся in-process работа за 2.010 s, потраченных еще до того, как удаленный движок вычислит хоть одно число, и это остается 2.1 s даже если движок на другой стороне был бы бесконечно быстрым (получено: 150,000 × 14 µs). Никакое вычислительное преимущество не переживает настолько тонкую гранулярность. И вспомните, что этот порог — Unix socket на одном хосте; сделайте тот же вызов на бар к сервису через сеть, и порог вырастет на два-три порядка, на 150,000 вызовах.

Порог границы на одной машине как выбор реализации: round-trip Python-через-Unix-socket на четырнадцати микросекундах, возвышающийся над пересечением shared-memory ring на тридцати девяти наносекундах — три порядка величины друг от друга

Еще одна честная калибровка, потому что 14 µs тоже не закон физики — это цена нашего транспорта: клиент на Python, сокет ядра ОС, syscall'ы в обоих направлениях. Транспорт, спроектированный специально для одной машины, идет намного ниже. ZigBolt — наша open-source шина сообщений на Zig для HFT-нагрузок, бенчмаркнутая нативно на этой же машине — делает round-trip через shared-memory ring примерно за 39 ns в среднем (one-way p50 10/20/30 ns на сообщениях 64/256/1024-byte). Это примерно 360x ниже нашего порога сокета (получено: 14.05 µs / 39 ns). Сравнение намеренно яблоки-с-апельсинами, и мы это отмечаем прямо: наши 14 µs — это round-trip сокета с клиентом на Python, 39 ns ZigBolt — это нативный Zig через shared memory, так что разрыв смешивает транспорт и рантайм. Читайте это не как гонку между двумя, а как диапазон, который может занимать порог на одной машине: примерно три порядка величины, выбираемые реализацией. Это старый урок Lightweight RPC (Bershad et al., 1990) в современном обличье — пересечения на одной машине доминированы машинерией протокола, и они схлопываются, когда транспорт построен именно для случая одной машины. Арифметика break-even выше не меняет форму; просто сдвигается барьер. При пороге 39 ns даже гранулярность на каждый бар прошла бы его (150,000 × 39 ns ≈ 5.9 ms, получено) — именно поэтому HFT-системы могут позволить себе границы, которые не может позволить себе REST-сервис.

Вот вся история break-even в одном предложении: границе все равно, насколько быстр ваш движок; она берет плату за каждое пересечение, так что переменные, которые вы контролируете, — это сколько работы несет каждое пересечение и из чего сделано само пересечение. Пакетируйте на весь перебор — и GG больше ста тысяч. Пакетируйте на комбинацию, G1795G \approx 1795 — все еще нормально. Вызывайте на каждый бар через сокет, G<1G < 1 — архитектура мертва еще до первой оптимизации, и никаким переписыванием движка, на Rust или на чем угодно еще, ее не воскресить.

Где на самом деле живет 1.13x — и вердикт

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

Время честно препарировать заголовочный разрыв, потому что он несет самый контринтуитивный вывод исследования.

Пакетная Rust-архитектура отстает от in-process numba на 266 ms (получено: 2.276 − 2.010). Измеренные компоненты границы: один round trip с полным payload на ~2.0 ms, сырая сериализация на 49 µs, заголовки фреймов на горстку байт — назовем весь счет за границу ~2 ms. Значит, свыше 99% разрыва — это вообще не граница. Это вычисления: если вычесть IPC, Rust-сервер тратит ~2.274 s на выполнение перебора, который numba делает за 2.010 s — наивное Rust-ядро примерно на 13% медленнее в сырых вычислениях (получено).

Это заслуживает бескомпромиссного абзаца, потому что "перепишите на Rust, и будет быстрее" — такое же народное поверье, как и "IPC вас убьет". Оба ядра в итоге упираются в LLVM — numba опускает через него байткод Python, rustc опускает через него MIR — и оба, скорее всего, выполняются как скалярные циклы: внутренняя сумма WMA — это редукция с плавающей точкой, которую LLVM не автовекторизует без разрешения на fast-math reassociation, которое @njit numba не выдает по умолчанию, а наш порт не запрашивает. Так что эти ~13% — это измеренный разрыв в кодогенерации между двумя скалярными LLVM-скомпилированными циклами — и вместо того чтобы утверждать причину, мы проверили очевидную. Естественный подозреваемый — безопасное индексирование Rust: горячий цикл WMA проверяет границы при каждом обращении к массиву, тогда как @njit numba компилируется с отключенной проверкой границ. Поэтому мы построили проверенный на эквивалентность вариант того же ядра на get_unchecked — без единой проверки границ в горячем пути — и замерили его как пятую архитектуру. Это не закрыло разрыв: 2.337 s (1.16x), чуть медленнее, чем 2.276 s у сборки с проверкой границ. Гипотеза проверена, гипотеза отвергнута. Честное состояние знания: эти ~13% реальны и воспроизводимы (медианы по 10 прогонам, разброс в пределах ~2%), и на данный момент не атрибутированы — какая-то разница в поведении аллокации, структуре циклов или планировании инструкций, которую разрешило бы только профилирование на уровне ассемблера. Урок остается нетронутым: наивный Rust не быстрее хорошего numba автоматически, и языковая граница, купленная в расчете на бесплатный выигрыш в вычислениях, может прийти с прикрепленным проигрышем в вычислениях. Настроенное Rust-ядро — преаллоцированные буферы, явный SIMD, потоки по комбинациям — все еще может перевернуть знак. Но это вопрос вычислений, который решается профилированием и работой над ядром, а вопрос этого исследования — граница. Ответ границы: пересеченная один раз, байтами, она стоит ~0.1%.

Так что соберем полный вердикт, каждая часть которого измерена выше.

Кросс-языковой сервис движка побеждает, когда выполняется все из следующего:

  • Вычислительное преимущество реально — измерено на вашем ядре, а не предположено из репутации языка. (Наше было −13%, пока не доказано обратное — и первое "очевидное" объяснение этого дефицита умерло при тестировании.)
  • Вы пересекаете крупноблочно — один вызов на перебор или на фолд, тысячи раз выше порога в 14 µs, как демонстрируют итоговые 1.13x пакетной архитектуры (~0.1% граница).
  • Вы говорите бинарно — сырые массивы с префиксом длины, Arrow, что угодно класса memcpy на 49 µs за 1.2 MB; никогда текст на 66,243 µs.
  • Данные предзагружены — stateful сервер принимает вызовы только с параметрами у конца кривой echo на ~16 µs, вместо того чтобы пересылать мегабайты заново.

Он проигрывает, когда развернут так, как обычно разворачивают сервисы движка:

  • JSON/REST-микросервис — платит налог сериализации 1348x на каждом вызове, в обоих направлениях; при болтливой гранулярности это 5.3 s кодирования на задаче в 2 s.
  • RPC на единицу работы — на комбинацию это стоит здесь 107 ms и выживает только потому, что каждый вызов несет 25,130 µs вычислений; на бар это ~2.1 s чистого IPC еще до того, как случится хоть какая-то работа, на задаче в 2.0 s.
  • Spawn на каждый вызов — ~24 ms фиксированной стоимости каждый раз, безобидно один раз на перебор, почти две секунды, если платить на каждую комбинацию.

То есть: архитектуры, которые проваливаются, не экзотичны. JSON REST-движок, subprocess на символ, gRPC-на-тик — это честная перепись того, как на самом деле строится "давайте вынесем бэктест-движок отдельно". Народное поверье эмпирически хорошо обосновано как описание распространенной практики и эмпирически неверно как закон природы. Граница никогда не была проблемой. Проблема — способы пересекать ее по умолчанию.

Один аргумент за границу заслуживает отдельного предложения, потому что именно из-за него мы вообще запустили это исследование. Одно скомпилированное ядро за хорошо спроектированной границей может обслуживать и исследовательский перебор, и живой торговый цикл — тот же бинарник, та же арифметика, бит в бит. Наше исследование паритета бэктест-live каталогизировало, как исследовательский и продакшен-движки расходятся, когда это две разные кодовые базы; сервис движка — самое сильное структурное лекарство от этого расхождения, и это исследование честно оценивает цену лекарства: сделано правильно — это около 0.1% wall time и проверка эквивалентности, доказывающая, что при переносе ничего не изменилось. Эта сделка — выделенная граница процесса в обмен на паритет одного ядра — на этих цифрах выгодна. Сделано неправильно, та же идея доставляет в продакшен налог сериализации 1348x, с вашим PnL, катящимся сверху.

Выводы

  1. Граница почти бесплатна; народное поверье не проходит проверку измерением. Round-trip всего close-ряда в 1.2 MB через Unix socket — включая полный парсинг и перекодирование — стоит 2,043.4 µs, около 0.1% от задачи в 2.010 s (получено). Пакетная архитектура Rust-через-сокет выходит на итоговые 1.13x, и ~99% даже этого разрыва — не IPC.
  2. "Перепишите на Rust" — это утверждение про вычисления, проверьте его до покупки границы. Наш построчный Rust-порт вычисляет ~на 13% медленнее numba-ядра (получено: 2.274 s против 2.010 s) — воспроизводимый разрыв в кодогенерации между двумя скалярными LLVM-скомпилированными циклами, который остается не атрибутированным: мы проверили очевидного подозреваемого и отвергли его, поскольку проверенная на эквивалентность сборка get_unchecked без проверок границ оказалась не быстрее (2.337 s против 2.276 s). Наивный Rust не быстрее автоматически; настроенное ядро вполне может быть — измеряйте, затем решайте.
  3. Настоящий налог — это текст. Кодирование 150,000 float в JSON стоит 66,243 µs против 49.1 µs для raw — 1348x, платится за направление, за вызов, на обеих сторонах. Болтливый JSON-деплой сжигает 5.3 s кодирования на задаче в 2 s (получено). Говорите бинарно через границы: сырые фреймы, Arrow — никогда json.dumps на ценовом массиве.
  4. Chatty vs chunky измеримо, и виновник — statelessness. Вызовы на комбинацию, пересылающие данные заново: 1.19x против 1.13x у пакетной (+107 ms, получено; одностороннее предсказание кривой echo в ~81 ms оказывается на ~25% ниже, остальное — это фрейминг на каждый вызов). Предзагруженный stateful сервер взял бы те же 80 вызовов по ~16 µs каждый — примерно 1.3 ms суммарно (получено из порога echo). Пересылайте параметры, а не датасет.
  5. Уважайте порог — и знайте, что порог — это выбор. Наше пересечение Python-через-Unix-socket упирается в порог 14 µs; гранулярность на комбинацию проходит его с запасом ~1,795x (25,130 µs вычислений на вызов) — безопасно. Паттерн на бар (иллюстративная крайность на другой нагрузке: live-движок на каждый тик, не этот перебор) заплатил бы 150,000 × 14 µs ≈ 2.1 s чистого IPC на задаче в 2.0 s (получено) — мертворожденная архитектура даже с бесконечно быстрым движком. Spawn на каждый вызов добавляет фиксированные ~24 ms (получено). А транспорт на shared memory, спроектированный специально под задачу, как ZigBolt, делает round-trip за ~39 ns нативно на этой машине — примерно 360x ниже нашего порога сокета (получено; нативный Zig против клиента на Python, так что читайте это как диапазон, который может занимать порог, а не как гонку).
  6. Пересекайте один раз, байтами, когда данные уже на месте — и граница покупает вам паритет за ~0.1%. Одно ядро, обслуживающее исследования и live, защищенное проверкой эквивалентности (PnL −5165.58, 57,029 сделок, идентично между языками и между обеими Rust-сборками), — это честный кейс для сервиса движка. Нечестные кейсы — JSON, болтливый, spawn на каждый вызов — это те, что создали IPC его репутацию.

Весь эксперимент — Rust-движок, wire-протокол, harness'ы для echo и сериализации, проверка эквивалентности, и каждое число в этой статье, воспроизводимое из одного детерминированного скрипта — в статье-компаньоне на ipc-tax.marketmaker.cc, с кодом и данными на github.com/suenot/ipc-tax.

Сокет никогда не был проблемой. Две миллисекунды за весь датасет, туда и обратно — фольклор ошибся на три порядка величины, причем сразу в обе стороны: слишком пессимистичен насчет байтов, слишком снисходителен к тексту. Пересекайте границу так, будто это чего-то стоит, и это перестанет стоить.

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

Авторы

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-трейдингу и обновления платформы.

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