De IPC-belasting: zet de backtest-engine achter een socket en verlies 13% — bijna niets daarvan komt door de socket
Onderdeel van de serie "Backtests zonder illusies".
📄 Dit artikel is uitgegroeid tot een onderzoekspaper. Eén padafhankelijke backtestkernel wordt regel voor regel geport van numba naar Rust en op vier manieren over een proces-/taalgrens aangeroepen, met een equivalentiepoort die identieke PnL per combinatie bevestigt — plus geïsoleerde metingen van de pure IPC-latentiecurve, de serialisatiebelasting en de spawnkosten. Lees het paper online (interactieve versie + PDF) op ipc-tax.marketmaker.cc, code en data op github.com/suenot/ipc-tax.
Elke backtest-engine die snel wordt, roept uiteindelijk hetzelfde gesprek op. Het onze kwam op schema. De snelheidsladder had net een parametersweep van 80 combinaties teruggebracht van 69,9 seconden pandas naar ongeveer 2 seconden single-threaded numba, en de natuurlijke volgende drang was: waarom stoppen bij een Python-JIT? Herschrijf de kernel in Rust. Maak er een echte engine-service van — één gecompileerde binary achter een socket, aanroepbaar vanuit elk onderzoeksscript, elke taal, en ook de live trader. Eén kernel, één waarheid, geen gedupliceerde logica.
En dan komt het tegenargument, ook op schema: zodra je het proces verlaat, vreet IPC je op. De data moet worden geserialiseerd, over een grens verstuurd, gedeserialiseerd; elke aanroep kost syscalls en contextwissels; je prachtige Rust-kernel zal zijn leven slijten wachtend op een pipe. Blijf in het proces. Dat weet iedereen.
Dit artikel meet wat iedereen denkt te weten, en de meting is interessanter dan beide kanten van het argument. Het volksgeloof — "een snellere engine over meerdere talen verliest van in-process numba omdat IPC je fataal wordt" — blijkt in het algemeen onjuist en alleen onder specifieke voorwaarden juist. De grens één keer oversteken, in ruwe bytes, kost ongeveer 2 milliseconden op een taak van twee seconden: een afrondingsfout. De belasting zit niet in de grens. Ze zit in hoe je hem oversteekt — en de drie manieren waarop engine-services in de praktijk meestal worden ingezet (een JSON-API, een aanroep per werkeenheid, een process-spawn per aanroep) zijn elk, meetbaar, een stuk van de ramp die de folklore voorspelt.
Hier is het hele experiment vooraf. Alles hieronder is de anatomie van elke regel.
| Architectuur | Wat de grens per sweep oversteekt | Wandtijd | vs in-process |
|---|---|---|---|
| in-process numba | niets — een directe aanroep | 2,010 s | 1,00x |
| Rust-server, batched (Unix-socket) | één rondreis: de hele serie + alle 80 parametersets | 2,276 s | 1,13x |
Rust-server, batched, get_unchecked-kernel |
dezelfde enkele rondreis — een grenscontrolevrije kernelvariant (zie het verdict) | 2,337 s | 1,16x |
| Rust-server, chatty (Unix-socket) | 80 rondreizen: de serie opnieuw verstuurd per combinatie | 2,383 s | 1,19x |
| Rust spawn (stdin/stdout) | process-spawn + één gepijpte aanvraag | 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, nul externe crates). 150.000 bars × 80 combinaties, 0,09% rondreiskosten, seed 42; de close-serie is 1.200.000 bytes (1,2 MB) op de lijn. Mediaan van 10 runs per architectuur; min-max-spreidingen blijven binnen ~2%. Alle vijf draaien dezelfde HMA/HMA3 stop-and-reverse sweep, en een equivalentiepoort bevestigt dat de resultaten per combinatie (PnL, aantal trades) van beide Rust-kernelvarianten exact overeenkomen met numba — fingerprint-PnL −5165,58 over 57.029 trades, byte-identiek aan de numba-kernel uit de snelheidsladder-studie op dezelfde seed. We vergelijken grenzen, geen implementaties.
Lees de batched-rij zorgvuldig, want die draagt de hele stelling. De Rust-over-socket-architectuur is 1,13x trager dan in-process numba — 266 ms achter op de volledige sweep (afgeleid: 2,276 − 2,010). Het volksverhaal zegt dat die milliseconden IPC zijn. Dat zijn ze niet. Ongeveer 2 ms van dat gat is de grens — de hele 1,2 MB close-serie erin verstuurd, resultaten teruggestuurd, direct gemeten. De overige ~264 ms komen doordat onze naïeve Rust-kernel de sweep simpelweg ongeveer 13% trager berekent dan de numba-kernel (afgeleid: 2,276 s minus ~2 ms grens ≈ 2,274 s Rust-rekentijd, tegenover 2,010 s voor numba). Rust de taal verloor niet van Python de taal; één scalaire, door LLVM gecompileerde lus verloor een codegen-race van een andere — en we konden het verlies niet eens toeschrijven aan de voor de hand liggende verdachte: een grenscontrolevrije get_unchecked-build van dezelfde kernel bleek niet sneller (2,337 s; de verdict-sectie ontleedt dit). De socket had er bijna niets mee te maken.
Houd beide helften van die zin vast. De grens is bijna gratis wanneer correct overgestoken — en "herschrijf het in Rust" koopt je een deploymentgrens, geen automatische rekenwinst. Beide feiten gaan in tegen de populaire intuïtie, en beide staan in de tabel.
Eén kernel, twee talen, vier grenzen
De workload is bewust dezelfde die de snelheidsladder heeft vastgelegd, zodat de twee studies aan elkaar verankerd zijn. De kernel is een HMA/HMA3-kruising — een stop-and-reverse-systeem op twee Hull-achtige voortschrijdende gemiddelden, zeven weighted-moving-average-passes per parametercombinatie plus een stateful bar-voor-bar event-loop die een positie aanhoudt, PnL boekt minus een rondreiskosten van 0,09% bij elke kruising, en omkeert. De data zijn 150.000 bars van geseede synthetische geometrische Brownse beweging (seed=42); het grid bestaat uit 80 HMA-lengtes verspreid over . De in-process referentie is de single-threaded numba-sport van de ladder, opnieuw gemeten voor deze studie: 1,98 s daar, 2,010 s hier — dezelfde kernel, dezelfde machine, geruststellend saai.
De engine over meerdere talen is een regel-voor-regel port van die numba-kernel naar Rust — dezelfde lussen, dezelfde NaN-afhandeling, dezelfde kostenarithmetiek — gecompileerd in release-modus zonder externe crates, zodat het hele experiment afhankelijkheidsvrij en reproduceerbaar blijft. Het spreekt een bewust minimaal binair protocol: één lengte-geprefixt frame in elke richting, alles 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
De echo-opcode is het scalpel van de studie: een rondreis van controleerbare grootte die niets berekent, zodat de pure grenskosten geïsoleerd kunnen worden gemeten — serialisatie, syscalls, sockettransit, deserialisatie, en niets anders.
Vijf gemeten architecturen — vier grenspatronen plus één kernelvariant:
- in_process — de numba-kernel direct aanroepen. Geen grens. De referentie.
- rust_batch_unix — een persistente Rust-server op een Unix-domain-socket. Eén rondreis verstuurt de hele close-serie plus alle 80 parametersets; Rust berekent elke combinatie; één antwoord komt terug. De omvangrijke aanroep.
- rust_batch_unchecked — dezelfde batched grens, maar de kernel indexeert met
get_unchecked(geen grenscontroles in het hot path). Bestaat om een specifieke hypothese over het rekenverschil te testen; de verdict-sectie besteedt hem. - rust_chatty_unix — dezelfde server, maar één rondreis per combinatie, de 1,2 MB-serie elke keer opnieuw verstuurd. De naïeve RPC-per-werkeenheid-architectuur.
- rust_spawn_stdin — de binary spawnen per sweep en de aanvraag pipen via stdin. Het "shell out naar een CLI-engine"-patroon; betaalt procescreatie.
En de equivalentiepoort, zonder welke niets hiervan iets zou betekenen: na de timing wordt de per-combinatie (PnL, aantal trades)-vector van elke Rust-variant vergeleken met numba — aantal trades exact, PnL tot een absolute . De gecommitte run rapporteert all_ok: true voor zowel de safe-indexing- als de get_unchecked-build. De fingerprint van de eerste combinatie — PnL −5165,58 procentpunten over 57.029 trades — komt cijfer voor cijfer overeen met de numba-kernel van de snelheidsladder-studie, wat beide papers vastpint op dezelfde kernel met dezelfde seed. Ports over meerdere talen zijn precies waar stille divergentie graag leeft (een kost toegepast vóór in plaats van na de procentconversie, een NaN-vergelijking die anders vertakt, een off-by-one in een venster — hetzelfde soort bug waarvan onze look-ahead-bias-taxonomie liet zien dat het uit ruis een Sharpe van 15 kan fabriceren). Een benchmark van twee engines die verschillende dingen berekenen is geen benchmark; het zijn twee ongerelateerde programma's die racen.
Met vastgestelde equivalentie is elk verschil in de tabel hierboven grens en rekentijd — niets anders.
Wat oversteken werkelijk kost: de echo-curve

