← Makalelere geri dön
July 2, 2026
5 dakikalık okuma

IPC Vergisi: Backtest Motorunu Bir Soketin Arkasına Koyun ve %13 Kaybedin — Kaybın Neredeyse Hiçbiri Sokete Ait Değil

IPC Vergisi: Backtest Motorunu Bir Soketin Arkasına Koyun ve %13 Kaybedin — Kaybın Neredeyse Hiçbiri Sokete Ait Değil
#algotrading
#backtest
#performans
#ipc
#rust
#mimari
Part 4 of 4 · Collection
High-Performance Backtest Engines

"Yanılsamasız Backtestler" serisinin bir parçası.

📄 Bu makale bir araştırma makalesine dönüştü. Yol bağımlı tek bir backtest çekirdeği numba'dan Rust'a satır satır taşınıyor ve bir process/dil sınırı üzerinden dört farklı şekilde çağrılıyor; kombinasyon başına aynı (PnL, işlem sayısı) çıktısını doğrulayan bir eşdeğerlik kapısıyla birlikte — ayrıca saf IPC gecikme eğrisinin, serileştirme vergisinin ve spawn maliyetinin izole ölçümleri. Makaleyi çevrimiçi olarak (interaktif sürüm + PDF) ipc-tax.marketmaker.cc adresinde, kod ve veriyi ise github.com/suenot/ipc-tax adresinde okuyabilirsiniz.

Hızlanan her backtest motoru er ya da geç aynı tartışmayı doğurur. Bizimki de zamanında geldi. Hız merdiveni 80 kombinasyonluk bir parametre taramasını 69.9 saniyelik pandas'tan tek thread'li numba'nın yaklaşık 2 saniyesine daha yeni indirmişti ve doğal bir sonraki dürtü şuydu: neden bir Python JIT'inde durulsun? Çekirdeği Rust'ta yeniden yazın. Onu gerçek bir motor servisi yapın — soketin arkasında derlenmiş tek bir ikili, her araştırma betiğinden, her dilden ve canlı trader'dan da çağrılabilir. Tek çekirdek, tek gerçek, tekrarlanan mantık yok.

Ve sonra karşı argüman geliyor, o da zamanında: process'ten çıktığınız an, IPC sizi yer. Veri serileştirilmeli, bir sınırın ötesine gönderilmeli, deserileştirilmeli; her çağrı syscall'lara ve context switch'lere mal oluyor; güzelim Rust çekirdeğiniz hayatını bir pipe'ı bekleyerek geçirecek. Process içinde kalın. Herkes bunu bilir.

Bu makale herkesin bildiği şeyi ölçüyor ve ölçüm, tartışmanın her iki tarafından da daha ilginç. Halk inancı — "daha hızlı bir diller-arası motor, process içi numba'ya kaybeder çünkü IPC sizi öldürür" — genelde yanlış, yalnızca belirli koşullar altında doğru çıkıyor. Sınırı bir kez, ham byte'larla geçmek, iki saniyelik bir işte yaklaşık 2 milisaniyeye mal oluyor: bir yuvarlama hatası. Vergi sınırda değil. Onu nasıl geçtiğinizde yatıyor — ve motor servislerinin doğada genellikle dağıtıldığı üç yol (bir JSON API'si, iş birimi başına bir çağrı, çağrı başına bir process spawn'ı) her biri, ölçülebilir biçimde, halk bilgeliğinin öngördüğü felaketin bir parçası.

İşte deneyin tamamı baştan. Aşağıdaki her şey her satırın anatomisidir.

Mimari Sweep başına sınırı ne geçiyor Duvar saati process içine göre
process içi numba hiçbir şey — doğrudan bir çağrı 2.010 s 1.00x
Rust sunucu, batch (Unix soketi) tek bir round-trip: tüm seri + 80 parametre setinin tamamı 2.276 s 1.13x
Rust sunucu, batch, get_unchecked çekirdeği aynı tek round-trip — bounds-check'siz bir çekirdek varyantı (verdict kısmına bakın) 2.337 s 1.16x
Rust sunucu, gevezelik eden (chatty) (Unix soketi) 80 round-trip: seri kombinasyon başına yeniden gönderiliyor 2.383 s 1.19x
Rust spawn (stdin/stdout) process spawn + tek bir pipe'lanmış istek 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, sıfır harici crate). 150,000 bar × 80 kombinasyon, %0.09 round-trip ücreti, seed 42; close serisi tel üzerinde 1,200,000 byte (1.2 MB). Mimari başına 10 çalıştırmanın medyanı; min-max sapmaları ~%2 içinde kalıyor. Beşi de aynı HMA/HMA3 stop-and-reverse taramasını çalıştırıyor ve bir eşdeğerlik kapısı her iki Rust çekirdek varyantının kombinasyon başına (PnL, işlem sayısı) sonuçlarının numba ile tam olarak eşleştiğini doğruluyor — 57,029 işlem boyunca −5165.58 fingerprint PnL, aynı seed'de hız merdiveni çalışmasının numba çekirdeğiyle byte-identical. Sınırları karşılaştırıyoruz, uygulamaları değil.

