IPC салығы: бэктест қозғалтқышын сокеттің артына қойып, 13% жоғалту — оның ішінде сокетке қатысты дерлік ештеңе жоқ
"Иллюзиясыз бэктесттер" сериясының бөлігі.
📄 Бұл мақала зерттеу мақаласына айналды. Жолға тәуелді бэктест ядросы numba тілінен Rust тіліне жолма-жол порттталып, процесс/тіл шекарасы арқылы төрт түрлі жолмен шақырылады, комбинация сайынғы бірдей PnL растайтын эквиваленттілік қақпасымен — сонымен қатар таза IPC кідірту қисығының, сериализация салығының және spawn құнының оқшауланған өлшемдерімен. Мақаланы онлайн оқыңыз (интерактивті нұсқа + PDF) ipc-tax.marketmaker.cc сайтында, код пен деректер github.com/suenot/ipc-tax мекенжайында.
Жылдам болып шыққан кез келген бэктест қозғалтқышы ерте ме, кеш пе бір әңгімені тудырады. Біздікі уақтылы келді. Жылдамдық баспалдағы енді ғана 80 комбинациялы параметр сынағын pandas-тың 69.9 секундынан бір ағынды numba-ның шамамен 2 секундына дейін түсірген болатын, және келесі табиғи қызығушылық: неге Python JIT-те тоқтап қалу керек? Ядроны Rust тілінде қайта жазу. Оны толыққанды қозғалтқыш қызметіне айналдыру — сокеттің артында тұрған компиляцияланған бинарлық файл, әрбір зерттеу скриптінен, әрбір тілден және тіпті лайв-трейдерден шақырылатын. Бір ядро, бір ақиқат, қайталанған логика жоқ.
Содан кейін қарсы аргумент те уақтылы келеді: процесстен шыққан сәтте IPC сізді жейді. Деректерді сериализациялау керек, шекара арқылы жіберу керек, десериализациялау керек; әрбір шақыру syscall мен контекст ауыстыруларға ақы төлейді; сіздің әдемі Rust ядроңыз бүкіл өмірін құбыр күтумен өткізеді. Процессте қалыңыз. Мұны бәрі біледі.
Бұл мақала бәрі білетінін өлшейді, және өлшеу дау-дамайдың екі жағынан да қызықтырақ. Халықтық сенім — "жылдамырақ тілдер аралық қозғалтқыш процесс ішіндегі numba-дан жеңіледі, өйткені IPC сізді өлтіреді" — жалпы алғанда қате, тек арнайы жағдайларда ғана дұрыс болып шығады. Шекараны бір рет, шикі байттармен кесіп өту екі секундтық тапсырмада шамамен 2 миллисекунд тұрады: дөңгелектеу қатесі. Салық шекарада емес. Ол — оны қалай кесіп өтуіңізде — және қозғалтқыш қызметтерінің табиғи ортада әдетте орналастырылатын үш тәсілі (JSON API, жұмыс бірлігі сайынғы шақыру, шақыру сайынғы процесс spawn'ы) әрқайсысы, өлшенетіндей түрде, фольклор болжайтын апаттың бір бөлігі болып табылады.
Міне, бүкіл эксперимент алдын ала. Төмендегінің бәрі әрбір жолдың анатомиясы.
| Архитектура | Сынақ сайын шекара арқылы не өтеді | Нақты уақыт | процесс ішіндегіге қатысты |
|---|---|---|---|
| процесс ішіндегі numba | ештеңе — тікелей шақыру | 2.010 с | 1.00x |
| Rust сервері, batched (Unix сокеті) | бір рейс: бүкіл серия + барлық 80 параметр жинағы | 2.276 с | 1.13x |
Rust сервері, batched, get_unchecked ядросы |
сол бір рейс — шекара тексерусіз ядро нұсқасы (үкімді қараңыз) | 2.337 с | 1.16x |
| Rust сервері, chatty (Unix сокеті) | 80 рейс: серия комбинация сайын қайта жіберіледі | 2.383 с | 1.19x |
| Rust spawn (stdin/stdout) | процесс spawn'ы + бір құбыр арқылы жіберілген сұраныс | 2.300 с | 1.14x |
Apple M2 Max, Python 3.14.6, numpy 2.4.3, numba 0.64.0, rustc 1.94.0 (релиз құрастыруы, сыртқы crate жоқ). 150,000 бар × 80 комбинация, 0.09% рейс ақысы, seed 42; жабылу сериясы сымда 1,200,000 байт (1.2 МБ) құрайды. Архитектура сайын 10 жүгірістің медианасы; мин-макс аралықтары ~2% шегінде қалады. Барлық бесеуі бірдей HMA/HMA3 stop-and-reverse сынағын жүргізеді, ал эквиваленттілік қақпасы екі Rust ядро нұсқасының да комбинация сайынғы (PnL, мәміле саны) нәтижелерінің numba-мен дәл сәйкес келетінін растайды — саусақ ізі PnL −5165.58, 57,029 мәміле бойынша, жылдамдық баспалдағы зерттеуінің numba ядросымен сол seed бойынша байт-байтпен бірдей. Біз шекараларды салыстырамыз, іске асыруларды емес.
Batched жолын мұқият оқыңыз, өйткені ол бүкіл тезисті көтереді. Rust-сокет-арқылы архитектурасы процесс ішіндегі numba-дан 1.13x баяу — толық сынақта 266 мс артта қалады (туынды: 2.276 − 2.010). Халықтық әңгіме сол миллисекундтарды IPC дейді. Олай емес. Сол алшақтықтың шамамен 2 мс шекара болып табылады — бүкіл 1.2 МБ жабылу сериясы жіберіліп, нәтижелер қайтарылып, тікелей өлшенген. Қалған ~264 мс біздің қарапайым Rust ядросының numba ядросынан шамамен 13% баяу есептеуіне байланысты (туынды: 2.276 с минус ~2 мс шекара ≈ 2.274 с Rust есептеуі, numba-ның 2.010 с-мен салыстырғанда). Тіл ретіндегі Rust тіл ретіндегі Python-ға жеңілген жоқ; бір скалярлық LLVM-компиляцияланған цикл екінші коденерациялау жарысында жеңілді — және біз жеңілісті айқын күдіктіге де тағайындай алмадық: сол ядроның шекара тексерусіз get_unchecked құрастыруы жылдамырақ болып шықпады (2.337 с; үкім бөлімі мұны бөлшектейді). Сокеттің мұның бәріне қатысы дерлік болмады.
Осы сөйлемнің екі бөлігін де ұстап тұрыңыз. Шекара дұрыс кесіп өткенде дерлік тегін — және "оны Rust-та қайта жаз" сізге орналастыру шекарасын сатып алады, автоматты есептеу жеңісін емес. Екі факт те танымал интуицияға қайшы келеді, және екеуі де кестеде.
Бір ядро, екі тіл, төрт шекара
Жұмыс жүктемесі жылдамдық баспалдағы бекіткенмен әдейі бірдей, сондықтан екі зерттеу бір-бірімен байланысады. Ядро — HMA/HMA3 қиылысы — екі Hull стиліндегі жылжымалы орташалардағы stop-and-reverse жүйесі, параметр комбинациясы сайын жеті өлшенген-жылжымалы-орташа өтуі, плюс позицияны алып жүретін, әр қиылыста 0.09% рейс ақысын шегеріп PnL жазатын және кері бұрылатын күй сақтайтын бар-бар оқиға циклі. Деректер — 150,000 бар seed арқылы жасалған синтетикалық геометриялық броундық қозғалыс (seed=42); тор — аралығына таралған 80 HMA ұзындығы. Процесс ішіндегі эталон — баспалдақтың бір ағынды numba сатысы, осы зерттеу үшін қайта өлшенген: сонда 1.98 с, мұнда 2.010 с — сол ядро, сол машина, сендіретіндей жалықтырарлық.
Тілдер аралық қозғалтқыш сол numba ядросының Rust тіліне жолма-жол порты — сол циклдер, сол NaN өңдеуі, сол ақы арифметикасы — сыртқы crate жоқ релиз режимінде компиляцияланған, сондықтан бүкіл эксперимент тәуелділіксіз және қайта жаңғыртылатын күйде қалады. Ол әдейі минималды бинарлық хаттамада сөйлейді: әрбір бағытта ұзындықпен префиксі бар фрейм, барлығы 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 opcode'ы зерттеудің скальпелі: ештеңе есептемейтін, басқарылатын өлшемдегі рейс, сондықтан таза шекара құнын оқшауланған түрде өлшеуге болады — сериализация, syscall, сокет транзиті, десериализация, және басқа ешнәрсе.
Бес өлшенген архитектура — төрт шекара үлгісі плюс бір ядро нұсқасы:
- in_process — numba ядросын тікелей шақыру. Шекара жоқ. Эталон.
- rust_batch_unix — Unix домен сокетіндегі тұрақты Rust сервері. Бір рейс бүкіл жабылу сериясы мен барлық 80 параметр жинағын жібереді; Rust әрбір комбинацияны есептейді; бір жауап қайтады. Ауыр шақыру.
- rust_batch_unchecked — сол batched шекара, бірақ ядро
get_uncheckedарқылы индекстейді (ыстық жолда шекара тексеруі жоқ). Есептеу алшақтығы туралы белгілі бір гипотезаны тексеру үшін бар; үкім бөлімі оны жұмсайды. - rust_chatty_unix — сол сервер, бірақ комбинация сайын бір рейс, 1.2 МБ серия әр жолы қайта жіберіледі. Қарапайым RPC-жұмыс-бірлігі-сайын архитектурасы.
- rust_spawn_stdin — бинарлық файлды сынақ сайын spawn ету және сұранысты stdin арқылы жіберу. "CLI қозғалтқышты сыртқа шақыру" үлгісі; процесс құру құнын төлейді.
Және эквиваленттілік қақпасы, онсыз мұның бәрі мағынасыз болар еді: уақыт өлшегеннен кейін, әрбір Rust нұсқасының комбинация сайынғы (PnL, мәміле саны) векторы numba-мен салыстырылады — мәміле саны дәл, PnL абсолютті дейін. Тапсырылған жүгіріс екі құрастыруға да — қауіпсіз индекстеу мен get_unchecked — all_ok: true хабарлайды. Бірінші комбинацияның саусақ ізі — PnL −5165.58 пайыздық тармақ, 57,029 мәміле бойынша — жылдамдық баспалдағы зерттеуінің numba ядросымен сан-санмен сәйкес келеді, бұл екі мақаланы да сол seed бойынша бір ядроға бекітеді. Тілдер аралық порттар дәл үнсіз алшақтық жақсы көретін жер (пайызға түрлендіруден бұрын емес, кейін қолданылатын ақы, әртүрлі тармақталатын NaN салыстыруы, терезедегі бір-бір қате — біздің look-ahead ауытқу таксономиясы шудан 15 Sharpe жасай алатынын көрсеткен сол қате түрі). Әртүрлі нәрсені есептейтін екі қозғалтқыштың эталон сынағы эталон сынағы емес; бұл екі байланыссыз бағдарламаның жарысы.
Эквиваленттілік орнатылғаннан кейін, жоғарыдағы кестедегі әрбір айырмашылық — шекара мен есептеу — басқа ешнәрсе емес.
Кесіп өту нақты не тұрады: эхо қисығы

Скальпельден бастайық. Echo операциясы флоуттан тұратын пейлоудты Rust сервері арқылы рейске жібереді — Python фреймді құрастырады, сервер барлық флоутты талдайды, оларды қайта кодтайды және кері жібереді. Екі бағыт та сериализация, syscall және сокет транзитіне ақы төлейді. Міне, өлшенген қисық (10 жүгірістің медианасы):
| Пейлоуд (флоут) | Бағыт сайын байт | Рейс |
|---|---|---|
| 1 | 8 | 14.1 мкс |
| 100 | 800 | 16.4 мкс |
| 1,000 | 8,000 | 18.1 мкс |
| 10,000 | 80,000 | 192.5 мкс |
| 100,000 | 800,000 | 1,367.3 мкс |
| 150,000 | 1,200,000 | 2,043.4 мкс |
Бұл кестеде екі құрылымдық факт өмір сүреді.
Біріншіден, түбі. Мәні бойынша ештеңе алып жүрмейтін рейс — 8 байт — 14 мкс тұрады. Бұл осы тасымал арқылы шақыру жасаудың дербес бағасы: екі write syscall, екі read syscall, ядро сокет механизмі, жоспарлаушының оянуы. Қисықтың сол жақта қаншалықты жалпақ екеніне назар аударыңыз: 1 флоуттан 1,000 флоутқа дейін құн әрең қозғалады (14.1 → 18.1 мкс). Шамамен 8 КБ-тан төмен сіз шақыру үшін ақы төлейсіз, байттар үшін емес. Бұл сан — кідірту түбі — бүкіл зерттеудің ең маңызды тұрақтысы, және біз төменде тепе-теңдік нүктесінің арифметикасын осыған негіздейміз.
Екіншіден, көлбеу. ~10,000 флоуттан кейін қисық өткізу қабілетіне тәуелді және шамамен сызықты болады. Толық 1.2 МБ серия — жалпы 2.4 МБ жылжытылды, барып-қайту, соның ішінде Rust жағында 150,000 флоуттың толық талдауы мен қайта кодталуы — 2,043.4 мкс тұрады. Бұл бүкіл қарапайым стек арқылы тиімді ~1.2 ГБ/с береді (туынды: 2.4 МБ / 2.04 мс) — ұзындықпен префиксі бар фреймдер мен байт-байт флоут талдаушысы бар Unix домен сокеті, ешбір zero-copy әдістері жоқ, ортақ жады жоқ, ешбір айлакерлік жоқ.
Екі өлшенген тұрақтысымен бір кесіп өтудің ақылға қонымды моделі:
Енді бас тақырыптағы санды контекстке қойайық. Толық сынақ процесс ішінде 2.010 с алады. Оның бүкіл деректер жиынын шекара арқылы барып-қайту жіберу ~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 домен сокетінен. Қозғалтқыш сонымен қатар TCP-де сөйлейді (TCP_NODELAY арқылы), бірақ біз оны өлшеген жоқпыз; loopback TCP осы түптерден біршама жоғары орналасады, ал нақты желі секірісі мүлдем басқа режим — миллисекунд түбі, микросекунд емес. Сондықтан мұндағы бәрі шекараны осылай кесіп өтудің ең жақсы мүмкін жағдайы. Бұл әрі қарай өлшенетін салықтарды одан да айыптырақ етеді: олар сіз соның үстіне таңдау бойынша төлейтін нәрсе.
Сериализация салығы: JSON таңдағаны үшін 1348x

Міне, "IPC жүктемесі" туралы халықтық сенімнің қате белгіленуі болып шығатын жер. Біз сол 150,000-флоут жабылу сериясын кодтау құнын үш жолмен өлшедік — дәл жоғарыдағы әрбір архитектура жіберетін пейлоуд:
| Кодтау | 1.2 МБ флоутты кодтау уақыты | шикімен салыстырғанда |
|---|---|---|
шикі байттар (.tobytes()) |
49.1 мкс | 1.0x |
| pickle | 29.8 мкс | 0.6x |
JSON (json.dumps(close.tolist())) |
66,243 мкс | 1348x |
Шикі жол — функция шақыруы киінген 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 тіпті біздің шикі жолымыздан сәл арзанырақ түседі, өйткені astype dtype сәйкес келсе де dtype түрлендіру көшірмесін төлейді; екеуі де memcpy класына жатады және екеуі де дөңгелектеу қателері. Бинарлық отбасы жалпы алғанда мәтіндік отбасыдан үш ретті шамада төмен өмір сүреді.)
Ал мәтіндік жол — дерлік әрбір "қозғалтқышты микросервис жасайық" орналастыруы шын мәнінде жіберетін нәрсе:
body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})
Алпыс алты миллисекунд. Тек кодтау үшін. json.dumps(close.tolist()) әрбір флоутты Python нысанына жинайды, содан кейін әрқайсысын ондық мәтін ретінде рендерлейді — шикі жол бір блок көшірмесін жасаған жерде 150,000 куча бөлінуі және 150,000 флоут-жолдыққа түрлендіру. Ал сым пейлоуды да үлкейеді (float64 бинарлықта 8 байт тұрады және ондық мәтін ретінде шамамен екі-үш есе — біз қосымша транзитке ақы да алмадық).
Енді мұны нақты орналастыру жасайтындай масштабтайық. Сол 66 мс — бір кодтау, бір жақ, бір шақыру. JSON қызметі кодтау мен декодтауды екеуін де, шекараның екі жағында, әрбір шақыруда төлейді. JSON арқылы бір batched шақыру бүкіл сынақтың есептеу бюджетінің ~3.3% тек клиент жағындағы кодтауға жағар еді (туынды: 66 мс / 2.010 с). JSON-ды chatty архитектурасының астына қойыңыз — комбинация сайын бір шақыру, төмендегі үлгі — және тек клиент жағындағы кодтау 80 × 66 мс = 5.3 с тұрады: бір байт та қозғалмастан және сервер ештеңе талдамастан бүкіл пайдалы жұмыстың екі жарым есесінен астам (туынды).
Бұл көптеген командалардың білместен өндірісте өлшеген нақты "IPC салығы". Ол ешқашан процессаралық байланыс болған емес. Ол сандық массивтердің мәтіндік сериализациясы болды — шекараның ең арзан құрамдасына өз-өзіне жасалған 1348x. Бағаналы әлем бұл сабақты жылдар бұрын үйренді, және бұл біздің Polars мен pandas зерттеуі деректер құбыры жағынан үнемі тап болатын дәл сол сабақ. Arrow сияқты форматтар дәл сол себепті бар — массив деректері процесс пен тіл шекараларын мәтін ретінде емес, шикі бағаналы байттар ретінде кесіп өте алуы үшін. Егер сіздің қозғалтқыш қызметіңіз баға массивтері үшін JSON сөйлесе, ешбір сокет баптауы сізді құтқармайды — хаттаманың өзі тар жер.
Chatty мен chunky: Фаулер заңы, өлшенген

