IPC-налог: спрячьте бэктест-движок за сокетом и потеряйте 13% — при этом сокет почти ни при чем
Статья из серии "Бэктесты без иллюзий".
📄 Эта статья выросла в исследовательскую работу. Одно 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, разбросанных по . 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 с точностью до абсолютных . Зафиксированный прогон отдает all_ok: true и для safe-indexing, и для get_unchecked-сборки. Контрольное значение первой комбинации — PnL −5165.58 процентных пунктов на 57,029 сделках — совпадает с numba-ядром из исследования лесенки скорости цифра в цифру, что привязывает обе статьи к одному и тому же ядру на одном и том же seed. Именно в кросс-языковых портах любит прятаться тихое расхождение (комиссия, примененная до вместо после перевода в проценты, сравнение NaN, ветвящееся по-другому, off-by-one в окне — тот же вид бага, который наша таксономия look-ahead показала способным сфабриковать Sharpe 15 из шума). Бенчмарк двух движков, вычисляющих разные вещи, — это не бенчмарк; это гонка двух несвязанных программ.
Раз эквивалентность установлена, каждое отличие в таблице выше — это граница и вычисления, и больше ничего.
Что на самом деле стоит пересечение: кривая echo

Начнем со скальпеля. Операция echo делает round-trip payload из float через Rust-сервер — Python строит фрейм, сервер парсит все 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, ничего хитрого.
Разумная модель одного пересечения, с обеими измеренными константами:
Теперь поместим заголовочное число в контекст. Весь перебор занимает 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

Вот где народное поверье про "накладные расходы 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 для ценовых массивов, никакая настройка сокета вас не спасет — узкое место и есть протокол.
Болтливый против пакетного: закон Фаулера, измеренный

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

Третий паттерн деплоя — самый старый: вообще никакого сервера. Запустить бинарник движка (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: порог — это барьерная ставка

Все измеренное до сих пор сжимается в одно правило проектирования, и это правило — арифметика, а не мнение.
Каждое пересечение границы стоит как минимум порог задержки — здесь это 14 µs, round-trip echo с крошечным payload, и это близко к лучшему, что предлагает этот транспорт. Этот порог — барьерная ставка (hurdle rate): вызов через границу имеет смысл делать, только если вычисления, которые он пересылает, превышают барьер с комфортным запасом. Определим коэффициент гранулярности
и доля границы в вашем wall time — примерно — плюс транзит payload сверху, если вызов также несет данные.
Теперь пропустим через это числа нашего перебора. Измеренная in-process стоимость одной комбинации — 25,130 µs. При гранулярности на комбинацию:
Вызовы на комбинацию сидят примерно в 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 вычислений). Итог хуже, чем звучит соотношение:
— больше, чем вся in-process работа за 2.010 s, потраченных еще до того, как удаленный движок вычислит хоть одно число, и это остается 2.1 s даже если движок на другой стороне был бы бесконечно быстрым (получено: 150,000 × 14 µs). Никакое вычислительное преимущество не переживает настолько тонкую гранулярность. И вспомните, что этот порог — Unix socket на одном хосте; сделайте тот же вызов на бар к сервису через сеть, и порог вырастет на два-три порядка, на 150,000 вызовах.

Еще одна честная калибровка, потому что 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 в одном предложении: границе все равно, насколько быстр ваш движок; она берет плату за каждое пересечение, так что переменные, которые вы контролируете, — это сколько работы несет каждое пересечение и из чего сделано само пересечение. Пакетируйте на весь перебор — и больше ста тысяч. Пакетируйте на комбинацию, — все еще нормально. Вызывайте на каждый бар через сокет, — архитектура мертва еще до первой оптимизации, и никаким переписыванием движка, на Rust или на чем угодно еще, ее не воскресить.
Где на самом деле живет 1.13x — и вердикт

Время честно препарировать заголовочный разрыв, потому что он несет самый контринтуитивный вывод исследования.
Пакетная 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, катящимся сверху.
Выводы
- Граница почти бесплатна; народное поверье не проходит проверку измерением. Round-trip всего close-ряда в 1.2 MB через Unix socket — включая полный парсинг и перекодирование — стоит 2,043.4 µs, около 0.1% от задачи в 2.010 s (получено). Пакетная архитектура Rust-через-сокет выходит на итоговые 1.13x, и ~99% даже этого разрыва — не IPC.
- "Перепишите на Rust" — это утверждение про вычисления, проверьте его до покупки границы. Наш построчный Rust-порт вычисляет ~на 13% медленнее numba-ядра (получено: 2.274 s против 2.010 s) — воспроизводимый разрыв в кодогенерации между двумя скалярными LLVM-скомпилированными циклами, который остается не атрибутированным: мы проверили очевидного подозреваемого и отвергли его, поскольку проверенная на эквивалентность сборка
get_uncheckedбез проверок границ оказалась не быстрее (2.337 s против 2.276 s). Наивный Rust не быстрее автоматически; настроенное ядро вполне может быть — измеряйте, затем решайте. - Настоящий налог — это текст. Кодирование 150,000 float в JSON стоит 66,243 µs против 49.1 µs для raw — 1348x, платится за направление, за вызов, на обеих сторонах. Болтливый JSON-деплой сжигает 5.3 s кодирования на задаче в 2 s (получено). Говорите бинарно через границы: сырые фреймы, Arrow — никогда
json.dumpsна ценовом массиве. - Chatty vs chunky измеримо, и виновник — statelessness. Вызовы на комбинацию, пересылающие данные заново: 1.19x против 1.13x у пакетной (+107 ms, получено; одностороннее предсказание кривой echo в ~81 ms оказывается на ~25% ниже, остальное — это фрейминг на каждый вызов). Предзагруженный stateful сервер взял бы те же 80 вызовов по ~16 µs каждый — примерно 1.3 ms суммарно (получено из порога echo). Пересылайте параметры, а не датасет.
- Уважайте порог — и знайте, что порог — это выбор. Наше пересечение 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, так что читайте это как диапазон, который может занимать порог, а не как гонку).
- Пересекайте один раз, байтами, когда данные уже на месте — и граница покупает вам паритет за ~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.
Сокет никогда не был проблемой. Две миллисекунды за весь датасет, туда и обратно — фольклор ошибся на три порядка величины, причем сразу в обе стороны: слишком пессимистичен насчет байтов, слишком снисходителен к тексту. Пересекайте границу так, будто это чего-то стоит, и это перестанет стоить.
Авторы
Инженер торговых систем
Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.