Batch satırını dikkatle okuyun, çünkü tüm tezi taşıyor. Rust-üzerinden-soket mimarisi, process içi numba'dan 1.13x daha yavaş — tam sweep'te 266 ms geride (türetilmiş: 2.276 − 2.010). Halk hikayesi o milisaniyelerin IPC olduğunu söylüyor. Değiller. O farkın yaklaşık 2 ms'si sınır — doğrudan ölçülen, 1.2 MB'lık tam close serisi içeri gönderiliyor, sonuçlar geri gönderiliyor. Kalan ~264 ms'si, naif Rust çekirdeğimizin sweep'i numba çekirdeğinden yaklaşık %13 daha yavaş hesaplamasından ibaret (türetilmiş: 2.276 s eksi ~2 ms sınır ≈ 2.274 s Rust hesaplaması, numba için 2.010 s'ye karşı). Rust-dili Python-diline kaybetmedi; tek bir skaler LLVM-derlenmiş döngü, başka bir codegen yarışını kaybetti — ve kaybı bariz şüpheliye bile yükleyemedik: aynı çekirdeğin bounds-check'siz bir get_unchecked build'i daha hızlı çıkmadı (2.337 s; verdict bölümü bunu didikliyor). Soketin bunların hiçbiriyle neredeyse hiç ilgisi yoktu.

Bu cümlenin iki yarısını da tutun. Sınır, doğru geçildiğinde neredeyse bedava — ve "onu Rust'ta yeniden yaz" size otomatik bir hesaplama kazancı değil, bir dağıtım sınırı satın alır. Her iki gerçek de popüler içgüdüye ters düşüyor, ve ikisi de tabloda.

Tek çekirdek, iki dil, dört sınır

İş yükü kasıtlı olarak hız merdiveni'nin sabitlediği ile aynı, böylece iki çalışma birbirine sabitleniyor. Çekirdek bir HMA/HMA3 kesişimi — iki Hull tarzı hareketli ortalama üzerinde bir stop-and-reverse sistemi, parametre kombinasyonu başına yedi ağırlıklı hareketli ortalama geçişi artı pozisyon taşıyan, her kesişimde %0.09'luk bir round-trip ücreti düşerek PnL kaydeden ve tersine dönen durum bilgili bar-bar olay döngüsü. Veri, seed'li sentetik geometrik Brownian hareketinin (seed=42) 150,000 barı; ızgara [6,200][6, 200] üzerine yayılmış 80 HMA uzunluğu. Process içi referans, merdivenin tek thread'li numba basamağı, bu çalışma için yeniden ölçüldü: orada 1.98 s, burada 2.010 s — aynı çekirdek, aynı makine, güven verici derecede sıkıcı.

Diller-arası motor, o numba çekirdeğinin Rust'a satır satır portu — aynı döngüler, aynı NaN işleme, aynı ücret aritmetiği — hiçbir harici crate olmadan release modda derlenmiş, böylece tüm deney bağımlılıksız ve tekrarlanabilir kalıyor. Kasıtlı olarak minimal bir ikili protokol konuşuyor: her yönde uzunluk-önekli tek bir frame, hepsi 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'u çalışmanın neşteri: hiçbir şey hesaplamayan, kontrol edilebilir boyutta bir round-trip, böylece saf sınır maliyeti izole olarak ölçülebiliyor — serileştirme, syscall'lar, soket transiti, deserileştirme ve başka hiçbir şey.

Ölçülen beş mimari — dört sınır deseni artı bir çekirdek varyantı:

  • in_process — numba çekirdeğini doğrudan çağırır. Sınır yok. Referans.
  • rust_batch_unix — bir Unix domain soketi üzerinde kalıcı bir Rust sunucusu. Tek bir round-trip tüm close serisini artı 80 parametre setinin tamamını gönderiyor; Rust her kombinasyonu hesaplıyor; tek bir yanıt geri geliyor. Yığın (chunky) çağrı.
  • rust_batch_unchecked — aynı batch sınırı, ancak çekirdek get_unchecked ile indeksliyor (hot path'te bounds check yok). Hesaplama farkıyla ilgili belirli bir hipotezi test etmek için var; verdict bölümü onu harcıyor.
  • rust_chatty_unix — aynı sunucu, ancak kombinasyon başına bir round-trip, 1.2 MB'lık seri her seferinde yeniden gönderiliyor. Naif RPC-per-unit-of-work mimarisi.
  • rust_spawn_stdin — sweep başına ikiliyi spawn eder ve isteği stdin üzerinden pipe'lar. "CLI motoruna shell out et" deseni; process oluşturma maliyetini öder.

Ve bunlar olmadan hiçbirinin bir anlamı olmayacak eşdeğerlik kapısı: zamanlamadan sonra, her Rust varyantının kombinasyon başına (PnL, işlem sayısı) vektörü numba'nınkiyle karşılaştırılıyor — işlem sayıları tam, PnL mutlak 10610^{-6}'ya kadar. Kaydedilen çalıştırma hem güvenli indeksleme hem de get_unchecked build'leri için all_ok: true raporluyor. İlk kombinasyon fingerprint'i — 57,029 işlem boyunca −5165.58 yüzde puanı PnL — hız merdiveni çalışmasının numba çekirdeğiyle hane hane eşleşiyor, bu da her iki makaleyi aynı seed'de aynı çekirdeğe sabitliyor. Diller arası portlar tam olarak sessiz sapmanın yaşamayı sevdiği yerdir (yüzde dönüşümünden sonra yerine önce uygulanan bir ücret, farklı dallanan bir NaN karşılaştırması, bir pencerede off-by-one — look-ahead taksonomimizin gürültüden 15'lik bir Sharpe üretebildiğini gösterdiği aynı türden bug). Farklı şeyler hesaplayan iki motorun benchmark'ı bir benchmark değildir; birbiriyle ilgisiz iki programın yarışmasıdır.

Eşdeğerlik kurulduğuna göre, yukarıdaki tablodaki her fark sınır ve hesaplamadır — başka hiçbir şey değil.

Geçişin gerçekte neye mal olduğu: echo eğrisi

Bir sınır geçişinin ölçülen maliyeti: küçük payload'lar için on dört mikrosaniyede düz kalan, ancak on bin float'ı geçtikten sonra yukarı bükülen ve tam 1.2 megabaytlık seri için iki milisaniyeye ulaşan bir gecikme eğrisi

Neşterle başlayalım. Echo işlemi, nn float'lık bir payload'ı Rust sunucusu üzerinden round-trip yapıyor — Python frame'i oluşturuyor, sunucu tüm nn float'ı parse ediyor, onları yeniden kodluyor ve geri gönderiyor. Her iki yön de serileştirme, syscall'lar ve soket transiti ödüyor. İşte ölçülen eğri (10 çalıştırmanın medyanları):

Payload (float) Her yönde byte 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

Bu tabloda iki yapısal gerçek yaşıyor.

Önce, taban (floor). Esasen hiçbir şey taşımayan bir round-trip — 8 byte — 14 µs'ye mal oluyor. Bu, bu taşıma katmanı üzerinden herhangi bir çağrı yapmanın indirgenemez bedeli: iki write syscall'ı, iki read syscall'ı, kernel soket makinesi, scheduler uyanmaları. Eğrinin solda ne kadar düz olduğuna dikkat edin: 1 float'tan 1,000 float'a kadar maliyet neredeyse hiç kımıldamıyor (14.1 → 18.1 µs). Yaklaşık 8 KB'ın altında çağrı için ödüyorsunuz, byte'lar için değil. Bu sayı — gecikme tabanı — tüm çalışmadaki tek en önemli sabit, ve break-even aritmetiğini aşağıda bunun üzerine kuracağız.

Sonra, eğim. ~10,000 float'ı geçtikten sonra eğri bant genişliği sınırlı ve kabaca doğrusal hale geliyor. Tam 1.2 MB'lık seri — Rust tarafında 150,000 float'ın tam bir parse ve yeniden kodlaması dahil, toplamda gidiş-dönüş 2.4 MB taşınarak — 2,043.4 µs'ye mal oluyor. Bu, tüm naif stack boyunca efektif ~1.2 GB/s'ye tekabül ediyor (türetilmiş: 2.4 MB / 2.04 ms) — uzunluk ön ekli frame'lere sahip bir Unix domain soketi ve byte-byte bir float parser'ı, zero-copy hileleri yok, paylaşımlı bellek yok, akıllıca hiçbir şey yok.

Her iki sabiti de ölçülmüş, bir çağrının makul bir modeli:

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

Şimdi manşet rakamı bağlama oturtalım. Tam sweep process içinde 2.010 s sürüyor. Tüm veri setini sınırdan gönderip geri almak ~2.0 ms'ye mal oluyor — işin yaklaşık %0.1'i (türetilmiş: 2.0434 ms / 2.010 s). Sınırı bir kez, ham byte'larla geçerseniz, sınır bir yuvarlama hatasıdır. Halk inancının önce ölen yarısı bu: korku hiçbir zaman bu kadar ucuz bir şey hakkında değildi.

O geçişin Rust tarafı, sistem kodunun olabileceği kadar sıradan — engine/src/main.rs'den uyarlanmıştır:

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());
}