Мартин Фаулердің Бөлінген нысан жобалауының бірінші заңы — "нысандарыңызды бөлмеңіз" — оның сол демде тұжырымдаған салдарымен келеді: егер шекараны кесіп өту керек болса, интерфейс ірі түйіршікті болуы керек, өйткені қашықтағы шақыру жергіліктіден шамалар ретінде қымбатқа түседі. Әрбір бөлінген жүйелер ардагері келіседі. Дерлік ешкімде өз жұмыс жүктемесі үшін сан жоқ. Міне, бізде бар.
Chunky мен chatty архитектуралары сол сервер, сол хаттама, сол деректер жұмыс істейді — тек шақыру түйіршіктілігі ерекшеленеді:
srv.call(0, close, params)
[srv.call(0, close, [params[k]]) for k in range(n)]
Chunky: 2.276 с (1.13x). Chatty: 2.383 с (1.19x) — 107 мс баяу (туынды: 2.383 − 2.276). Бұл дельтаның нені білдіретіні және білдірмейтіні туралы дәл болу үшін: эхо қисығы оған қарапайым болжам береді — толық серияны әрбір 2,043 мкс толық-пейлоуд рейсінің шамамен жартысында қосымша 79 рет жіберу, шамамен 81 мс — бұл өлшенген 107 мс-тан шамамен 25% төмен түседі; қалғаны Python жағындағы шақыру сайынғы сұраныс құрастыру мен фрейминг, оны эхо болжамы қамтымайды. Қалай болғанда да, бұл қосымша кесіп өту сайын ~1.4 мс шығады (туынды: 107 / 79); жауаптар елеусіз — комбинация сайын 16 байт.
Сол 107 мс-тың екі оқылымы бар, және екеуі де маңызды.
Жеңіл оқылым: бұл жалпы уақыттың тек ~4.5%, апат емес. Дұрыс — және фольклордың апаты неге мұнда жүзеге аспағанын түсіну маңызды. Әрбір chatty шақыру әлі де 25,130 мкс нақты есептеуді алып жүреді (бір комбинацияның құны — процесс ішіндегі комбинация сайынғы өлшенген құн), сондықтан шақыру сайынғы ~1.4 мс шекара жүктемесі шақыру сайынғы жұмыстан бір ретті шама төмен қалады. Chatty архитектуралары әрбір шақыру шынымен ауыр болғанда өлімге әкелмейді. Олар түйіршіктілік кішірейген сайын өлімге әкеледі — бұл тепе-теңдік нүктесі бөлімінің бүкіл тақырыбы.
Айыптаушы оқылым: бұл салық толығымен ерікті болды, және ол шақыру саны × пейлоудпен масштабталады. Chatty үлгісі деректер жиынын әрбір шақыруда бір ғана себеппен қайта жібереді: қызмет күйсіз, сондықтан әрбір сұраныс бүкіл контекстті алып жүруі керек. Бұл қарапайым "сынақ соңғы нүктесінің" әдепкі формасы — және дерлік әрбір ақ тақтада эскиз жасалған REST микросервисінің. Күйлі сервер — серияны бір рет жүктеу, содан кейін 48 байт параметр фреймдерін жіберу — әрбір комбинация сайынғы шақыруды эхо қисығының кіші-пейлоуд соңына жақын орналастырар еді: шақыру сайын шамамен 16 мкс, барлық 80-і үшін шамамен 1.3 мс (эхо түбінен туынды; аналитикалық, бөлек өлшенбеген). Chatty жазасы кішірекпейді; ол жоғалады. Сабақ дәл: мәселе көп шақыру жасауда емес — ол хаттама әрбір шақыруды бірінші деп теседі, сондықтан күйді қайта жіберуде.
Деректерді алдын ала жүктеңіз. Параметрлерді жіберіңіз. Шекараны әр жолы бүкіл әлемді чемоданыңызда емес, ниетпен кесіп өтіңіз.
Spawn құны: қозғалтқышты шақыру сайын жалдау

