← Kembali ke artikel
July 2, 2026
5 menit baca

Pajak IPC: Taruh Engine Backtest di Balik Socket dan Rugi 13% — Nyaris Tak Ada yang Disebabkan oleh Socket-nya

Pajak IPC: Taruh Engine Backtest di Balik Socket dan Rugi 13% — Nyaris Tak Ada yang Disebabkan oleh Socket-nya
#algotrading
#backtest
#performa
#ipc
#rust
#arsitektur
Part 4 of 4 · Collection
High-Performance Backtest Engines

Bagian dari seri "Backtests Without Illusions".

📄 Artikel ini berkembang menjadi paper riset. Satu kernel backtest path-dependent di-port baris demi baris dari numba ke Rust dan dipanggil lintas batas proses/bahasa dengan empat cara, dengan gerbang ekuivalensi yang mengonfirmasi PnL per-kombo identik — plus pengukuran terisolasi dari kurva latensi IPC murni, pajak serialisasi, dan biaya spawn. Baca paper-nya online (versi interaktif + PDF) di ipc-tax.marketmaker.cc, kode dan data di github.com/suenot/ipc-tax.

Setiap engine backtest yang bertambah cepat pada akhirnya memicu percakapan yang sama. Punya kami muncul tepat waktu. Tangga kecepatan baru saja membawa sweep parameter 80-kombinasi dari 69.9 detik pandas turun ke sekitar 2 detik numba single-threaded, dan gatal alami berikutnya adalah: mengapa berhenti di JIT Python? Tulis ulang kernelnya di Rust. Jadikan itu engine service sungguhan — satu binary terkompilasi di balik sebuah socket, dapat dipanggil dari setiap skrip riset, setiap bahasa, dan juga trader live. Satu kernel, satu kebenaran, tanpa logika terduplikasi.

Dan kemudian argumen tandingannya muncul, juga tepat waktu: begitu Anda meninggalkan proses, IPC akan memakan Anda. Data harus diserialisasi, dikirim lintas batas, dideserialisasi; setiap panggilan membayar syscall dan context switch; kernel Rust Anda yang indah akan menghabiskan hidupnya menunggu di depan pipe. Tetaplah in-process. Semua orang tahu ini.

Artikel ini mengukur hal yang semua orang tahu, dan hasil pengukurannya lebih menarik daripada kedua sisi argumen. Keyakinan populer — "engine cross-language yang lebih cepat kalah dari numba in-process karena IPC membunuh Anda" — ternyata salah secara umum dan hanya benar dalam kondisi spesifik. Menyeberangi batas sekali, dalam raw bytes, berbiaya sekitar 2 milidetik pada pekerjaan dua detik: sebuah rounding error. Pajaknya bukan pada batasnya. Pajaknya ada pada bagaimana Anda menyeberanginya — dan tiga cara engine service biasanya di-deploy di dunia nyata (JSON API, panggilan per unit pekerjaan, spawn proses per panggilan) masing-masing, secara terukur, adalah sepotong dari bencana yang diprediksi oleh folklore itu.

Berikut seluruh eksperimennya di muka. Semua yang di bawah adalah anatomi dari setiap baris.

Arsitektur Apa yang menyeberangi batas per sweep Wall time vs in-process
in-process numba tidak ada — panggilan langsung 2.010 s 1.00x
Server Rust, batched (Unix socket) satu round-trip: seluruh seri + semua 80 set parameter 2.276 s 1.13x
Server Rust, batched, kernel get_unchecked round-trip tunggal yang sama — varian kernel bebas bounds-check (lihat vonisnya) 2.337 s 1.16x
Server Rust, chatty (Unix socket) 80 round-trip: seri dikirim ulang per kombo 2.383 s 1.19x
Rust spawn (stdin/stdout) spawn proses + satu request yang di-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 build, zero crate eksternal). 150,000 bar × 80 kombo, fee round-trip 0.09%, seed 42; seri close-nya adalah 1,200,000 byte (1.2 MB) di kabel. Median dari 10 run per arsitektur; spread min–max tetap dalam ~2%. Kelima-limanya menjalankan sweep stop-and-reverse HMA/HMA3 yang sama, dan sebuah gerbang ekuivalensi mengonfirmasi bahwa hasil (PnL, jumlah transaksi) per-kombo dari kedua varian kernel Rust cocok persis dengan numba — fingerprint PnL −5165.58 pada 57,029 transaksi, byte-identical dengan kernel numba dari studi tangga kecepatan pada seed yang sama. Kami membandingkan batas, bukan implementasi.