Devam etmeden önce dürüst bir kapsam notu: bu çalışmadaki tüm sınır rakamları tek bir host üzerinde bir Unix domain soketidir. Motor TCP de konuşuyor (TCP_NODELAY ile), ancak bunu ölçmedik; loopback TCP bu tabanların biraz üzerinde oturur ve gerçek bir ağ atlaması tamamen farklı bir rejimdir — mikrosaniye değil, milisaniye mertebesinde bir taban. Buradaki her şey bu nedenle bir sınırı bu şekilde geçmenin neredeyse en iyi durumudur. Bu da bir sonraki ölçülen vergileri daha da kınayıcı kılıyor: bunlar bunun üzerine, seçim yoluyla ödediğiniz şeylerdir.

Serileştirme vergisi: JSON seçmenin bedeli 1348x

Aynı 150,000 float'lık dizinin iki kodlaması yan yana: mikrosaniyelerle ölçülen ham-byte memcpy'a karşı üç büyüklük mertebesi daha yüksekte yükselen bir JSON metin kodlaması

İşte "IPC overhead" hakkındaki halk inancının bir yanlış etiketleme olduğu ortaya çıktığı yer. Aynı 150,000 float'lık close serisini kodlamanın maliyetini üç şekilde ölçtük — yukarıdaki her mimarinin gönderdiği tam payload:

Kodlama 1.2 MB float'ı kodlama süresi ham'a göre
ham byte'lar (.tobytes()) 49.1 µs 1.0x
pickle 29.8 µs 0.6x
JSON (json.dumps(close.tolist())) 66,243 µs 1348x

Ham yol, bir fonksiyon çağrısı kılığına girmiş bir memcpy'dır:

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, ham yolumuzdan bile biraz daha ucuza geliyor çünkü astype, dtype zaten eşleşse bile bir dtype-dönüşüm kopyası ödüyor; ikisi de memcpy sınıfında ve ikisi de yuvarlama hatası. İkili aile bir bütün olarak metin ailesinin üç büyüklük mertebesi altında yaşıyor.)

Ve metin yolu, neredeyse her "motoru bir mikroservis yapalım" dağıtımının fiilen gönderdiği şey:

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