Begin met het scalpel. De echo-op maakt een rondreis van een payload van floats via de Rust-server — Python bouwt het frame, de server parseert alle floats, codeert ze opnieuw en stuurt ze terug. Beide richtingen betalen serialisatie, syscalls en sockettransit. Hier is de gemeten curve (medianen over 10 runs):
| Payload (floats) | Bytes per richting | Rondreis |
|---|---|---|
| 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 |
Twee structurele feiten leven in deze tabel.
Ten eerste, de bodem. Een rondreis die in wezen niets vervoert — 8 bytes — kost 14 µs. Dat is de onherleidbare prijs van het überhaupt doen van een aanroep over dit transport: twee write-syscalls, twee read-syscalls, kernel-socketmachinerie, scheduler-wake-ups. Merk op hoe vlak de curve links is: van 1 float naar 1.000 floats beweegt de kost nauwelijks (14,1 → 18,1 µs). Onder ongeveer 8 KB betaal je voor de aanroep, niet voor de bytes. Dit getal — de latentiebodem — is de belangrijkste constante van de hele studie, en we bouwen hieronder de break-evenarithmetiek erop.
Ten tweede, de helling. Voorbij ~10.000 floats wordt de curve bandbreedte-gebonden en ongeveer lineair. De volledige 1,2 MB-serie — 2,4 MB in totaal verplaatst, heen en terug, inclusief een volledige parse en her-codering van 150.000 floats aan de Rust-kant — kost 2.043,4 µs. Dat komt neer op een effectieve ~1,2 GB/s door de hele naïeve stack (afgeleid: 2,4 MB / 2,04 ms) — een Unix-domain-socket met lengte-geprefixte frames en een byte-voor-byte float-parser, geen zero-copy-trucs, geen shared memory, niets slims.
Een redelijk model van één oversteek, met beide gemeten constanten:
Plaats nu het kopgetal in context. De volledige sweep duurt 2,010 s in-process. De hele dataset over de grens en terug versturen kost ~2,0 ms — ongeveer 0,1% van de taak (afgeleid: 2,0434 ms / 2,010 s). Als je één keer oversteekt, in ruwe bytes, is de grens een afrondingsfout. Dat is de helft van het volksgeloof die het eerst sterft: de angst ging nooit over iets zo goedkoops.
De Rust-kant van die oversteek is zo ongeglamoureus als systeemcode maar kan zijn — aangepast van 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());
}
Eén eerlijke scope-noot voordat we verdergaan: alle grensgetallen in deze studie komen van een Unix-domain-socket op één host. De engine spreekt ook TCP (met TCP_NODELAY), maar we hebben dat niet gemeten; loopback-TCP zit enigszins boven deze bodems, en een echte netwerkhop is een compleet ander regime — milliseconden bodem, geen microseconden. Alles hier is daarom het best mogelijke geval voor het oversteken van een grens op deze manier. Wat de hierna gemeten belastingen des te verwerpelijker maakt: dat is wat je bovenop dat betaalt, uit vrije keuze.
De serialisatiebelasting: 1348x voor het kiezen van JSON