Baca baris batched-nya baik-baik, karena ia membawa keseluruhan tesis. Arsitektur Rust-over-socket 1.13x lebih lambat daripada numba in-process — tertinggal 266 ms pada sweep penuh (derived: 2.276 − 2.010). Cerita rakyatnya bilang milidetik-milidetik itu adalah IPC. Bukan. Sekitar 2 ms dari celah itu adalah batasnya — seluruh seri close 1.2 MB dikirim masuk, hasil dikirim kembali, diukur langsung. ~264 ms sisanya adalah karena kernel Rust naif kami sekadar menghitung sweep sekitar 13% lebih lambat daripada kernel numba (derived: 2.276 s dikurangi ~2 ms batas ≈ 2.274 s compute Rust, vs 2.010 s untuk numba). Rust-sang-bahasa tidak kalah dari Python-sang-bahasa; satu loop terkompilasi-LLVM scalar kalah dalam perlombaan codegen melawan yang lain — dan kami bahkan tidak bisa menyematkan kekalahan itu pada tersangka yang jelas: build get_unchecked bebas bounds-check dari kernel yang sama ternyata tidak lebih cepat (2.337 s; bagian vonis membedah ini). Socket-nya nyaris tidak ada hubungannya dengan semua ini.

Pegang kedua separuh kalimat itu. Batasnya nyaris gratis ketika diseberangi dengan benar — dan "tulis ulang di Rust" membelikan Anda sebuah batas deployment, bukan kemenangan compute otomatis. Kedua fakta ini berlawanan dengan insting populer, dan keduanya ada di tabel.

Satu kernel, dua bahasa, empat batas

Beban kerjanya sengaja dibuat sama dengan yang dipatok oleh tangga kecepatan, sehingga kedua studi ini saling berlabuh satu sama lain. Kernelnya adalah HMA/HMA3 cross — sistem stop-and-reverse pada dua moving average bergaya Hull, tujuh pass weighted-moving-average per kombinasi parameter ditambah event loop bar-per-bar yang stateful, yang membawa sebuah posisi, mencatat PnL dikurangi fee round-trip 0.09% pada setiap cross, dan membalik arah. Datanya adalah 150,000 bar geometric Brownian motion sintetis yang di-seed (seed=42); grid-nya adalah 80 panjang HMA tersebar pada [6,200][6, 200]. Referensi in-process adalah anak tangga numba single-threaded milik tangga kecepatan, diukur ulang untuk studi ini: 1.98 s di sana, 2.010 s di sini — kernel yang sama, mesin yang sama, membosankan dengan meyakinkan.

Engine cross-language-nya adalah port baris demi baris dari kernel numba tersebut ke Rust — loop yang sama, penanganan NaN yang sama, aritmetika fee yang sama — dikompilasi dalam mode release tanpa crate eksternal, sehingga seluruh eksperimen tetap bebas dependency dan bisa direproduksi. Ia berbicara dalam protokol biner yang sengaja diminimalkan: satu frame length-prefixed di setiap arah, semuanya 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

Opcode echo adalah pisau bedah studi ini: sebuah round-trip berukuran terkendali yang tidak menghitung apa pun, sehingga biaya batas murni bisa diukur secara terisolasi — serialisasi, syscall, transit socket, deserialisasi, dan tidak ada yang lain.

Lima arsitektur yang diukur — empat pola batas ditambah satu varian kernel:

  • in_process — panggil kernel numba secara langsung. Tanpa batas. Referensinya.
  • rust_batch_unix — server Rust persisten pada Unix domain socket. Satu round-trip mengirim seluruh seri close plus semua 80 set parameter; Rust menghitung setiap kombo; satu balasan kembali. Panggilan yang chunky.
  • rust_batch_unchecked — batas batched yang sama, tetapi kernelnya mengindeks dengan get_unchecked (tanpa bounds check di hot path). Ini ada untuk menguji hipotesis spesifik tentang celah compute; bagian vonis akan membahasnya habis.
  • rust_chatty_unix — server yang sama, tetapi satu round-trip per kombo, seri 1.2 MB dikirim ulang setiap kali. Arsitektur RPC-per-unit-kerja yang naif.
  • rust_spawn_stdin — spawn binary per sweep dan pipe permintaan lewat stdin. Pola "shell out ke CLI engine"; membayar pembuatan proses.

Dan gerbang ekuivalensi, yang tanpanya semua ini tidak berarti apa-apa: setelah pengukuran waktu, vektor (PnL, jumlah transaksi) per-kombo dari setiap varian Rust dibandingkan terhadap milik numba — jumlah transaksi harus persis, PnL hingga absolut 10610^{-6}. Run yang di-commit melaporkan all_ok: true untuk build safe-indexing maupun build get_unchecked. Fingerprint kombo pertama — PnL −5165.58 poin persentase pada 57,029 transaksi — cocok digit demi digit dengan kernel numba dari studi tangga kecepatan, yang mematok kedua paper ke kernel yang sama pada seed yang sama. Port cross-language justru adalah tempat favorit divergensi diam-diam bersembunyi (fee yang diterapkan sebelum alih-alih sesudah konversi persen, perbandingan NaN yang bercabang berbeda, off-by-one pada sebuah window — spesies bug yang sama yang ditunjukkan oleh taksonomi look-ahead kami bisa memproduksi Sharpe 15 dari noise). Benchmark dari dua engine yang menghitung hal berbeda bukanlah benchmark; itu adalah dua program tak berkaitan yang berlomba.

Dengan ekuivalensi yang sudah ditetapkan, setiap perbedaan pada tabel di atas adalah batas dan compute — tidak ada yang lain.