Үшінші орналастыру үлгісі ең ескісі: сервер мүлдем жоқ. Қозғалтқыш бинарлық файлын spawn ету, сұранысты stdin арқылы жіберу, жауапты stdout-тан оқу, оны өлуге қалдыру. Әрбір shell скриптерінің инстинкті, әрбір "Python-нан CLI-ды тек шақыр" интеграциясы, сынақ сайын бинарлық файл іске қосуға конфигурацияланған әрбір гиперпараметр фреймворкі.
Өлшенген: 2.300 с (1.14x) — тұрақты-сервер batch-тан шамамен 24 мс жоғары (туынды: 2.300 − 2.276). Сол 24 миллисекунд fork/exec, динамикалық жүктеуші, құбыр орнату және процесс бұзуды сатып алады. Және мұның не өлшейтіні үлгі үшін түбіне жақын екенін ескеріңіз: парақ кэшінде жылы, тәуелділіксіз кішкентай нативті бинарлық файл. Кез келген нәрсені орындалу ортасымен spawn ету — JVM, импорттары бар Python интерпретаторы — әлдеқайда қымбатқа түседі; біз мұны мұнда өлшеген жоқпыз, бірақ бағыт күмән тудырмайды.
Бұл салықтың құрылымы маңызды: ол шақыру сайын тұрақты, шақыру қанша жұмыс алып жүретініне қарамастан. Толық 80-комбинациялы сынаққа бөлінгенде, 24 мс шамамен 1% — шу. Комбинация сайын қайта spawn етіңіз, сол тұрақты 80 × ~24 мс ≈ 1.9 с болады — негізінен бүкіл пайдалы жұмыс процесс құруға жағылады (туынды; аналитикалық). Бар сайын қайта spawn етіңіз, арифметика жазуға да тұрмайды.
Тұрақты құн, ұсақ түйіршіктілік: біреуін таңдаңыз. Spawn төлейтін үлгі тек spawn сирек болғанда және оның артындағы пейлоуд орасан зор болғанда ғана ақылға қонымды — дәл біздің бір-spawn-сынақ-сайын өлшеуіміз сияқты, және символ саны өскен сайын символ-сайынғы ішкі процесс архитектуралары шын мәнінде пайдаланылатын жолға дәл қарама-қарсы.
Тепе-теңдік нүктесінің арифметикасы: түбі — минималды межелік мөлшерлеме