Hier blijkt het volksgeloof over "IPC-overhead" een verkeerde etikettering te zijn. We hebben de kosten gemeten van het coderen van dezelfde serie van 150.000 floats close-serie op drie manieren — precies de payload die elke bovenstaande architectuur verstuurt:
| Codering | Tijd om 1,2 MB floats te coderen | vs ruw |
|---|---|---|
ruwe bytes (.tobytes()) |
49,1 µs | 1,0x |
| pickle | 29,8 µs | 0,6x |
JSON (json.dumps(close.tolist())) |
66.243 µs | 1348x |
Het ruwe pad is een memcpy vermomd als functieaanroep:
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 komt zelfs iets goedkoper uit dan ons ruwe pad omdat astype een dtype-conversiekopie betaalt zelfs wanneer het dtype al overeenkomt; beide zijn memcpy-klasse en beide zijn afrondingsfouten. De binaire familie als geheel leeft drie ordes van grootte onder de tekstfamilie.)
En het tekstpad is wat bijna elke "laten we de engine een microservice maken"-deployment daadwerkelijk verstuurt:
body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})
Zesenzestig milliseconden. Om te coderen. json.dumps(close.tolist()) verpakt elke float in een Python-object, en rendert dan elk als decimale tekst — 150.000 heap-allocaties en 150.000 float-naar-string-conversies waar het ruwe pad één blokkopie deed. En de payload op de lijn zwelt ook op (een float64 kost 8 bytes binair en ongeveer twee tot drie keer zoveel als decimale tekst — we hebben zelfs niet de extra transit berekend).
Schaal het nu zoals een echte deployment het doet. Die 66 ms zijn één codering, één kant, één aanroep. Een JSON-service betaalt codering en decodering, aan beide kanten van de grens, bij elke aanroep. Eén enkele batched aanroep over JSON zou ~3,3% van het hele rekenbudget van de sweep verbranden aan alleen client-side codering (afgeleid: 66 ms / 2,010 s). Zet JSON onder de chatty-architectuur — één aanroep per combinatie, het patroon hieronder — en de client-side codering alleen kost 80 × 66 ms = 5,3 s: meer dan tweeënhalf keer de hele nuttige taak (afgeleid), voordat er ook maar één byte beweegt en voordat de server iets parseert.
Dit is de werkelijke "IPC-belasting" die de meeste teams onbewust in productie hebben gemeten. Het was nooit inter-proces-communicatie. Het was tekstserialisatie van numerieke arrays — een zelf toegebrachte 1348x op het goedkoopste onderdeel van de grens. De kolomgeoriënteerde wereld leerde deze les jaren geleden, en het is dezelfde les waar onze Polars-vs-pandas-studie steeds tegenaan liep vanuit de datapijplijnkant. Formaten zoals Arrow bestaan precies daarom, zodat array-data proces- en taalgrenzen kan oversteken als ruwe kolombytes, niet als tekst. Als je engine-service JSON spreekt voor prijsarrays, zal geen socket-tuning je redden — het protocol is het knelpunt.
Chatty vs chunky: de wet van Fowler, gemeten