Altmış altı milisaniye. Sadece kodlamak için. json.dumps(close.tolist()) her float'ı bir Python nesnesine kutuluyor, sonra her birini ondalık metin olarak render ediyor — ham yolun tek bir blok kopyası yaptığı yerde 150,000 heap tahsisi ve 150,000 float-to-string dönüşümü. Ve tel üzerindeki payload de şişiyor (bir float64 ikili biçimde 8 byte'a mal olur ve ondalık metin olarak kabaca bunun iki-üç katına — ekstra transit için ücret bile almadık).

Şimdi bunu gerçek bir dağıtımın yaptığı gibi ölçekleyelim. O 66 ms tek bir kodlama, tek bir taraf, tek bir çağrı. Bir JSON servisi kodlama ve çözmeyi, sınırın her iki tarafında, her çağrıda öder. JSON üzerinden tek bir batch çağrı, tüm sweep'in hesaplama bütçesinin ~%3.3'ünü yalnızca client tarafı kodlamada yakardı (türetilmiş: 66 ms / 2.010 s). JSON'ı chatty mimarinin altına koyun — kombinasyon başına bir çağrı, aşağıdaki desen — ve yalnızca client tarafı kodlama 80 × 66 ms = 5.3 s'ye mal olur: tek bir byte bile hareket etmeden ve sunucu herhangi bir şeyi parse etmeden önce, tüm faydalı işin iki buçuk katından fazlası (türetilmiş).

Bu, çoğu ekibin bilmeden production'da ölçtüğü gerçek "IPC vergisi". Bu asla inter-process communication değildi. Sayısal dizilerin metin serileştirmesiydi — sınırın en ucuz bileşenine kendi kendine uygulanmış bir 1348x. Kolonsal (columnar) dünya bu dersi yıllar önce öğrendi, ve bu, Polars vs pandas çalışmamızın veri pipeline'ı tarafından defalarca karşılaştığı aynı ders: Arrow gibi formatlar tam olarak dizi verisinin process ve dil sınırlarını metin olarak değil, ham kolonsal byte'lar olarak geçebilmesi için var. Motor servisiniz fiyat dizileri için JSON konuşuyorsa, hiçbir soket ayarı sizi kurtaramaz — protokolün kendisi darboğazdır.

Chatty vs chunky: Fowler'ın yasası, ölçüldü

Sınırdan büyük, framelenmiş bir payload'ı bir kez gönderen chunky bir mimari, her biri tüm veri setini peşinden sürükleyen seksen küçük round-trip yapan chatty bir mimarinin yanında

Martin Fowler'ın Dağıtık Nesne Tasarımının Birinci Yasası — "nesnelerinizi dağıtmayın" — aynı nefeste açıkladığı bir sonuçla birlikte gelir: eğer bir sınırı geçmek zorundaysanız, arayüz kaba taneli (coarse-grained) olmalıdır, çünkü uzak bir çağrı yerel bir çağrıdan büyüklük mertebeleri kadar daha fazlaya mal olur. Her dağıtık sistemler veteranı başını sallar. Neredeyse hiç kimsenin kendi iş yükü için bir rakamı yoktur. İşte bizimki.

Chunky ve chatty mimariler aynı sunucuyu, aynı protokolü, aynı veriyi çalıştırır — yalnızca çağrı granülerliği farklıdır:

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 daha yavaş (türetilmiş: 2.383 − 2.276). Bu deltanın ne olduğu ve ne olmadığı konusunda kesin olmak gerekirse: echo eğrisi bunun için naif bir tahmin veriyor — serinin tamamının 79 ekstra gönderimi, her biri 2,043 µs'lik tam payload round-trip'inin kabaca yarısında, yaklaşık 81 ms — bu, ölçülen 107 ms'nin yaklaşık %25 altına düşüyor; kalan kısım, echo tahmininin içermediği, Python tarafındaki çağrı başına istek oluşturma ve framing. Her iki durumda da ekstra geçiş başına ~1.4 ms'ye tekabül ediyor (türetilmiş: 107 / 79); yanıtlar ihmal edilebilir — kombinasyon başına 16 byte.

O 107 ms'nin iki okuması var, ve ikisi de önemli.

Yumuşak okuma: bu yalnızca duvar saatinin ~%4.5'i, bir felaket değil. Doğru — ve halk bilgeliğinin felaketinin burada neden gerçekleşmediğini anlamaya değer. Her chatty çağrı hâlâ 25,130 µs gerçek hesaplama taşıyor (bir kombinasyonluk — ölçülen process içi kombinasyon başına maliyet), bu yüzden çağrı başına ~1.4 ms'lik sınır overhead'i, çağrı başına işin bir büyüklük mertebesi altında kalıyor. Chatty mimariler her çağrı gerçekten ağır olduğunda ölümcül değildir. Granülerlik küçüldükçe ölümcül hale gelirler — ki bu break-even bölümünün tüm konusu.

Kınayıcı okuma: bu vergi tamamen gönüllüydü ve çağrı sayısı × payload ile ölçekleniyor. Chatty desen, veri setini her çağrıda yalnızca bir nedenle yeniden gönderiyor: servis stateless, bu yüzden her istek tüm bağlamı taşımak zorunda. Bu, naif bir "sweep endpoint"inin — ve esasen bir beyaz tahtada eskizlenmiş her REST mikroservisinin — varsayılan şeklidir. Stateful bir sunucu — seriyi bir kez yükleyin, sonra 48 byte'lık parametre frame'leri gönderin — her kombinasyon başına çağrıyı echo eğrisinin küçük-payload ucuna yakın bir yere koyardı: çağrı başına yaklaşık 16 µs, 80'inin tamamı için kabaca 1.3 ms (echo tabanından türetilmiş; analitik, ayrıca ölçülmedi). Chatty cezası küçülmezdi; ortadan kalkardı. Ders kesin: sorun çok sayıda çağrı yapmak değil — protokol her çağrının ilki olduğunu varsaydığı için state'i yeniden göndermek.