Apa yang sebenarnya dibayar untuk menyeberang: kurva echo

Biaya terukur dari sebuah penyeberangan batas: kurva latensi datar di empat belas mikrodetik untuk payload kecil, baru menanjak setelah sepuluh ribu float, mencapai dua milidetik untuk seri penuh 1.2-megabyte

Mulai dengan pisau bedahnya. Operasi echo melakukan round-trip payload sebanyak nn float melalui server Rust — Python membangun frame-nya, server mem-parsing semua nn float, meng-encode ulang, dan mengirimnya kembali. Kedua arah membayar serialisasi, syscall, dan transit socket. Berikut kurva terukurnya (median dari 10 run):

Payload (float) Byte per arah 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

Dua fakta struktural hidup di tabel ini.

Pertama, lantainya (floor). Round-trip yang membawa praktis tidak ada apa-apa — 8 byte — berbiaya 14 µs. Itulah harga tak terhindarkan dari melakukan panggilan sama sekali lewat transport ini: dua syscall write, dua syscall read, mesin socket kernel, wake-up scheduler. Perhatikan betapa datarnya kurva di sisi kiri: dari 1 float hingga 1,000 float biayanya nyaris tidak bergerak (14.1 → 18.1 µs). Di bawah sekitar 8 KB Anda membayar untuk panggilannya, bukan byte-nya. Angka ini — lantai latensi — adalah konstanta tunggal terpenting di seluruh studi, dan kita akan membangun aritmetika break-even di atasnya di bawah.

Kedua, kemiringannya (slope). Setelah ~10,000 float, kurva menjadi bandwidth-bound dan kira-kira linear. Seri 1.2 MB penuh — 2.4 MB dipindahkan total, pergi dan pulang, termasuk parse dan re-encode penuh 150,000 float di sisi Rust — berbiaya 2,043.4 µs. Itu setara dengan ~1.2 GB/s efektif lewat seluruh stack naif ini (derived: 2.4 MB / 2.04 ms) — sebuah Unix domain socket dengan frame length-prefixed dan parser float byte-per-byte, tanpa trik zero-copy, tanpa shared memory, tanpa apa pun yang canggih.

Model yang masuk akal dari satu penyeberangan, dengan kedua konstanta terukur:

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}}

Sekarang taruh angka headline-nya dalam konteks. Seluruh sweep memakan 2.010 s in-process. Mengirim seluruh dataset-nya melintasi batas dan kembali berbiaya ~2.0 ms — sekitar 0.1% dari total pekerjaan (derived: 2.0434 ms / 2.010 s). Jika Anda menyeberang sekali, dalam raw bytes, batasnya adalah rounding error. Itulah separuh dari keyakinan populer yang mati lebih dulu: ketakutannya tidak pernah tentang sesuatu yang semurah ini.

Sisi Rust dari penyeberangan itu senaif mungkin kode sistem bisa jadi — diadaptasi dari 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());
}

Satu catatan lingkup yang jujur sebelum lanjut: semua angka batas dalam studi ini adalah Unix domain socket pada satu host. Engine-nya juga bisa berbicara TCP (dengan TCP_NODELAY), tetapi kami tidak mengukurnya; loopback TCP berada sedikit di atas lantai-lantai ini, dan network hop sungguhan adalah rezim yang sama sekali berbeda — lantai berskala milidetik, bukan mikrodetik. Semua di sini karenanya adalah kasus nyaris terbaik untuk menyeberangi batas dengan cara ini. Yang membuat pajak-pajak yang diukur berikutnya makin memberatkan: itu adalah apa yang Anda bayar di atas itu, atas pilihan sendiri.

Pajak serialisasi: 1348x untuk memilih JSON

Dua encoding dari array 150,000-float yang sama berdampingan: sebuah memcpy raw-bytes diukur dalam mikrodetik berhadapan dengan encoding teks JSON yang menjulang tiga orde besaran lebih tinggi

Di sinilah keyakinan populer tentang "overhead IPC" ternyata adalah pelabelan yang keliru. Kami mengukur biaya meng-encode seri close 150,000-float yang sama dengan tiga cara — payload persis yang dikirim setiap arsitektur di atas:

Encoding Waktu meng-encode 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

Jalur raw-nya adalah sebuah memcpy yang menyamar sebagai pemanggilan fungsi:

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 bahkan mendarat sedikit lebih murah daripada jalur raw kami karena astype membayar copy konversi-dtype meski dtype-nya sudah cocok; keduanya sekelas memcpy dan keduanya adalah rounding error. Keluarga biner secara keseluruhan hidup tiga orde besaran di bawah keluarga teks.)

Dan jalur teks adalah apa yang sebenarnya dikirim oleh hampir setiap deployment "mari jadikan engine ini microservice":

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

Enam puluh enam milidetik. Untuk meng-encode. json.dumps(close.tolist()) mem-boks setiap float ke dalam objek Python, lalu me-render masing-masing sebagai teks desimal — 150,000 alokasi heap dan 150,000 konversi float-ke-string di mana jalur raw hanya melakukan satu block copy. Dan payload di kabel juga menggembung (float64 berbiaya 8 byte dalam biner dan kira-kira dua hingga tiga kali itu sebagai teks desimal — kami bahkan belum membebankan biaya transit ekstranya).