Martin Fowlers Eerste Wet van Distributed Object Design — "verdeel je objecten niet" — komt met een corollarium dat hij in dezelfde adem uitsprak: als je een grens moet oversteken, moet de interface grofkorrelig zijn, omdat een remote call ordes van grootte meer kost dan een lokale. Elke veteraan van gedistribueerde systemen knikt instemmend. Bijna niemand heeft een getal voor de eigen workload. Hier is het onze.
De chunky- en chatty-architecturen draaien op dezelfde server, hetzelfde protocol, dezelfde data — alleen de aanroepgranulariteit verschilt:
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 trager (afgeleid: 2,383 − 2,276). Om precies te zijn over wat dit verschil wel en niet is: de echo-curve geeft er een naïeve voorspelling voor — 79 extra verzendingen van de volledige serie tegen ongeveer de helft van de volle-payload-rondreis van 2.043 µs elk, ongeveer 81 ms — wat ongeveer 25% onder de gemeten 107 ms uitkomt; de rest is het bouwen van de aanvraag en framing aan Python-kant per aanroep, wat de echo-voorspelling niet omvat. Hoe dan ook komt het neer op ~1,4 ms per extra oversteek (afgeleid: 107 / 79); de antwoorden zijn verwaarloosbaar — 16 bytes per combinatie.
Twee interpretaties van die 107 ms, en beide zijn belangrijk.
De milde interpretatie: het is slechts ~4,5% van de wandtijd, geen catastrofe. Waar — en het is de moeite waard te begrijpen waarom de ramp van de folklore zich hier niet voordeed. Elke chatty-aanroep draagt nog steeds 25.130 µs echte rekentijd (het equivalent van één combinatie — de gemeten in-process kosten per combinatie), zodat de grens-overhead per aanroep van ~1,4 ms een orde van grootte onder het werk per aanroep blijft. Chatty-architecturen zijn niet fataal wanneer elke aanroep echt zwaar is. Ze worden fataal naarmate de granulariteit kleiner wordt — wat het hele onderwerp van de break-even-sectie is.
De vernietigende interpretatie: deze belasting was volledig vrijwillig, en ze schaalt met aantal aanroepen × payload. Het chatty-patroon verstuurt de dataset opnieuw bij elke aanroep om één reden: de service is stateless, dus elke aanvraag moet alle context meedragen. Dat is de standaardvorm van een naïef "sweep-endpoint" — en van vrijwel elke REST-microservice die ooit op een whiteboard geschetst is. Een stateful server — de serie eenmaal laden, dan 48-byte parameterframes versturen — zou elke per-combinatie-aanroep dichtbij het kleine-payload-uiteinde van de echo-curve plaatsen: ongeveer 16 µs per aanroep, ongeveer 1,3 ms voor alle 80 (afgeleid van de echo-bodem; analytisch, niet apart gemeten). De chatty-straf zou niet krimpen; ze zou verdwijnen. De les is precies: het probleem is niet veel aanroepen doen — het is het opnieuw versturen van state omdat het protocol doet alsof elke aanroep de eerste is.
Laad de data vooraf. Verstuur parameters. Steek de grens over met intentie, niet met de hele wereld in je koffer, elke keer weer.
De spawnkosten: de engine per aanroep huren