Veriyi önceden yükleyin. Parametreleri gönderin. Sınırı niyetle geçin, her seferinde bavulunuzda tüm dünyayla değil.

Spawn maliyeti: motoru çağrı başına kiralamak

Tek bir istek için sıfırdan spawn edilen bir motor ikilisi: process oluşturma, loader ve pipe kurulumu, kısa bir faydalı iş parçasının önünde sabit bir gişe olarak istiflenmiş

Üçüncü dağıtım deseni en eskisi: sunucu falan yok. Motor ikilisini spawn edin, bir isteği stdin üzerinden pipe'layın, yanıtı stdout'tan okuyun, ölmesine izin verin. Her shell scripter'ın içgüdüsü, her "Python'dan CLI'ı çağır yeter" entegrasyonu, deneme başına bir ikili başlatacak şekilde yapılandırılmış her hiperparametre framework'ü.

Ölçülen: 2.300 s (1.14x) — kalıcı-sunucu batch'inin yaklaşık 24 ms üzerinde (türetilmiş: 2.300 − 2.276). O 24 milisaniye bir fork/exec, dinamik loader, pipe kurulumu ve process teardown'ı satın alır. Ve şunu not edin: bunun ölçtüğü şey desen için tabana yakın: küçük, bağımlılıksız, page cache'de ısınmış bir native ikili. Bir runtime ile herhangi bir şeyi spawn etmek — bir JVM, import'ları olan bir Python yorumlayıcısı — çok daha fazlaya mal olur; bunları burada ölçmedik, ama yön şüphe götürmez.

Bu verginin yapısı önemli olan şey: çağrı başına sabit, çağrının ne kadar iş taşıdığından bağımsız. Tam bir 80-kombinasyonluk sweep üzerinden amortize edildiğinde, 24 ms yaklaşık %1 — gürültü. Kombinasyon başına yeniden spawn edin ve aynı sabit 80 × ~24 ms ≈ 1.9 s'ye dönüşür — esasen tüm faydalı işin process oluşturmada yakılması (türetilmiş; analitik). Bar başına yeniden spawn edin ve aritmetiğin yazılmaya değmez.

Sabit maliyet, ince granülerlik: birini seçin. Bir spawn ödeyen desen, yalnızca spawn nadir ve arkasındaki payload devasa olduğunda mantıklıdır — tam olarak bizim sweep-başına-tek-spawn ölçümümüz gibi, ve sembol sayısı büyüdüğünde sembol-başına-subprocess mimarilerinin sonunda kullanılma şeklinin tam tersine.

Break-even aritmetiği: bir taban bir hurdle rate'tir

Bir terazide break-even aritmetiği: bir tarafta on dört mikrosaniyelik sınır tabanı, her çağrının taşıdığı hesaplamaya karşı tartılıyor, kombinasyon başına çağrılar suyun çok üzerinde ve bar başına çağrılar boğulmuş

Şimdiye kadar ölçülen her şey tek bir tasarım kuralına sıkışıyor, ve kural aritmetik, fikir değil.

Her sınır geçişi en az gecikme tabanına mal olur — burada 14 µs, küçük-payload echo round-trip'i, ve bu taşıma katmanının sunabileceği en iyiye yakın. O taban bir hurdle rate'tir: sınır üzerinden bir çağrı yapmak, ancak taşıdığı hesaplama hurdle'ı rahat bir kat ile aşarsa değerlidir. Granülerlik oranını tanımlayalım

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

ve sınırın duvar saatinizdeki payı kabaca 1/(1+G)1/(1+G) — çağrı ayrıca veri taşıyorsa üzerine payload transiti eklenir.

Şimdi sweep'in rakamlarını bunun içinden geçirelim. Bir kombinasyonun ölçülen process içi maliyeti 25,130 µs. Kombinasyon başına granülerlikte:

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

Kombinasyon başına çağrılar tabanın ~1,795x üzerinde oturuyor — sınır çağrı başına yüzde onda birinin oldukça altında pay alıyor. İşte bu yüzden chatty mimari bile yalnızca 107 ms kaybetti: bu iş yükünün granülerliğinde, veriyi yeniden göndermeyen veya metin konuşmayan her geçiş deseni güvenle amortize ediliyor. Kombinasyon-seviyesi, fold-seviyesi, sweep-seviyesi çağrıların hepsi ucuz bölgenin derinliklerinde.

Şimdi tam tersi uca dönelim. Bu bir örnekleyici, iş yükleri arası ekstrapolasyon — sweep'imizin bir varyantı değil, ama doğada gerçekten var olan bir iş yükü şekli: motora bar başına danışılıyor. Canlı tarzda bir tick-başına motor servisi; bar-başına bir gRPC sinyal akışı; 150,000 barın her biri için bir kez poll edilen bir "strateji sunucusu". Bu çekirdekteki bar başına faydalı hesaplama 25,130 µs / 150,000 ≈ 0.17 µs (türetilmiş) — her çağrı, faydalı işte kendi sınır maliyetinin yaklaşık 1/84'ünü taşırdı (türetilmiş: 0.168 µs hesaplamaya karşı 14.05 µs taban). Toplam, oranın kulağa geldiğinden daha kötü:

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}