Sekarang skalakan dengan cara deployment sungguhan melakukannya. 66 ms itu adalah satu encode, satu sisi, satu panggilan. Layanan JSON membayar encode dan decode, pada kedua sisi batas, pada setiap panggilan. Satu panggilan batched tunggal lewat JSON akan menghabiskan ~3.3% dari seluruh anggaran compute sweep hanya untuk encoding sisi klien (derived: 66 ms / 2.010 s). Taruh JSON di bawah arsitektur chatty — satu panggilan per kombo, pola di bawah — dan encoding sisi klien saja berbiaya 80 × 66 ms = 5.3 s: lebih dari dua setengah kali seluruh pekerjaan yang berguna (derived), sebelum satu byte pun bergerak dan sebelum server mem-parsing apa pun.

Inilah "pajak IPC" sesungguhnya yang sudah diukur kebanyakan tim di produksi tanpa mereka sadari. Itu tidak pernah menjadi inter-process communication. Itu adalah serialisasi teks dari array numerik — sebuah 1348x yang ditimpakan sendiri pada komponen batas yang paling murah. Dunia kolumnar mempelajari pelajaran ini bertahun-tahun lalu, dan itu adalah pelajaran yang sama yang terus ditemui oleh studi Polars vs pandas kami dari sisi data-pipeline: format seperti Arrow ada justru agar data array bisa menyeberangi batas proses dan bahasa sebagai raw columnar bytes, bukan sebagai teks. Jika engine service Anda berbicara JSON untuk array harga, tidak ada tuning socket yang bisa menyelamatkan Anda — protokolnya adalah bottleneck-nya.

Chatty vs chunky: hukum Fowler, terukur

Sebuah arsitektur chunky mengirim satu payload framed besar melintasi batas sekali, di samping arsitektur chatty yang melakukan delapan puluh round-trip kecil yang masing-masing menyeret seluruh dataset

First Law of Distributed Object Design dari Martin Fowler — "jangan mendistribusikan objek Anda" — datang dengan corollary yang ia jabarkan dalam napas yang sama: jika Anda harus menyeberangi batas, interface-nya harus coarse-grained (berbutir kasar), karena panggilan remote berbiaya orde besaran lebih mahal daripada panggilan lokal. Setiap veteran distributed-systems mengangguk setuju. Hampir tidak ada yang punya angka untuk beban kerja mereka sendiri. Ini angka kami.

Arsitektur chunky dan chatty menjalankan server yang sama, protokol yang sama, data yang sama — hanya granularitas panggilannya yang berbeda:

srv.call(0, close, params)

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

Chunky: 2.276 s (1.13x). Chatty: 2.383 s (1.19x) — 107 ms lebih lambat (derived: 2.383 − 2.276). Untuk tepat soal apa delta itu dan apa yang bukan: kurva echo memberi prediksi naif untuknya — 79 pengiriman ekstra dari seri penuh pada kira-kira setengah dari round-trip full-payload 2,043 µs masing-masing, sekitar 81 ms — yang mendarat sekitar 25% di bawah 107 ms terukur; sisanya adalah pembangunan request dan framing per-panggilan di sisi Python, yang tidak dimasukkan oleh prediksi echo. Bagaimanapun juga itu menjadi ~1.4 ms per penyeberangan ekstra (derived: 107 / 79); balasannya dapat diabaikan — 16 byte per kombo.

Dua cara membaca 107 ms itu, dan keduanya penting.

Pembacaan yang longgar: itu hanya ~4.5% dari wall time, bukan bencana. Benar — dan patut dipahami mengapa bencana folklore itu gagal terwujud di sini. Setiap panggilan chatty tetap membawa 25,130 µs compute sungguhan (setara satu kombo — biaya per-kombo in-process yang terukur), sehingga overhead batas per-panggilan sebesar ~1.4 ms tetap satu orde besaran di bawah pekerjaan per-panggilan. Arsitektur chatty tidak fatal ketika setiap panggilan sungguh-sungguh berat. Ia menjadi fatal seiring granularitas mengecil — yang menjadi keseluruhan subjek bagian break-even.

Pembacaan yang memberatkan: pajak ini sepenuhnya sukarela, dan skalanya mengikuti jumlah panggilan × payload. Pola chatty mengirim ulang dataset pada setiap panggilan karena satu alasan saja: service-nya stateless, sehingga setiap request harus membawa seluruh konteks. Itulah bentuk default dari "sweep endpoint" yang naif — dan pada dasarnya dari hampir setiap REST microservice yang pernah disketsakan di whiteboard. Server yang stateful — muat seri sekali, lalu kirim frame parameter 48-byte — akan menaruh setiap panggilan per-kombo dekat ujung payload-kecil dari kurva echo: sekitar 16 µs per panggilan, kira-kira 1.3 ms untuk semua 80 (derived dari lantai echo; analitis, tidak diukur terpisah). Penalti chatty-nya tidak akan mengecil; ia akan lenyap. Pelajarannya presisi: masalahnya bukan membuat banyak panggilan — masalahnya adalah mengirim ulang state karena protokolnya berpura-pura setiap panggilan adalah yang pertama.