Het derde deploymentpatroon is het oudste: helemaal geen server. Spawn de engine-binary, pipe één aanvraag over stdin, lees het antwoord van stdout, laat het sterven. Het instinct van elke shell-scripter, elke "roep gewoon de CLI aan vanuit Python"-integratie, elk hyperparameter-framework geconfigureerd om per trial een binary te lanceren.
Gemeten: 2,300 s (1,14x) — ongeveer 24 ms boven de persistente-server-batch (afgeleid: 2,300 − 2,276). Die 24 milliseconden kopen een fork/exec, de dynamische loader, pipe-setup en process-teardown. En merk op dat wat hier gemeten wordt dichtbij de bodem voor het patroon ligt: een kleine afhankelijkheidsvrije native binary, warm in de pagecache. Iets spawnen met een runtime — een JVM, een Python-interpreter met imports — kost veel meer; we hebben dat hier niet gemeten, maar de richting staat niet ter discussie.
De structuur van deze belasting is wat ertoe doet: ze is vast per aanroep, ongeacht hoeveel werk de aanroep meedraagt. Geamortiseerd over een volledige sweep van 80 combinaties is 24 ms ongeveer 1% — ruis. Respawn per combinatie en dezelfde constante wordt 80 × ~24 ms ≈ 1,9 s — in wezen de hele nuttige taak verbrand aan procescreatie (afgeleid; analytisch). Respawn per bar en de arithmetiek is het schrijven niet meer waard.
Vaste kost, fijne granulariteit: kies er één. Het patroon dat een spawn betaalt is alleen zinnig wanneer de spawn zeldzaam is en de payload erachter enorm — precies zoals onze one-spawn-per-sweep-meting, en precies in tegenstelling tot hoe per-symbool-subprocesarchitecturen uiteindelijk gebruikt worden zodra het aantal symbolen groeit.
De break-even-arithmetiek: een bodem is een hurdle rate

Alles wat tot nu toe gemeten is, comprimeert tot één ontwerpregel, en de regel is arithmetiek, geen mening.
Elke grensoversteek kost minstens de latentiebodem — 14 µs hier, de kleine-payload-echo-rondreis, en dichtbij het beste dat dit transport biedt. Die bodem is een hurdle rate: een aanroep over de grens is alleen de moeite waard als de rekentijd die hij verstuurt de drempel met een comfortabele factor overtreft. Definieer de granulariteitsratio
en het aandeel van de grens in je wandtijd is ruwweg — met payloadtransit erbovenop als de aanroep ook data meedraagt.
Laat nu de sweep-getallen hierdoor lopen. De gemeten in-process kost van één combinatie is 25.130 µs. Bij per-combinatie-granulariteit:
Per-combinatie-aanroepen zitten ~1.795x boven de bodem — de grens claimt ruim onder een tiende procent per aanroep. Dit is waarom zelfs de chatty-architectuur slechts 107 ms verloor: bij deze granulariteit van de workload wordt elk oversteekpatroon dat geen data opnieuw verstuurt of tekst spreekt veilig geamortiseerd. Combo-niveau, fold-niveau, sweep-niveau aanroepen zitten allemaal diep in de goedkope zone.
Ga nu naar het tegenovergestelde uiterste. Dit is een illustratieve cross-workload-extrapolatie — geen variant van onze sweep, maar een workload-vorm die daadwerkelijk in het wild bestaat: de engine wordt per bar geraadpleegd. Een live-achtige engine-service per tick; een gRPC-per-bar-signaalstream; een "strategieserver" die eenmaal wordt aangesproken voor elk van 150.000 bars. De nuttige rekentijd per bar in deze kernel is 25.130 µs / 150.000 ≈ 0,17 µs (afgeleid) — elke aanroep zou ongeveer 1/84 van zijn eigen grenskost aan nuttig werk meedragen (afgeleid: de bodem van 14,05 µs boven 0,168 µs rekentijd). Het totaal is erger dan de ratio klinkt:
— meer dan de hele in-process taak van 2,010 s, uitgegeven voordat de remote engine ook maar één getal berekent, en het zou 2,1 s blijven zelfs als de engine aan de andere kant oneindig snel was (afgeleid: 150.000 × 14 µs). Geen rekenvoordeel overleeft een zo fijne granulariteit. En bedenk dat deze bodem een Unix-socket op één host is; maak die per-bar-aanroep naar een service over een netwerk, en de bodem groeit met twee tot drie ordes van grootte, over 150.000 aanroepen.