Осы уақытқа дейін өлшенгеннің бәрі бір жобалау ережесіне қысылады, және ереже — арифметика, пікір емес.
Әрбір шекара кесіп өтуі кем дегенде кідірту түбін тұрады — мұнда 14 мкс, кіші-пейлоуд эхо рейсі, және осы тасымал ұсынатын ең жақсыға жақын. Сол түбі — минималды межелік мөлшерлеме: шекара арқылы шақыру тек жіберілетін есептеу сол межені ыңғайлы еселікпен өткенде ғана құнды. Түйіршіктілік қатынасын анықтаңыз
және сіздің жалпы уақытыңыздағы шекараның үлесі шамамен — егер шақыру деректерді де алып жүрсе, пейлоуд транзиті үстіне қосылады.
Енді сынақтың сандарын осы арқылы жүргізейік. Бір комбинацияның өлшенген процесс ішіндегі құны 25,130 мкс. Комбинация сайынғы түйіршіктілікте:
Комбинация сайынғы шақырулар түбінен ~1,795x жоғары орналасады — шекара шақыру сайын пайыздың оннан бір бөлігінен әлдеқайда аз талап етеді. Сондықтан chatty архитектурасы да тек 107 мс жоғалтты: осы жұмыс жүктемесінің түйіршіктілігінде, деректерді қайта жібермейтін немесе мәтінде сөйлемейтін кез келген кесіп өту үлгісі қауіпсіз бөлінеді. Комбинация-деңгейлі, fold-деңгейлі, сынақ-деңгейлі шақырулардың бәрі арзан аймақта терең.
Енді қарама-қарсы шекке ауысайық. Бұл — иллюстративті жұмыс-жүктемелер-аралық экстраполяция — біздің сынақтың нұсқасы емес, шын мәнінде табиғи ортада бар жұмыс жүктемесі формасы: қозғалтқышқа бар сайын жүгінеді. Лайв стиліндегі тик-сайынғы қозғалтқыш қызметі; gRPC-бар-сайын сигнал ағыны; 150,000 бардың әрқайсысы үшін бір рет сұралатын "стратегия сервері". Осы ядродағы бар сайынғы пайдалы есептеу 25,130 мкс / 150,000 ≈ 0.17 мкс (туынды) — әрбір шақыру пайдалы жұмыста өз шекара құнының шамамен 1/84 бөлігін алып жүрер еді (туынды: 0.168 мкс есептеу үстіндегі 14.05 мкс түбі). Жалпы саны қатынас естілгеннен де нашар:
— бүкіл 2.010 с процесс ішіндегі тапсырмадан да көп, қашықтағы қозғалтқыш бір санды есептемей тұрып жұмсалды, және қашықтағы қозғалтқыш шексіз жылдам болса да 2.1 с болып қалар еді (туынды: 150,000 × 14 мкс). Осындай ұсақ түйіршіктілікте ешбір есептеу артықшылығы аман қалмайды. Және есіңізде болсын, бұл түбі бір хосттағы Unix сокеті; сол бар-сайынғы шақыруды желі арқылы қызметке жасаңыз, түбі 150,000 шақыруда екі-үш ретті шамаға өседі.