Preload datanya. Kirim parameternya. Seberangi batas dengan niat, bukan dengan seluruh dunia di koper Anda setiap kali.

Biaya spawn: menyewa engine per panggilan

Sebuah binary engine di-spawn dari nol untuk satu request tunggal: pembuatan proses, loader, dan setup pipe bertumpuk sebagai gerbang tol tetap di depan ruas pendek pekerjaan yang berguna

Pola deployment ketiga adalah yang tertua: tanpa server sama sekali. Spawn binary engine-nya, pipe satu request lewat stdin, baca balasannya dari stdout, biarkan ia mati. Naluri setiap shell scripter, setiap integrasi "tinggal panggil CLI dari Python", setiap framework hyperparameter yang dikonfigurasi untuk meluncurkan sebuah binary per trial.

Terukur: 2.300 s (1.14x) — sekitar 24 ms di atas batch server-persisten (derived: 2.300 − 2.276). 24 milidetik itu membeli sebuah fork/exec, dynamic loader, setup pipe, dan teardown proses. Dan perhatikan bahwa yang diukur ini dekat dengan lantai untuk pola ini: sebuah binary native kecil bebas dependency, hangat di page cache. Men-spawn apa pun dengan runtime — sebuah JVM, interpreter Python dengan import — berbiaya jauh lebih mahal; kami tidak mengukurnya di sini, tetapi arahnya tidak diragukan.

Struktur pajak inilah yang penting: ia tetap per panggilan, tidak peduli seberapa banyak pekerjaan yang dibawa panggilan itu. Diamortisasi pada sweep 80-kombo penuh, 24 ms adalah sekitar 1% — noise. Spawn ulang per kombo dan konstanta yang sama menjadi 80 × ~24 ms ≈ 1.9 s — pada dasarnya seluruh pekerjaan yang berguna habis terbakar untuk pembuatan proses (derived; analitis). Spawn ulang per bar dan aritmetikanya bahkan tidak layak dituliskan.

Biaya tetap, granularitas halus: pilih salah satu. Pola yang membayar spawn hanya masuk akal ketika spawn-nya jarang dan payload di baliknya sangat besar — persis seperti pengukuran satu-spawn-per-sweep kami, dan persis berlawanan dengan cara arsitektur per-symbol-subprocess akhirnya dipakai begitu jumlah simbol bertambah.

Aritmetika break-even: sebuah lantai adalah hurdle rate

Aritmetika break-even pada sebuah timbangan: empat belas mikrodetik lantai batas di satu sisi ditimbang terhadap compute yang dibawa setiap panggilan, dengan panggilan per-kombo jauh di atas permukaan air dan panggilan per-bar tenggelam

Semua yang terukur sejauh ini terkompresi menjadi satu aturan desain, dan aturannya adalah aritmetika, bukan opini.

Setiap penyeberangan batas berbiaya minimal lantai latensi — 14 µs di sini, round-trip echo payload-kecil, dan mendekati yang terbaik yang bisa ditawarkan transport ini. Lantai itu adalah hurdle rate: sebuah panggilan lintas batas hanya layak dilakukan jika compute yang dikirimnya melampaui hurdle dengan kelipatan yang nyaman. Definisikan rasio granularitas

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

dan porsi batas dari wall time Anda kira-kira 1/(1+G)1/(1+G) — dengan transit payload di atasnya jika panggilan itu juga membawa data.

Sekarang jalankan angka-angka sweep melewatinya. Biaya in-process terukur untuk satu kombo adalah 25,130 µs. Pada granularitas per-kombo:

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

Panggilan per-kombo berada ~1,795x di atas lantai — batasnya mengklaim jauh di bawah sepersepuluh persen per panggilan. Inilah mengapa bahkan arsitektur chatty hanya kehilangan 107 ms: pada granularitas beban kerja ini, setiap pola penyeberangan yang tidak mengirim ulang data atau berbicara teks teramortisasi dengan aman. Panggilan level-kombo, level-fold, level-sweep semuanya jauh di dalam zona murah.

Sekarang balik ke ekstrem yang berlawanan. Yang ini adalah ekstrapolasi cross-workload ilustratif — bukan varian dari sweep kami, tetapi bentuk beban kerja yang sungguh ada di dunia nyata: engine-nya dikonsultasikan per bar. Sebuah engine service per-tick bergaya live; sebuah signal stream gRPC-per-bar; sebuah "strategy server" yang di-poll sekali untuk setiap satu dari 150,000 bar. Compute berguna per bar pada kernel ini adalah 25,130 µs / 150,000 ≈ 0.17 µs (derived) — setiap panggilan akan membawa sekitar 1/84 dari biaya batasnya sendiri sebagai pekerjaan berguna (derived: lantai 14.05 µs dibagi 0.168 µs compute). Totalnya lebih buruk daripada kedengarannya rasio itu:

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}