Nog één eerlijke kalibratie, want 14 µs is ook geen natuurwet — het is de prijs van ons transport: een Python-client, een kernel-socket, syscalls in beide richtingen. Een speciaal voor dezelfde machine gebouwd transport gaat veel lager. ZigBolt — onze open-source Zig-berichtenbus voor HFT-workloads, native gebenchmarkt op diezelfde machine — doet een shared-memory-ring-rondreis in ongeveer 39 ns gemiddeld (one-way p50 van 10/20/30 ns bij 64/256/1024-byte berichten). Dat is ongeveer 360x onder onze socketbodem (afgeleid: 14,05 µs / 39 ns). De vergelijking is bewust appels-met-peren, en we markeren het als zodanig: onze 14 µs is een Python-client-socket-rondreis, ZigBolts 39 ns is native Zig over shared memory, dus het gat vermengt transport en runtime. Lees het niet als een race tussen de twee, maar als de range die de bodem op dezelfde machine kan innemen: ongeveer drie ordes van grootte, gekozen door de implementatie. Dit is de oude Lightweight-RPC-les (Bershad et al., 1990) in modern jasje — oversteken op dezelfde machine worden gedomineerd door protocolmachinerie, en ze storten in wanneer het transport voor het same-machine-geval is gebouwd. De break-even-arithmetiek hierboven verandert niet van vorm; de hurdle verschuift alleen. Bij een bodem van 39 ns zou zelfs per-bar-granulariteit hem overtreffen (150.000 × 39 ns ≈ 5,9 ms, afgeleid) — wat precies is hoe HFT-systemen zich grenzen kunnen veroorloven die een REST-service niet kan.
Dit is het hele break-even-verhaal in één zin: de grens geeft niet om hoe snel je engine is; hij rekent per oversteek, dus de variabelen die je controleert zijn hoeveel werk elke oversteek meedraagt — en waar de oversteek van gemaakt is. Batch per sweep en is boven de honderdduizend. Batch per combinatie, — nog steeds prima. Roep aan per bar over een socket, — de architectuur is dood voor de eerste optimalisatie, en geen herschrijving van de engine, in Rust of iets anders, kan haar doen herleven.
Waar de 1,13x werkelijk vandaan komt — en het verdict