tüm 2.010 s'lik process içi işten bile daha fazla, uzaktaki motor tek bir sayı hesaplamadan önce harcanmış, ve karşı taraftaki motor sonsuz hızlı olsa bile 2.1 s olarak kalırdı (türetilmiş: 150,000 × 14 µs). Hiçbir hesaplama avantajı bu kadar ince bir granülerlikte hayatta kalamaz. Ve bu tabanın tek bir host üzerinde bir Unix soketi olduğunu hatırlayın; o bar-başına çağrıyı ağ üzerinden bir servise yapın ve taban 150,000 çağrıda iki-üç büyüklük mertebesi büyür.

Bir uygulama seçimi olarak aynı-makine sınır tabanı: on dört mikrosaniyede bir Python-üzerinden-Unix-soketi round-trip'i, otuz dokuz nanosaniyede bir paylaşımlı-bellek ring geçişinin üç büyüklük mertebesi üzerinde yükseliyor

Bir kalibrasyon daha, dürüstçe, çünkü 14 µs de bir fizik yasası değil — bu bizim taşıma katmanımızın fiyatı: bir Python client'ı, bir kernel soketi, her iki yönde syscall'lar. Amaca özel, aynı-makine için inşa edilmiş bir taşıma katmanı çok daha düşüğe iner. ZigBolt — HFT iş yükleri için açık kaynak Zig mesajlaşma bus'ımız, bu aynı makinede native olarak benchmark edildi — bir paylaşımlı-bellek ring round-trip'ini yaklaşık 39 ns ortalamada yapıyor (64/256/1024 byte'lık mesajlarda 10/20/30 ns'lik tek yönlü p50). Bu, soket tabanımızın kabaca 360x altında (türetilmiş: 14.05 µs / 39 ns). Karşılaştırma kasıtlı olarak elma-armut kıyaslaması, ve bunu böyle işaretliyoruz: bizim 14 µs'imiz bir Python-client soket round-trip'i, ZigBolt'un 39 ns'i paylaşımlı bellek üzerinde native Zig, bu yüzden fark taşıma katmanı ve runtime'ı birbirine karıştırıyor. Bunu ikisi arasında bir yarış olarak değil, aynı-makine tabanının işgal edebileceği aralık olarak okuyun: uygulamaya göre seçilen kabaca üç büyüklük mertebesi. Bu, modern kılıkta eski Lightweight RPC dersi (Bershad ve ark., 1990) — aynı-makine geçişleri protokol makinesi tarafından domine edilir, ve taşıma katmanı aynı-makine durumu için inşa edildiğinde çökerler. Yukarıdaki break-even aritmetiği şeklini değiştirmez; hurdle yalnızca yer değiştirir. 39 ns'lik bir tabanda, bar-başına granülerlik bile onu aşardı (150,000 × 39 ns ≈ 5.9 ms, türetilmiş) — HFT sistemlerinin bir REST servisinin karşılayamayacağı sınırları tam olarak nasıl karşılayabildiği bu.

İşte tüm break-even hikayesi tek cümlede: sınır motorunuzun ne kadar hızlı olduğunu umursamaz; geçiş başına ücretlendirir, bu yüzden kontrol ettiğiniz değişkenler her geçişin ne kadar iş taşıdığı — ve geçişin neyden yapıldığıdır. Sweep başına batch yapın ve GG yüz binin üzerinde. Kombinasyon başına batch yapın, G1795G \approx 1795 — hâlâ iyi. Bir soket üzerinden bar başına çağırın, G<1G < 1 — mimari ilk optimizasyondan önce ölmüştür, ve motorun Rust'ta ya da başka herhangi bir şeyde yeniden yazılması onu diriltemez.

1.13x aslında nerede yaşıyor — ve verdict

266 milisaniyelik fark diseke edildi: sınır olarak etiketlenmiş iki milisaniyelik ince bir dilim, iki skaler derlenmiş çekirdek arasındaki ölçülen codegen farkının büyük bir dilimi yanında, halk inancı üstü çizilmiş

Manşet farkı dürüstçe dissekte etme zamanı, çünkü çalışmanın en counterintuitive bulgusunu taşıyor.

Batch'lenmiş Rust mimarisi process içi numba'nın 266 ms gerisinde kalıyor (türetilmiş: 2.276 − 2.010). Ölçülen sınır bileşenleri: ~2.0 ms'de bir tam-payload round trip, 49 µs'de ham serileştirme, birkaç byte'lık frame header'ları — tüm sınır faturasına ~2 ms diyelim. Farkın %99'undan fazlası bu nedenle hiç sınır değil. Bu hesaplama: IPC'den arındırıldığında, Rust sunucusu numba'nın 2.010 s'de yaptığı sweep'i ~2.274 s'de yapıyor — naif Rust çekirdeği ham hesaplamada yaklaşık %13 daha yavaş (türetilmiş).