— lebih dari seluruh pekerjaan in-process 2.010 s, dihabiskan sebelum engine remote menghitung satu angka pun, dan itu akan tetap 2.1 s bahkan jika engine di sisi lain memiliki kecepatan tak terhingga (derived: 150,000 × 14 µs). Tidak ada keunggulan compute yang selamat dari granularitas sehalus itu. Dan ingat lantai ini adalah Unix socket pada satu host; jadikan panggilan per-bar itu ke sebuah service lintas jaringan dan lantainya tumbuh dua hingga tiga orde besaran, pada 150,000 panggilan.

Lantai batas same-machine sebagai pilihan implementasi: round-trip Python-over-Unix-socket pada empat belas mikrodetik menjulang di atas penyeberangan shared-memory ring pada tiga puluh sembilan nanodetik, terpaut tiga orde besaran

Satu kalibrasi jujur lagi, karena 14 µs juga bukan hukum fisika — itu adalah harga dari transport kami: sebuah klien Python, sebuah kernel socket, syscall di kedua arah. Transport same-machine yang purpose-built bisa jauh lebih rendah. ZigBolt — bus messaging Zig open-source kami untuk beban kerja HFT, di-benchmark secara native pada mesin yang sama ini — melakukan round-trip shared-memory ring dalam rata-rata sekitar 39 ns (one-way p50 sebesar 10/20/30 ns pada pesan 64/256/1024-byte). Itu kira-kira 360x di bawah lantai socket kami (derived: 14.05 µs / 39 ns). Perbandingan ini sengaja apples-to-oranges, dan kami menandainya sebagai demikian: 14 µs kami adalah round-trip socket klien-Python, 39 ns ZigBolt adalah Zig native lewat shared memory, sehingga celahnya mencampur transport dan runtime. Bacalah ini bukan sebagai perlombaan antara keduanya, melainkan sebagai rentang yang bisa ditempati lantai same-machine: sekitar tiga orde besaran, dipilih oleh implementasi. Ini adalah pelajaran lama Lightweight RPC (Bershad et al., 1990) dalam balutan modern — penyeberangan same-machine didominasi oleh mesin protokol, dan mereka runtuh ketika transport-nya dibangun untuk kasus same-machine. Aritmetika break-even di atas tidak berubah bentuk; hurdle-nya hanya berpindah. Pada lantai 39 ns, bahkan granularitas per-bar akan melampauinya (150,000 × 39 ns ≈ 5.9 ms, derived) — yang persis menjelaskan bagaimana sistem HFT mampu membeli batas yang tidak sanggup dibeli oleh sebuah REST service.

Ini adalah keseluruhan cerita break-even dalam satu kalimat: batasnya tidak peduli seberapa cepat engine Anda; ia membebankan biaya per penyeberangan, sehingga variabel yang Anda kendalikan adalah seberapa banyak pekerjaan yang dibawa setiap penyeberangan — dan terbuat dari apa penyeberangan itu. Batch per sweep dan GG berada di atas seratus ribu. Batch per kombo, G1795G \approx 1795 — masih baik-baik saja. Panggil per bar lewat socket, G<1G < 1 — arsitekturnya sudah mati sebelum optimisasi pertama, dan tidak ada penulisan ulang engine, di Rust atau apa pun, yang bisa menghidupkannya kembali.

Di mana 1.13x itu sebenarnya berada — dan vonisnya

Celah 266-milidetik dibedah: sepotong tipis dua milidetik dilabeli sebagai batas di samping bongkahan besar perbedaan codegen terukur antara dua kernel scalar terkompilasi, dengan keyakinan populer dicoret

Waktunya membedah celah headline secara jujur, karena ia membawa temuan paling kontra-intuitif dari studi ini.

Arsitektur Rust batched tertinggal dari numba in-process sebesar 266 ms (derived: 2.276 − 2.010). Komponen batas yang terukur: satu round trip full-payload pada ~2.0 ms, serialisasi raw pada 49 µs, header frame pada segenggam byte — sebut seluruh tagihan batasnya ~2 ms. Karenanya lebih dari 99% dari celah itu sama sekali bukan batasnya. Itu adalah compute: dilucuti dari IPC, server Rust menghabiskan ~2.274 s mengerjakan sweep yang dikerjakan numba dalam 2.010 s — kernel Rust yang naif sekitar 13% lebih lambat pada raw compute (derived).