Tijd om het kopgat eerlijk te ontleden, want het draagt de meest contra-intuïtieve bevinding van de studie.
De batched Rust-architectuur loopt 266 ms achter op in-process numba (afgeleid: 2,276 − 2,010). De gemeten grenscomponenten: één volle-payload-rondreis bij ~2,0 ms, ruwe serialisatie bij 49 µs, framekoppen bij een handvol bytes — noem de hele grensrekening ~2 ms. Meer dan 99% van het gat is dus helemaal niet de grens. Het is rekentijd: ontdaan van IPC besteedt de Rust-server ~2,274 s aan de sweep die numba in 2,010 s doet — de naïeve Rust-kernel is ongeveer 13% trager in pure rekentijd (afgeleid).
Dat verdient een onomwonden paragraaf, want "herschrijf het in Rust en het wordt sneller" is net zo goed volksgeloof als "IPC zal je fataal worden". Beide kernels eindigen in LLVM — numba verlaagt Python-bytecode erdoorheen, rustc verlaagt MIR erdoorheen — en beide draaien hoogstwaarschijnlijk als scalaire lussen: de interne som van de WMA is een floating-point-reductie, die LLVM niet automatisch zal vectoriseren zonder de fast-math-reassociatielicentie die numba's @njit-standaarden niet verlenen en onze port niet aanvraagt. Dus de ~13% is een gemeten codegen-gat tussen twee scalaire, door LLVM gecompileerde lussen — en in plaats van een oorzaak te beweren, hebben we de voor de hand liggende getest. De natuurlijke verdachte is Rusts veilige indexering: de hete WMA-lus controleert grenzen bij elke array-toegang, terwijl numba's @njit compileert met grenscontrole uitgeschakeld. Dus bouwden we een equivalentie-geverifieerde variant van dezelfde kernel op get_unchecked — nergens grenscontroles in het hot path — en maten hem als vijfde architectuur. Het sloot het gat niet: 2,337 s (1,16x), marginaal trager dan de grens-gecontroleerde build van 2,276 s. Hypothese getest, hypothese verworpen. De eerlijke stand van kennis: de ~13% is echt en reproduceerbaar (medianen over 10 runs, spreidingen binnen ~2%), en momenteel niet toe te schrijven — een verschil in allocatiegedrag, lusstructuur, of instructieplanning dat alleen assembly-level profiling zou kunnen oplossen. De les blijft intact: naïef Rust is niet automatisch sneller dan goede numba, en een taalgrens gekocht op de aanname van een gratis rekenwinst kan aankomen met een rekenverlies eraan vast. Een getunede Rust-kernel — vooraf toegewezen buffers, expliciete SIMD, threads over combinaties — zou het teken alsnog kunnen omdraaien. Maar dat is een rekenvraag, te beslechten door profiling en kernelwerk, en de vraag van deze studie is de grens. Het antwoord van de grens: eenmaal overgestoken, in bytes, kost het ~0,1%.
Stel dus het volledige verdict samen, elke clausule ervan hierboven gemeten.
Een engine-service over meerdere talen wint wanneer al het volgende geldt:
- Het rekenvoordeel is echt — gemeten op je eigen kernel, niet aangenomen op basis van de reputatie van de taal. (Het onze was −13% totdat het tegendeel bewezen werd — en de eerste "voor de hand liggende" verklaring voor dat tekort stierf in de test.)
- Je steekt grofkorrelig over — één aanroep per sweep of per fold, duizenden malen boven de bodem van 14 µs, zoals de batch-architectuur's totale 1,13x (~0,1% grens) aantoont.
- Je spreekt binair — lengte-geprefixte ruwe arrays, Arrow, alles van memcpy-klasse bij 49 µs per 1,2 MB; nooit tekst bij 66.243 µs.
- De data is vooraf geladen — een stateful server neemt alleen-parameter-aanroepen aan bij het ~16 µs-uiteinde van de echo-curve in plaats van megabytes opnieuw te versturen.
Hij verliest wanneer ingezet zoals engine-services meestal zijn:
- Een JSON/REST-microservice — betaalt de 1348x serialisatiebelasting bij elke aanroep, in beide richtingen; onder chatty-granulariteit is dat 5,3 s codering op een taak van 2 s.
- RPC per werkeenheid — per combinatie kost het hier 107 ms en overleeft alleen omdat elke aanroep 25.130 µs rekentijd meedraagt; per bar is het ~2,1 s pure IPC voordat er enig werk gebeurt, op een taak van 2,0 s.
- Een spawn per aanroep — ~24 ms vaste kosten elke keer, onschadelijk eenmaal per sweep, bijna twee seconden wanneer betaald per combinatie.
Dat wil zeggen: de architecturen die falen zijn niet exotisch. JSON-REST-engine, per-symbool-subproces, gRPC-per-tick — dat is een eerlijke telling van hoe "laten we de backtest-engine uitfactoren" daadwerkelijk gebouwd wordt. Het volksgeloof is empirisch goed onderbouwd als beschrijving van gangbare praktijk en empirisch onjuist als natuurwet. De grens was nooit het probleem. De standaardmanieren om hem over te steken zijn dat wel.
Eén argument voor de grens verdient zijn eigen zin, want het is de reden waarom we deze studie überhaupt hebben uitgevoerd. Eén gecompileerde kernel achter een goed ontworpen grens kan zowel de onderzoekssweep als de live-tradinglus bedienen — dezelfde binary, dezelfde arithmetiek, bit voor bit. Onze backtest-live-pariteitsstudie catalogiseerde hoe onderzoeks- en productie-engines uiteendrijven wanneer ze twee codebases zijn; een engine-service is de sterkste structurele remedie voor die drift, en deze studie prijst de remedie eerlijk: goed gedaan, ongeveer 0,1% van de wandtijd en een equivalentiepoort om te bewijzen dat er niets veranderde in de vertaling. Die ruil — een toegewijde procesgrens in ruil voor eenkernel-pariteit — is, volgens deze cijfers, een koopje. Slecht gedaan, verstuurt hetzelfde idee een 1348x serialisatiebelasting naar productie met je PnL erbovenop rijdend.
Belangrijkste inzichten
- De grens is bijna gratis; het volksgeloof faalt bij de meting. Het rondreizen van de hele 1,2 MB close-serie via een Unix-socket — met volledige parse en her-codering inbegrepen — kost 2.043,4 µs, ongeveer 0,1% van de taak van 2,010 s (afgeleid). De batched Rust-over-socket-architectuur komt uit op 1,13x totaal, en ~99% van zelfs dat gat is geen IPC.
- "Herschrijf het in Rust" is een rekenclaim — verifieer het voordat je de grens koopt. Onze regel-voor-regel Rust-port berekent ~13% trager dan de numba-kernel (afgeleid: 2,274 s vs 2,010 s) — een reproduceerbaar codegen-gat tussen twee scalaire, door LLVM gecompileerde lussen dat niet toegeschreven blijft: we hebben de voor de hand liggende verdachte getest en verworpen, aangezien een equivalentie-geverifieerde
get_unchecked-build zonder grenscontroles niet sneller bleek (2,337 s vs 2,276 s). Naïef Rust is niet automatisch sneller; een getunede kernel zou dat best kunnen zijn — meet, beslis dan. - De echte belasting is tekst. Het coderen van 150.000 floats als JSON kost 66.243 µs tegenover 49,1 µs ruw — 1348x, betaald per richting, per aanroep, aan beide kanten. Een chatty JSON-deployment verbrandt 5,3 s codering op een taak van 2 s (afgeleid). Spreek binair over grenzen: ruwe frames, Arrow — nooit
json.dumpsop een prijsarray. - Chatty vs chunky is meetbaar, en statelessness is de schuldige. Per-combinatie-aanroepen die de data opnieuw versturen: 1,19x tegenover de 1,13x van de batch (+107 ms, afgeleid; de eenrichting-voorspelling van de echo-curve van ~81 ms komt ~25% eronder uit, de rest is per-aanroep-framing). Een vooraf geladen stateful server zou dezelfde 80 aanroepen doen tegen ~16 µs elk — ongeveer 1,3 ms in totaal (afgeleid van de echo-bodem). Verstuur parameters, niet de dataset.
- Respecteer de bodem — en weet dat de bodem een keuze is. Onze Python-over-Unix-socket-oversteek bodemt op 14 µs; per-combinatie-granulariteit overtreft dit ~1.795x (25.130 µs rekentijd per aanroep) — veilig. Een per-bar-patroon (een illustratief cross-workload-extreem: een live per-tick-engine, niet deze sweep) zou 150.000 × 14 µs ≈ 2,1 s pure IPC betalen op een taak van 2,0 s (afgeleid) — dood bij aankomst, zelfs met een oneindig snelle engine. Spawnen per aanroep voegt een vaste ~24 ms toe (afgeleid). En een speciaal gebouwd shared-memory-transport zoals ZigBolt maakt rondreizen in ~39 ns native op deze machine — ~360x onder onze socketbodem (afgeleid; native Zig vs een Python-client, dus lees het als de range die de bodem kan innemen, geen race).
- Steek eenmaal over, in bytes, met de data al aanwezig — en de grens koopt je pariteit voor ~0,1%. Eén kernel die onderzoek en live bedient, gepoort door een equivalentiecontrole (PnL −5165,58, 57.029 trades, identiek over talen en over beide Rust-builds), is het eerlijke argument voor een engine-service. De oneerlijke gevallen — JSON, chatty, spawn-per-aanroep — zijn degene die IPC zijn reputatie gaven.
Het volledige experiment — de Rust-engine, het wire-protocol, de echo- en serialisatieharnassen, de equivalentiepoort, en elk getal in dit artikel regenereerbaar vanuit één deterministisch script — staat in het begeleidende paper op ipc-tax.marketmaker.cc, met code en data op github.com/suenot/ipc-tax.
De socket was nooit het probleem. Twee milliseconden voor de hele dataset, heen en terug — de folklore zat er drie ordes van grootte naast, en in beide richtingen tegelijk: te pessimistisch over bytes, te toegeeflijk over tekst. Steek de grens over alsof het iets kost, en dat zal het niet doen.
Auteurs
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.