Тағы бір адал калибрлеу, өйткені 14 мкс те физика заңы емес — бұл біздің тасымалымыздың бағасы: Python клиенті, ядро сокеті, екі бағытта syscall. Бір машинаға арнайы жасалған тасымал әлдеқайда төмен түседі. ZigBolt — HFT жұмыс жүктемелеріне арналған біздің ашық көзді Zig хабарлама шинасы, дәл осы машинада нативті түрде бенчмарк жасалған — ортақ жады сақинасының рейсін орта есеппен шамамен 39 нс уақытта орындайды (64/256/1024 байт хабарламаларда 10/20/30 нс бір бағытты p50). Бұл біздің сокет түбінен шамамен 360x төмен (туынды: 14.05 мкс / 39 нс). Салыстыру әдейі алма-мен-апельсин, және біз мұны солай белгілейміз: біздің 14 мкс — Python-клиент сокет рейсі, ZigBolt-тың 39 нс — ортақ жады арқылы нативті Zig, сондықтан алшақтық тасымал мен орындалу ортасын араластырады. Мұны екеуінің арасындағы жарыс ретінде емес, бір машина түбі алатын ауқым: шамамен үш ретті шама, іске асыру бойынша таңдалған ретінде оқыңыз. Бұл қазіргі киімдегі ескі Lightweight RPC сабағы (Bershad және т.б., 1990) — бір машинадағы кесіп өтулерге хаттама механизмі басым, және олар тасымал бір машина жағдайы үшін құрылғанда құлайды. Жоғарыдағы тепе-теңдік нүктесінің арифметикасы пішінін өзгертпейді; меже тек жылжиды. 39 нс түбінде, бар-сайынғы түйіршіктілік те оны өтер еді (150,000 × 39 нс ≈ 5.9 мс, туынды) — бұл дәл HFT жүйелерінің REST қызметі көтере алмайтын шекараларды қалай көтере алатынын көрсетеді.
Бұл бүкіл тепе-теңдік нүктесі әңгімесі бір сөйлемде: шекара сіздің қозғалтқышыңыздың қаншалықты жылдам екеніне мән бермейді; ол кесіп өту сайын ақы алады, сондықтан сіз бақылайтын айнымалылар — әрбір кесіп өту қанша жұмыс алып жүреді және кесіп өту неден жасалған. Сынақ сайын batch жасаңыз, жүз мыңнан асады. Комбинация сайын batch жасаңыз, — әлі де жақсы. Сокет арқылы бар сайын шақырыңыз, — архитектура бірінші оңтайландырудан бұрын өлген, және қозғалтқышты қайта жазу, Rust-та немесе басқа нәрседе, оны қайта тірілте алмайды.
1.13x нақты қайда өмір сүреді — және үкім