Bu, gözünü kırpmayan bir paragrafı hak ediyor, çünkü "Rust'ta yeniden yaz, daha hızlı olacak" da tıpkı "IPC seni öldürecek" kadar halk inancı. Her iki çekirdek de LLVM'de sonlanıyor — numba Python bytecode'unu onun üzerinden indirgiyor, rustc MIR'ı onun üzerinden indirgiyor — ve ikisi de büyük olasılıkla skaler döngüler olarak çalışıyor: WMA'nın iç toplamı bir floating-point indirgemesi, ve LLVM bunu, numba'nın @njit'inin varsayılan olarak vermediği ve bizim portumuzun talep etmediği fast-math yeniden-ilişkilendirme lisansı olmadan otomatik olarak vektörize etmeyecek. Yani ~%13, iki skaler LLVM-derlenmiş döngü arasındaki ölçülen bir codegen farkı — ve bir neden iddia etmek yerine, açık olanı test ettik. Doğal şüpheli Rust'ın güvenli indekslemesi: sıcak WMA döngüsü her dizi erişimini bounds-check ediyor, numba'nın @njit'i ise bounds check'i kapalı olarak derliyor. Bu yüzden aynı çekirdeğin get_unchecked üzerinde eşdeğerlik-doğrulanmış bir varyantını inşa ettik — sıcak yolda hiçbir yerde bounds check yok — ve onu beşinci bir mimari olarak zamanladık. Farkı kapatmadı: 2.337 s (1.16x), bounds-checked build'in 2.276 s'sinden marjinal olarak daha yavaş. Hipotez test edildi, hipotez reddedildi. Dürüst bilgi durumu: ~%13 gerçek ve tekrarlanabilir (10 çalıştırmanın medyanları, ~%2 içinde sapmalar), ve şu an atfedilmemiş — yalnızca assembly-seviyesi profiling'in çözebileceği tahsis davranışında, döngü yapısında veya instruction scheduling'de bir fark. Ders bozulmadan hayatta kalıyor: naif Rust otomatik olarak iyi numba'dan daha hızlı değildir, ve bedava bir hesaplama kazancı varsayımıyla satın alınan bir dil sınırı, üzerine iliştirilmiş bir hesaplama kaybıyla gelebilir. Ayarlanmış bir Rust çekirdeği — önceden tahsis edilmiş buffer'lar, açık SIMD, kombinasyonlar arasında thread'ler — hâlâ işareti çevirebilir. Ama bu bir hesaplama sorusu, profiling ve çekirdek işiyle çözülecek, ve bu çalışmanın sorusu sınır. Sınırın cevabı: bir kez, byte'larla geçildiğinde, ~%0.1'e mal oluyor.

Öyleyse tam verdict'i bir araya getirelim, her cümlesi yukarıda ölçülmüş.