Itu pantas mendapat paragraf yang tidak mengelak, karena "tulis ulang di Rust dan ia akan lebih cepat" sama-sama keyakinan populer seperti "IPC akan membunuh Anda." Kedua kernel bermuara di LLVM — numba menurunkan bytecode Python lewatnya, rustc menurunkan MIR lewatnya — dan keduanya kemungkinan besar berjalan sebagai loop scalar: sum internal WMA adalah reduksi floating-point, yang tidak akan di-auto-vectorize oleh LLVM tanpa lisensi reassociation fast-math yang tidak diberikan default @njit numba dan tidak diminta oleh port kami. Jadi ~13% itu adalah celah codegen terukur antara dua loop terkompilasi-LLVM yang sama-sama scalar — dan alih-alih menegaskan sebuah penyebab, kami menguji yang paling jelas. Tersangka alaminya adalah safe indexing Rust: loop WMA yang panas melakukan bounds-check pada setiap akses array, sementara @njit numba dikompilasi dengan bounds checking dimatikan. Jadi kami membangun varian kernel yang sama dan terverifikasi-ekuivalensi menggunakan get_unchecked — tanpa bounds check di mana pun pada hot path — dan mengukur waktunya sebagai arsitektur kelima. Ia tidak menutup celahnya: 2.337 s (1.16x), marginal lebih lambat daripada build bounds-checked-nya 2.276 s. Hipotesis diuji, hipotesis ditolak. Keadaan pengetahuan yang jujur: ~13% itu nyata dan bisa direproduksi (median dari 10 run, spread dalam ~2%), dan saat ini tidak teratribusi — semacam perbedaan dalam perilaku alokasi, struktur loop, atau penjadwalan instruksi yang hanya bisa dituntaskan oleh profiling level-assembly. Pelajarannya tetap utuh: Rust naif tidak otomatis lebih cepat daripada numba yang bagus, dan sebuah batas bahasa yang dibeli atas asumsi kemenangan compute gratis bisa tiba dengan kerugian compute yang menempel. Kernel Rust yang tuned — buffer prealokasi, SIMD eksplisit, thread lintas kombo — masih bisa membalik tandanya. Tapi itu adalah pertanyaan compute, untuk dituntaskan lewat profiling dan kerja kernel, dan pertanyaan studi ini adalah batasnya. Jawaban batasnya: diseberangi sekali, dalam byte, ia berbiaya ~0.1%.

Jadi rangkailah vonis lengkapnya, setiap klausanya sudah terukur di atas.

Sebuah engine service cross-language menang ketika semua ini berlaku:

  • Keunggulan compute-nya nyata — diukur pada kernel Anda sendiri, bukan diasumsikan dari reputasi bahasanya. (Milik kami adalah −13% hingga terbukti sebaliknya — dan penjelasan "jelas" pertama untuk defisit itu mati saat diuji.)
  • Anda menyeberang secara kasar — satu panggilan per sweep atau per fold, ribuan kali lipat di atas lantai 14 µs, sebagaimana ditunjukkan oleh total 1.13x arsitektur batch (~0.1% batas).
  • Anda berbicara biner — raw array length-prefixed, Arrow, apa pun sekelas memcpy pada 49 µs per 1.2 MB; jangan pernah teks pada 66,243 µs.
  • Datanya sudah dipreload — server yang stateful menerima panggilan params-only di ujung ~16 µs dari kurva echo, bukan mengirim ulang megabyte.

Ia kalah ketika di-deploy dengan cara engine service biasanya di-deploy:

  • Microservice JSON/REST — membayar pajak serialisasi 1348x pada setiap panggilan, kedua arah; di bawah granularitas chatty itu adalah 5.3 s encoding pada pekerjaan 2 s.
  • RPC per unit pekerjaan — per kombo berbiaya 107 ms di sini dan bertahan hanya karena setiap panggilan membawa 25,130 µs compute; per bar itu ~2.1 s IPC murni sebelum pekerjaan apa pun terjadi, pada pekerjaan 2.0 s.
  • Spawn per panggilan — ~24 ms biaya tetap setiap kali, tidak berbahaya sekali per sweep, hampir dua detik ketika dibayar per kombo.

Dengan kata lain: arsitektur-arsitektur yang gagal itu tidak eksotis. Engine JSON REST, subprocess per-symbol, gRPC-per-tick — itu adalah sensus yang adil tentang bagaimana "mari kita faktorkan keluar engine backtest" sebenarnya dibangun. Keyakinan populer itu secara empiris berdasar kuat sebagai deskripsi praktik umum dan secara empiris salah sebagai hukum alam. Batasnya tidak pernah menjadi masalah. Cara-cara default menyeberanginyalah masalahnya.

Satu argumen untuk batas ini pantas mendapat kalimatnya sendiri, karena itulah alasan kami menjalankan studi ini sama sekali. Satu kernel terkompilasi di balik batas yang dirancang dengan baik bisa melayani sweep riset dan loop trading live sekaligus — binary yang sama, aritmetika yang sama, bit demi bit. Studi paritas backtest-live kami mengkatalogkan bagaimana engine riset dan produksi saling menyimpang ketika mereka adalah dua codebase; sebuah engine service adalah obat struktural terkuat untuk penyimpangan itu, dan studi ini memberi harga jujur untuk obat itu: jika dilakukan dengan benar, sekitar 0.1% dari wall time dan sebuah gerbang ekuivalensi untuk membuktikan tidak ada yang berubah dalam translasi. Trade-off itu — batas proses khusus ditukar dengan paritas satu-kernel — adalah, berdasarkan angka-angka ini, sebuah tawar-menawar yang menguntungkan. Jika dilakukan dengan salah, ide yang sama mengirimkan pajak serialisasi 1348x ke produksi dengan PnL Anda menunggangi di atasnya.