Бас тақырыптағы алшақтықты адал бөлшектеу уақыты, өйткені ол зерттеудің ең интуицияға қайшы нәтижесін алып жүреді.
Batched Rust архитектурасы процесс ішіндегі numba-дан 266 мс артта қалады (туынды: 2.276 − 2.010). Өлшенген шекара құрамдастары: ~2.0 мс-та толық-пейлоуд рейсі, 49 мкс-та шикі сериализация, бірнеше байтта фрейм тақырыптары — бүкіл шекара шотын ~2 мс деп атайық. Сондықтан алшақтықтың 99%-нан астамы шекара мүлдем емес. Бұл есептеу: IPC-ден тазартылған, Rust сервері numba 2.010 с-та жасайтын сынақты жасауға ~2.274 с жұмсайды — қарапайым Rust ядросы шикі есептеуде шамамен 13% баяу (туынды).
Бұл ашық параграфқа лайық, өйткені "оны Rust-та қайта жаз, ол жылдамырақ болады" "IPC сізді өлтіреді" сияқты халықтық сенім. Екі ядро да ақыр соңында LLVM-ге түседі — numba Python bytecode-ын соның арқылы төмендетеді, rustc MIR-ды соның арқылы төмендетеді — және екеуі де, ең ықтимал, скалярлық циклдер ретінде жұмыс істейді: WMA-ның ішкі қосындысы — қалқымалы нүктелі редукция, оны LLVM numba-ның @njit әдепкілері бермейтін және біздің порт сұрамайтын fast-math қайта байланыстыру лицензиясынсыз автоматты векторлай алмайды. Сондықтан ~13% екі скалярлық LLVM-компиляцияланған цикл арасындағы өлшенген коденерациялау алшақтығы — және себепті мәлімдеудің орнына, біз айқын күдіктіні тексердік. Табиғи күдікті — Rust-тың қауіпсіз индекстеуі: ыстық WMA циклі әрбір массив қатынасында шекараны тексереді, ал numba-ның @njit-і шекара тексеруі өшірулі күйде компиляцияланады. Сондықтан біз сол ядроның get_unchecked қолданатын эквиваленттілігі тексерілген нұсқасын құрдық — ыстық жолда ешбір жерде шекара тексеруі жоқ — және оны бесінші архитектура ретінде өлшедік. Ол алшақтықты жаппады: 2.337 с (1.16x), шекара-тексерілген құрастырудың 2.276 с-нан аздап баяу. Гипотеза тексерілді, гипотеза қабылданбады. Білімнің адал жағдайы: ~13% нақты және қайта жаңғыртылатын (10 жүгірістің медианасы, ~2% шегіндегі аралықтар), және қазіргі уақытта тағайындалмаған — тек ассемблер деңгейіндегі профильдеу шеше алатын бөлу мінез-құлқындағы, цикл құрылымындағы немесе нұсқау жоспарлаудағы қандай да бір айырмашылық. Сабақ бүтін қалады: қарапайым Rust автоматты түрде жақсы numba-дан жылдам емес, және тегін есептеу жеңісі болжамымен сатып алынған тіл шекарасы есептеу шығынымен келуі мүмкін. Реттелген Rust ядросы — алдын ала бөлінген буферлер, айқын SIMD, комбинациялар бойынша ағындар — әлі де таңбаны аудара алады. Бірақ бұл профильдеу мен ядро жұмысымен шешілетін есептеу сұрағы, ал осы зерттеудің сұрағы — шекара. Шекараның жауабы: бір рет кесіп өткенде, байттарда, ол ~0.1% тұрады.
Сонымен, толық үкімді жинайық, жоғарыда өлшенген әрбір тармақ.
Тілдер аралық қозғалтқыш қызметі мыналардың бәрі орындалғанда жеңеді:
- Есептеу артықшылығы нақты — тілдің беделінен болжанбай, сіздің ядроңызда өлшенген. (Бізде дәлелденгенге дейін −13% болды — және сол тапшылықтың алғашқы "айқын" түсіндірмесі сынақта өлді.)
- Сіз ірі түйіршікті кесіп өтесіз — сынақ сайын немесе fold сайын бір шақыру, 14 мкс түбінен мыңдаған есе жоғары, batch архитектурасының жалпы 1.13x-і (~0.1% шекара) көрсеткендей.
- Сіз бинарлық тілде сөйлейсіз — ұзындықпен префиксі бар шикі массивтер, Arrow, 1.2 МБ-та 49 мкс тұратын memcpy класындағы кез келген нәрсе; 66,243 мкс тұратын мәтін ешқашан емес.
- Деректер алдын ала жүктелген — күйлі сервер мегабайттарды қайта жіберудің орнына эхо қисығының ~16 мкс соңында тек-параметр шақыруларын қабылдайды.
Ол қозғалтқыш қызметтері әдетте орналастырылатындай орналастырылғанда жеңіледі:
- JSON/REST микросервисі — әрбір шақыруда, екі бағытта да 1348x сериализация салығын төлейді; chatty түйіршіктілікте бұл 2 с тапсырмада 5.3 с кодтау.
- Жұмыс бірлігі сайынғы RPC — комбинация сайын мұнда 107 мс тұрады және тек әрбір шақыру 25,130 мкс есептеуді алып жүретіндіктен аман қалады; бар сайын бұл кез келген жұмыс болмай тұрып ~2.1 с таза IPC, 2.0 с тапсырмада.
- Шақыру сайынғы spawn — әр жолы ~24 мс тұрақты құн, сынақ сайын бір рет зиянсыз, комбинация сайын төленгенде екі секундқа жуық.
Яғни: сәтсіздікке ұшырайтын архитектуралар экзотикалық емес. JSON REST қозғалтқышы, символ-сайынғы ішкі процесс, gRPC-тик-сайын — бұл "бэктест қозғалтқышын бөлек шығарайық" шын мәнінде қалай құрылатынының әділ санағы. Халықтық сенім жалпы тәжірибенің сипаттамасы ретінде эмпирикалық түрде жақсы негізделген және табиғат заңы ретінде эмпирикалық түрде қате. Шекара ешқашан мәселе болған емес. Оны кесіп өтудің әдепкі тәсілдері мәселе.
Шекараның пайдасына бір аргумент өз сөйлеміне лайық, өйткені бұл біз бұл зерттеуді мүлдем жүргізген себеп. Жақсы жобаланған шекараның артындағы бір компиляцияланған ядро зерттеу сынағы мен лайв сауда циклін де қызмет ете алады — сол бинарлық файл, сол арифметика, бит-биттен. Біздің бэктест-лайв паритет зерттеуі зерттеу мен өндіріс қозғалтқыштары екі кодтық база болғанда қалай алшақтайтынын каталогтады; қозғалтқыш қызметі сол ауытқудың ең күшті құрылымдық емдеуі, және бұл зерттеу емдеудің бағасын адал белгілейді: дұрыс жасалғанда, жалпы уақыттың шамамен 0.1% және аудармада ештеңе өзгермегенін дәлелдейтін эквиваленттілік қақпасы. Ол айырбас — арнайы процесс шекарасы бір-ядро паритетіне айырбасталады — осы сандар бойынша тиімді мәміле. Дұрыс жасалмаса, сол идея PnL-іңіз үстінде отырып өндіріске 1348x сериализация салығын жібереді.
Негізгі қорытындылар
- Шекара дерлік тегін; халықтық сенім өлшеуде сәтсіздікке ұшырайды. Бүкіл 1.2 МБ жабылу сериясын Unix сокеті арқылы барып-қайту жіберу — толық талдау мен қайта кодтауды қоса алғанда — 2,043.4 мкс тұрады, 2.010 с тапсырманың шамамен 0.1% (туынды). Batched Rust-сокет-арқылы архитектурасы жалпы алғанда 1.13x-ке жетеді, және сол алшақтықтың да ~99% IPC емес.
- "Оны Rust-та қайта жаз" — есептеу мәлімдемесі — шекараны сатып алмас бұрын оны тексеріңіз. Біздің жолма-жол Rust портымыз numba ядросынан ~13% баяу есептейді (туынды: 2.274 с қарсы 2.010 с) — тағайындалмай қалатын, екі скалярлық LLVM-компиляцияланған цикл арасындағы қайта жаңғыртылатын коденерациялау алшақтығы: біз айқын күдіктіні тексердік және қабылдамадық, өйткені шекара тексерусіз эквиваленттілігі тексерілген
get_uncheckedқұрастыруы жылдамырақ болып шықпады (2.337 с қарсы 2.276 с). Қарапайым Rust автоматты түрде жылдам емес; реттелген ядро жылдам болуы мүмкін — өлшеңіз, содан кейін шешіңіз. - Нақты салық — мәтін. 150,000 флоутты JSON ретінде кодтау 66,243 мкс тұрады, шикіде 49.1 мкс-пен салыстырғанда — 1348x, бағыт сайын, шақыру сайын, екі жақта да төленген. Chatty JSON орналастыруы 2 с тапсырмада 5.3 с кодтауды жағады (туынды). Шекаралар арқылы бинарлық тілде сөйлеңіз: шикі фреймдер, Arrow — баға массивінде ешқашан
json.dumpsемес. - Chatty мен chunky өлшенеді, және күйсіздік кінәлі. Деректерді қайта жіберетін комбинация сайынғы шақырулар: batch-тың 1.13x-іне қарсы 1.19x (+107 мс, туынды; эхо қисығының ~81 мс бір бағытты болжамы одан ~25% төмен түседі, қалғаны шақыру сайынғы фрейминг). Алдын ала жүктелген күйлі сервер сол 80 шақыруды әрқайсысы ~16 мкс-та алар еді — жалпы шамамен 1.3 мс (эхо түбінен туынды). Параметрлерді жіберіңіз, деректер жиынын емес.
- Түбін құрметтеңіз — және түбінің таңдау екенін біліңіз. Біздің Python-Unix-сокет-арқылы кесіп өтуіміз 14 мкс-та түбіне жетеді; комбинация сайынғы түйіршіктілік оны ~1,795x өтеді (шақыру сайын 25,130 мкс есептеу) — қауіпсіз. Бар-сайынғы үлгі (иллюстративті жұмыс-жүктемелер-аралық шек: лайв тик-сайынғы қозғалтқыш, бұл сынақ емес) 2.0 с тапсырмада 150,000 × 14 мкс ≈ 2.1 с таза IPC төлер еді (туынды) — шексіз жылдам қозғалтқышпен де келгенде өлі. Шақыру сайын spawn ету тұрақты ~24 мс қосады (туынды). Ал ZigBolt сияқты арнайы жасалған ортақ жады тасымалы осы машинада нативті түрде ~39 нс-та рейс жасайды — біздің сокет түбінен ~360x төмен (туынды; нативті Zig қарсы Python клиенті, сондықтан мұны түбі алатын ауқым ретінде оқыңыз, жарыс ретінде емес).
- Бір рет, байттарда, деректер бұрыннан бар кезде кесіп өтіңіз — және шекара сізге ~0.1%-ке паритет сатып алады. Зерттеу мен лайвті де қызмет ететін, эквиваленттілік тексеруімен қорғалған (PnL −5165.58, 57,029 мәміле, тілдер арасында және екі Rust құрастыруында да бірдей) бір ядро — қозғалтқыш қызметінің адал жағдайы. Адал емес жағдайлар — JSON, chatty, шақыру-сайынғы spawn — IPC-ге оның беделін берген жағдайлар.
Толық эксперимент — Rust қозғалтқышы, сым хаттамасы, эхо мен сериализация тексеру аппараттары, эквиваленттілік қақпасы, және осы мақаладағы бір детерминистік скрипттен қайта жасалатын әрбір сан — ipc-tax.marketmaker.cc сайтындағы серіктес мақалада, код пен деректер github.com/suenot/ipc-tax мекенжайында.
Сокет ешқашан мәселе болған емес. Бүкіл деректер жиынына екі миллисекунд, барып-қайту — фольклор үш ретті шамаға қателесті, және бір мезгілде екі бағытта да: байттарға қатысты тым пессимистік, мәтінге қатысты тым кешірімді. Шекараны бір нәрсе тұратындай кесіп өтіңіз, сонда ол тұрмайды.
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.