Diller-arası bir motor servisi şunların hepsi geçerli olduğunda kazanır:

  • Hesaplama avantajı gerçek — çekirdeğinizde ölçülmüş, dilin ününden varsayılmamış. (Bizimki aksi kanıtlanana kadar −%13'tü — ve o eksiklik için ilk "bariz" açıklama testte öldü.)
  • Kaba taneli geçersiniz — sweep başına veya fold başına bir çağrı, 14 µs tabanın binlerce katı üzerinde, batch mimarisinin 1.13x toplamının (~%0.1 sınır) gösterdiği gibi.
  • İkili konuşursunuz — uzunluk ön ekli ham diziler, Arrow, 1.2 MB başına 49 µs'de memcpy sınıfında herhangi bir şey; asla 66,243 µs'de metin.
  • Veri önceden yüklenmiştir — stateful bir sunucu, megabaytları yeniden göndermek yerine echo eğrisinin ~16 µs ucunda yalnızca-parametre çağrıları alır.

Motor servislerinin genellikle dağıtıldığı şekilde dağıtıldığında kaybeder:

  • Bir JSON/REST mikroservisi — her çağrıda, her iki yönde 1348x serileştirme vergisini öder; chatty granülerlik altında bu, 2 s'lik bir işte 5.3 s'lik kodlamadır.
  • İş birimi başına RPC — kombinasyon başına burada 107 ms'ye mal oluyor ve yalnızca her çağrı 25,130 µs hesaplama taşıdığı için hayatta kalıyor; bar başına, 2.0 s'lik bir işte herhangi bir iş gerçekleşmeden önce ~2.1 s saf IPC.
  • Çağrı başına bir spawn — her seferinde ~24 ms sabit maliyet, sweep başına bir kez zararsız, kombinasyon başına ödendiğinde neredeyse iki saniye.

Yani şu demek: başarısız olan mimariler egzotik değil. JSON REST motoru, sembol-başına subprocess, tick-başına gRPC — bu, "backtest motorunu ayrı bir bileşene çıkaralım" fikrinin fiilen nasıl inşa edildiğinin adil bir sayımı. Halk inancı yaygın pratiğin bir tanımı olarak ampirik olarak sağlam temelli ve bir doğa yasası olarak ampirik olarak yanlış. Sınır asla sorun değildi. Onu geçmenin varsayılan yolları sorun.

Sınır lehine bir argüman kendi cümlesini hak ediyor, çünkü bu çalışmayı hiç yapmamızın nedeni bu. İyi tasarlanmış bir sınırın arkasındaki tek bir derlenmiş çekirdek, hem araştırma sweep'ine hem de canlı trading döngüsüne hizmet edebilir — aynı ikili, aynı aritmetik, bit bit aynı. Backtest-canlı eşitlik çalışmamız, araştırma ve production motorları iki ayrı kod tabanı olduğunda nasıl birbirinden uzaklaştığını kataloglamıştı; bir motor servisi bu sapmaya karşı en güçlü yapısal çare, ve bu çalışma çareyi dürüstçe fiyatlandırıyor: doğru yapıldığında, duvar saatinin yaklaşık %0.1'i ve çeviride hiçbir şeyin değişmediğini kanıtlayan bir eşdeğerlik kapısı. O takas — tek-çekirdek eşitliği karşılığında özel bir process sınırı — bu rakamlarda bir kelepirdir. Yanlış yapıldığında, aynı fikir production'a, PnL'niz üzerine binmiş 1348x'lik bir serileştirme vergisi gönderir.

Çıkarımlar

  1. Sınır neredeyse bedava; halk inancı ölçümde başarısız oluyor. Tam 1.2 MB'lık close serisini bir Unix soketi üzerinden round-trip yapmak — tam parse ve yeniden kodlama dahil — 2,043.4 µs'ye mal oluyor, 2.010 s'lik işin yaklaşık %0.1'i (türetilmiş). Batch'lenmiş Rust-üzerinden-soket mimarisi toplamda 1.13x'te oturuyor, ve o farkın bile ~%99'u IPC değil.
  2. "Rust'ta yeniden yaz" bir hesaplama iddiasıdır — sınırı satın almadan önce doğrulayın. Satır satır Rust portumuz numba çekirdeğinden ~%13 daha yavaş hesaplıyor (türetilmiş: 2.274 s'ye karşı 2.010 s) — iki skaler LLVM-derlenmiş döngü arasında atfedilmemiş kalan, tekrarlanabilir bir codegen farkı: açık şüpheliyi test ettik ve reddettik, çünkü bounds check'siz eşdeğerlik-doğrulanmış bir get_unchecked build'i daha hızlı çıkmadı (2.337 s'ye karşı 2.276 s). Naif Rust otomatik olarak daha hızlı değil; ayarlanmış bir çekirdek pekâlâ olabilir — ölçün, sonra karar verin.
  3. Gerçek vergi metin. 150,000 float'ı JSON olarak kodlamak, ham 49.1 µs'ye karşı 66,243 µs'ye mal oluyor — 1348x, yön başına, çağrı başına, her iki tarafta ödenen. Chatty bir JSON dağıtımı, 2 s'lik bir işte 5.3 s'lik kodlamayı yakıyor (türetilmiş). Sınırlar üzerinde ikili konuşun: ham frame'ler, Arrow — bir fiyat dizisi üzerinde asla json.dumps değil.
  4. Chatty ve chunky ölçülebilir, ve suçlu statelessness. Veriyi yeniden gönderen kombinasyon-başına çağrılar: batch'in 1.13x'ine karşı 1.19x (+107 ms, türetilmiş; echo eğrisinin ~81 ms'lik tek-yönlü tahmini bunun ~%25 altına düşüyor, geri kalanı çağrı başına framing). Önceden yüklenmiş, stateful bir sunucu aynı 80 çağrıyı her biri ~16 µs'de alırdı — toplamda yaklaşık 1.3 ms (echo tabanından türetilmiş). Veri setini değil, parametreleri gönderin.
  5. Tabana saygı gösterin — ve tabanın bir seçim olduğunu bilin. Python-üzerinden-Unix-soketi geçişimiz 14 µs'de tabana oturuyor; kombinasyon-başına granülerlik bunu ~1,795x aşıyor (çağrı başına 25,130 µs hesaplama) — güvenli. Bar-başına bir desen (örnekleyici, iş yükleri arası bir uç: bu sweep değil, canlı bir tick-başına motor) 2.0 s'lik bir işte 150,000 × 14 µs ≈ 2.1 s saf IPC öderdi (türetilmiş) — sonsuz hızlı bir motorla bile daha varır varmaz ölü. Çağrı başına spawn etmek sabit ~24 ms ekler (türetilmiş). Ve ZigBolt gibi amaca özel bir paylaşımlı-bellek taşıma katmanı, bu makinede native olarak ~39 ns'de round-trip yapıyor — soket tabanımızın ~360x altında (türetilmiş; native Zig'e karşı bir Python client'ı, bu yüzden bunu bir yarış değil, tabanın işgal edebileceği aralık olarak okuyun).
  6. Bir kez, byte'larla, veri zaten oradayken geçin — ve sınır size ~%0.1'e eşitlik satın alır. Bir eşdeğerlik kontrolüyle kapılanmış (PnL −5165.58, 57,029 işlem, diller arasında ve her iki Rust build'i arasında aynı), araştırmaya ve canlıya hizmet eden tek bir çekirdek, bir motor servisi için dürüst durum. Dürüst olmayan durumlar — JSON, chatty, çağrı-başına-spawn — IPC'ye ününü kazandıranlar.

Tam deney — Rust motoru, tel protokolü, echo ve serileştirme harness'leri, eşdeğerlik kapısı, ve bu makaledeki her rakamın tek bir deterministik betikten yeniden üretilebilir olması — ipc-tax.marketmaker.cc adresindeki eşlik eden makalede, kod ve veri ise github.com/suenot/ipc-tax adresinde.

Soket asla sorun değildi. Tüm veri seti için iki milisaniye, gidiş-dönüş — halk bilgeliği üç büyüklük mertebesi kadar yanılmıştı, ve aynı anda her iki yönde de: byte'lar konusunda fazla kötümser, metin konusunda fazla hoşgörülü. Sınırı bir şeye mal oluyormuş gibi geçin, ve olmayacak.

Sorumluluk Reddi: Bu makalede sağlanan bilgiler yalnızca eğitim ve bilgilendirme amaçlıdır ve finansal, yatırım veya ticaret tavsiyesi niteliği taşımaz. Kripto para ticareti önemli bir kayıp riski içerir.

Yazarlar

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

Piyasanın Önünde Olun

Özel yapay zeka ticaret içgörüleri, piyasa analizi ve platform güncellemeleri için bültenimize abone olun.

Gizliliğinize saygı duyuyoruz. İstediğiniz zaman abonelikten çıkabilirsiniz.