Poin-poin Kunci

  1. Batasnya nyaris gratis; keyakinan populer gagal diuji pengukuran. Round-trip seluruh seri close 1.2 MB lewat Unix socket — termasuk parse dan re-encode penuh — berbiaya 2,043.4 µs, sekitar 0.1% dari pekerjaan 2.010 s (derived). Arsitektur Rust-over-socket batched mendarat di 1.13x total, dan ~99% bahkan dari celah itu bukanlah IPC.
  2. "Tulis ulang di Rust" adalah klaim compute — verifikasi sebelum membeli batasnya. Port Rust baris demi baris kami menghitung ~13% lebih lambat daripada kernel numba (derived: 2.274 s vs 2.010 s) — celah codegen yang bisa direproduksi antara dua loop terkompilasi-LLVM sama-sama scalar yang tetap tidak teratribusi: kami menguji tersangka yang jelas dan menolaknya, karena build get_unchecked yang terverifikasi-ekuivalensi tanpa bounds check ternyata tidak lebih cepat (2.337 s vs 2.276 s). Rust naif tidak otomatis lebih cepat; kernel yang tuned bisa jadi memang lebih cepat — ukur, baru putuskan.
  3. Pajak sesungguhnya adalah teks. Meng-encode 150,000 float sebagai JSON berbiaya 66,243 µs vs 49.1 µs raw — 1348x, dibayar per arah, per panggilan, di kedua sisi. Deployment JSON yang chatty membakar 5.3 s encoding pada pekerjaan 2 s (derived). Bicaralah biner lintas batas: raw frame, Arrow — jangan pernah json.dumps pada array harga.
  4. Chatty vs chunky terukur, dan statelessness adalah pelakunya. Panggilan per-kombo yang mengirim ulang data: 1.19x vs 1.13x milik batch (+107 ms, derived; prediksi one-way kurva echo sebesar ~81 ms mendarat ~25% di bawahnya, sisanya adalah framing per-panggilan). Server stateful yang dipreload akan menempuh 80 panggilan yang sama pada ~16 µs masing-masing — total sekitar 1.3 ms (derived dari lantai echo). Kirim parameter, bukan dataset-nya.
  5. Hormati lantainya — dan ketahui bahwa lantainya adalah pilihan. Penyeberangan Python-over-Unix-socket kami memiliki lantai di 14 µs; granularitas per-kombo melampauinya ~1,795x (25,130 µs compute per panggilan) — aman. Pola per-bar (ekstrem cross-workload ilustratif: engine per-tick live, bukan sweep ini) akan membayar 150,000 × 14 µs ≈ 2.1 s IPC murni pada pekerjaan 2.0 s (derived) — mati sebelum lahir bahkan dengan engine berkecepatan tak terhingga. Spawn per panggilan menambahkan ~24 ms tetap (derived). Dan transport shared-memory purpose-built seperti ZigBolt melakukan round-trip dalam ~39 ns secara native pada mesin ini — ~360x di bawah lantai socket kami (derived; Zig native vs klien Python, jadi bacalah ini sebagai rentang yang bisa ditempati lantai, bukan sebuah perlombaan).
  6. Seberangi sekali, dalam byte, dengan data yang sudah ada di sana — dan batasnya membeli Anda paritas seharga ~0.1%. Satu kernel melayani riset dan live, digerbangi oleh pemeriksaan ekuivalensi (PnL −5165.58, 57,029 transaksi, identik lintas bahasa dan lintas kedua build Rust), adalah kasus jujur untuk sebuah engine service. Kasus-kasus tidak jujurnya — JSON, chatty, spawn-per-panggilan — adalah yang memberi IPC reputasinya.

Eksperimen lengkapnya — engine Rust-nya, wire protocol-nya, harness echo dan serialisasi, gerbang ekuivalensi, dan setiap angka dalam artikel ini yang bisa diregenerasi dari satu skrip deterministik — ada di paper pendamping di ipc-tax.marketmaker.cc, dengan kode dan data di github.com/suenot/ipc-tax.

Socket-nya tidak pernah menjadi masalah. Dua milidetik untuk seluruh dataset, round trip — folklore-nya meleset tiga orde besaran, dan dalam kedua arah sekaligus: terlalu pesimis soal byte, terlalu memaafkan soal teks. Seberangi batasnya seolah ia berbiaya sesuatu, dan ia tidak akan.

Penafian: Informasi yang disediakan dalam artikel ini hanya untuk tujuan edukasi dan informasi serta tidak merupakan nasihat keuangan, investasi, atau trading. Trading mata uang kripto mengandung risiko kerugian yang signifikan.

Penulis

Eugen Soloviov
Eugen Soloviov

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.

Newsletter

Selangkah Lebih Maju dari Pasar

Berlangganan newsletter kami untuk wawasan AI trading eksklusif, analisis pasar, dan pembaruan platform.

Kami menghormati privasi Anda. Berhenti berlangganan kapan saja.