Die IPC-Steuer: Die Backtest-Engine hinter einen Socket stellen und 13% verlieren — fast nichts davon geht auf den Socket
Teil der Serie "Backtests ohne Illusionen".
📄 Aus diesem Artikel ist ein Forschungspapier geworden. Ein pfadabhängiger Backtest-Kernel wird Zeile für Zeile von numba nach Rust portiert und auf vier Arten über eine Prozess-/Sprachgrenze hinweg aufgerufen, mit einem Äquivalenz-Gate, das identisches PnL pro Kombination bestätigt — plus isolierten Messungen der reinen IPC-Latenzkurve, der Serialisierungssteuer und der Spawn-Kosten. Lies das Paper online (interaktive Version + PDF) unter ipc-tax.marketmaker.cc, Code und Daten unter github.com/suenot/ipc-tax.
Jede Backtest-Engine, die schnell wird, provoziert irgendwann dasselbe Gespräch. Unseres kam pünktlich. Die Speed-Ladder hatte gerade einen 80-Kombinationen-Parameter-Sweep von 69,9 Sekunden pandas auf etwa 2 Sekunden Single-Thread-numba gebracht, und der natürliche nächste Impuls war: warum bei einem Python-JIT aufhören? Den Kernel in Rust neu schreiben. Ihn zu einem echten Engine-Service machen — eine kompilierte Binärdatei hinter einem Socket, aufrufbar von jedem Forschungsskript, jeder Sprache, und auch vom Live-Trader. Ein Kernel, eine Wahrheit, keine duplizierte Logik.
Und dann kommt das Gegenargument, ebenfalls pünktlich: sobald du den Prozess verlässt, frisst dich IPC auf. Die Daten müssen serialisiert, über eine Grenze verschickt, deserialisiert werden; jeder Aufruf kostet Syscalls und Kontextwechsel; dein wunderschöner Rust-Kernel wird sein Leben damit verbringen, auf eine Pipe zu warten. Bleib im Prozess. Das weiß doch jeder.
Dieser Artikel misst das, was jeder zu wissen glaubt, und die Messung ist interessanter als beide Seiten des Arguments. Der Volksglaube — "eine schnellere sprachübergreifende Engine verliert gegen In-Process-numba, weil IPC einen umbringt" — erweist sich als im Allgemeinen falsch und nur unter bestimmten Bedingungen richtig. Die Grenze einmal zu überqueren, in rohen Bytes, kostet etwa 2 Millisekunden bei einem Zwei-Sekunden-Job: ein Rundungsfehler. Die Steuer liegt nicht in der Grenze. Sie liegt darin, wie man sie überquert — und die drei Arten, wie Engine-Services in der Praxis üblicherweise eingesetzt werden (eine JSON-API, ein Aufruf pro Arbeitseinheit, ein Prozess-Spawn pro Aufruf), sind jede für sich messbar ein Stück der Katastrophe, die die Folklore vorhersagt.
Hier ist das gesamte Experiment im Voraus. Alles unten ist die Anatomie jeder Zeile.
| Architektur | Was pro Sweep die Grenze überquert | Wandzeit | vs In-Process |
|---|---|---|---|
| In-Process numba | nichts — ein direkter Aufruf | 2,010 s | 1,00x |
| Rust-Server, batched (Unix-Socket) | ein Round-Trip: die gesamte Reihe + alle 80 Parametersätze | 2,276 s | 1,13x |
Rust-Server, batched, get_unchecked-Kernel |
derselbe einzelne Round-Trip — eine bounds-check-freie Kernel-Variante (siehe das Urteil) | 2,337 s | 1,16x |
| Rust-Server, chatty (Unix-Socket) | 80 Round-Trips: die Reihe wird pro Kombination erneut verschickt | 2,383 s | 1,19x |
| Rust Spawn (stdin/stdout) | Prozess-Spawn + eine per Pipe geschickte Anfrage | 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, null externe Crates). 150.000 Bars × 80 Kombinationen, 0,09% Round-Trip-Gebühr, Seed 42; die Close-Reihe ist 1.200.000 Bytes (1,2 MB) auf der Leitung. Median aus 10 Läufen pro Architektur; Min-Max-Spannen bleiben innerhalb von ~2%. Alle fünf führen denselben HMA/HMA3-Stop-and-Reverse-Sweep aus, und ein Äquivalenz-Gate bestätigt, dass die Ergebnisse (PnL, Trade-Anzahl) pro Kombination beider Rust-Kernel-Varianten exakt mit numba übereinstimmen — Fingerprint-PnL −5165,58 über 57.029 Trades, bytegenau identisch mit dem numba-Kernel der Speed-Ladder-Studie beim selben Seed. Wir vergleichen Grenzen, nicht Implementierungen.
Lies die "batched"-Zeile sorgfältig, denn sie trägt die gesamte These. Die Rust-über-Socket-Architektur ist 1,13x langsamer als In-Process-numba — 266 ms hinter dem vollen Sweep zurück (abgeleitet: 2,276 − 2,010). Die Volksweisheit sagt, diese Millisekunden seien IPC. Sind sie nicht. Etwa 2 ms dieser Lücke sind die Grenze — die gesamte 1,2-MB-Close-Reihe hinein geschickt, Ergebnisse zurück geschickt, direkt gemessen. Die restlichen ~264 ms kommen daher, dass unser naiver Rust-Kernel den Sweep einfach etwa 13% langsamer berechnet als der numba-Kernel (abgeleitet: 2,276 s minus ~2 ms Grenze ≈ 2,274 s Rust-Compute, gegenüber 2,010 s für numba). Rust die Sprache hat nicht gegen Python die Sprache verloren; eine skalare, LLVM-kompilierte Schleife hat ein Codegen-Rennen gegen eine andere verloren — und wir konnten den Verlust nicht einmal auf den offensichtlichen Verdächtigen zurückführen: ein bounds-check-freier get_unchecked-Build desselben Kernels kam nicht schneller heraus (2,337 s; der Verdikt-Abschnitt seziert das). Der Socket hatte mit fast nichts davon zu tun.
Halte beide Hälften dieses Satzes fest. Die Grenze ist nahezu kostenlos wenn korrekt überquert — und "schreib es in Rust neu" kauft dir eine Deployment-Grenze, keinen automatischen Compute-Gewinn. Beide Fakten laufen dem populären Instinkt zuwider, und beide stehen in der Tabelle.
Ein Kernel, zwei Sprachen, vier Grenzen
Die Arbeitslast ist bewusst dieselbe, die die Speed-Ladder festgelegt hat, sodass sich die beiden Studien aneinander verankern. Der Kernel ist ein HMA/HMA3-Cross — ein Stop-and-Reverse-System auf zwei Hull-artigen gleitenden Durchschnitten, sieben gewichtete-gleitender-Durchschnitt-Durchläufe pro Parameterkombination plus eine zustandsbehaftete Bar-für-Bar-Event-Schleife, die eine Position trägt, PnL minus einer 0,09%-Round-Trip-Gebühr bei jedem Cross verbucht und umkehrt. Die Daten sind 150.000 Bars seed-gesteuerter synthetischer geometrischer Brownscher Bewegung (seed=42); das Grid sind 80 HMA-Längen verteilt über . Die In-Process-Referenz ist die Single-Thread-numba-Sprosse der Leiter, für diese Studie neu gemessen: 1,98 s dort, 2,010 s hier — derselbe Kernel, dieselbe Maschine, beruhigend langweilig.
Die sprachübergreifende Engine ist ein Zeile-für-Zeile-Port dieses numba-Kernels nach Rust — dieselben Schleifen, dieselbe NaN-Behandlung, dieselbe Gebühren-Arithmetik — im Release-Modus kompiliert ohne externe Crates, sodass das gesamte Experiment abhängigkeitsfrei und reproduzierbar bleibt. Es spricht ein bewusst minimales Binärprotokoll: ein längenpräfixierter Frame pro Richtung, 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
Der echo-Opcode ist das Skalpell der Studie: ein Round-Trip kontrollierbarer Größe, der nichts berechnet, sodass die reinen Grenzkosten isoliert gemessen werden können — Serialisierung, Syscalls, Socket-Transit, Deserialisierung und sonst nichts.
Fünf gemessene Architekturen — vier Grenzmuster plus eine Kernel-Variante:
- in_process — den numba-Kernel direkt aufrufen. Keine Grenze. Die Referenz.
- rust_batch_unix — ein persistenter Rust-Server auf einem Unix-Domain-Socket. Ein Round-Trip verschickt die gesamte Close-Reihe plus alle 80 Parametersätze; Rust berechnet jede Kombination; eine Antwort kommt zurück. Der klobige Aufruf.
- rust_batch_unchecked — dieselbe batched Grenze, aber der Kernel indexiert mit
get_unchecked(keine Bounds-Checks im heißen Pfad). Existiert, um eine spezifische Hypothese über die Compute-Lücke zu testen; der Verdikt-Abschnitt gibt sie aus. - rust_chatty_unix — derselbe Server, aber ein Round-Trip pro Kombination, die 1,2-MB-Reihe wird jedes Mal erneut verschickt. Die naive RPC-pro-Arbeitseinheit-Architektur.
- rust_spawn_stdin — die Binärdatei pro Sweep spawnen und die Anfrage über stdin durchleiten. Das "auf eine CLI-Engine auslagern"-Muster; zahlt die Prozesserstellung.
Und das Äquivalenz-Gate, ohne das nichts davon Bedeutung hätte: nach der Zeitmessung wird der (PnL, Trade-Anzahl)-Vektor jeder Rust-Variante pro Kombination mit numba verglichen — Trade-Anzahlen exakt, PnL auf ein absolutes . Der committete Lauf meldet all_ok: true sowohl für den sicher indexierenden als auch den get_unchecked-Build. Der First-Combo-Fingerprint — PnL −5165,58 Prozentpunkte über 57.029 Trades — stimmt mit dem numba-Kernel der Speed-Ladder-Studie ziffergenau überein, was beide Papers auf denselben Kernel beim selben Seed festnagelt. Sprachübergreifende Ports sind genau dort, wo stille Divergenz gerne lebt (eine Gebühr, die vor statt nach der Prozent-Umwandlung angewendet wird, ein NaN-Vergleich, der unterschiedlich verzweigt, ein Off-by-One in einem Fenster — dieselbe Art von Bug, die unsere Look-Ahead-Taxonomie zeigte, kann aus Rauschen einen Sharpe von 15 herstellen). Ein Benchmark zweier Engines, die unterschiedliche Dinge berechnen, ist kein Benchmark; es sind zwei unabhängige Programme, die gegeneinander antreten.
Mit etablierter Äquivalenz ist jeder Unterschied in der obigen Tabelle Grenze und Compute — sonst nichts.
Was das Überqueren tatsächlich kostet: die Echo-Kurve

Beginnen wir mit dem Skalpell. Der Echo-Op schickt eine Payload von Floats im Round-Trip durch den Rust-Server — Python baut den Frame, der Server parst alle Floats, kodiert sie erneut und schickt sie zurück. Beide Richtungen zahlen Serialisierung, Syscalls und Socket-Transit. Hier ist die gemessene Kurve (Mediane über 10 Läufe):
| Payload (Floats) | Bytes pro Richtung | 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 |
Zwei strukturelle Fakten leben in dieser Tabelle.
Erstens, der Boden. Ein Round-Trip, der im Grunde nichts trägt — 8 Bytes — kostet 14 µs. Das ist der irreduzible Preis dafür, überhaupt einen Aufruf zu tätigen über diesen Transport: zwei write-Syscalls, zwei read-Syscalls, Kernel-Socket-Maschinerie, Scheduler-Aufwachvorgänge. Beachte, wie flach die Kurve links ist: von 1 Float bis 1.000 Floats bewegen sich die Kosten kaum (14,1 → 18,1 µs). Unter etwa 8 KB zahlst du für den Aufruf, nicht für die Bytes. Diese Zahl — der Latenzboden — ist die wichtigste Konstante der gesamten Studie, und wir werden die Break-Even-Arithmetik unten darauf aufbauen.
Zweitens, die Steigung. Nach ~10.000 Floats wird die Kurve bandbreitengebunden und annähernd linear. Die volle 1,2-MB-Reihe — 2,4 MB insgesamt bewegt, hin und zurück, inklusive vollem Parsen und Neu-Kodieren von 150.000 Floats auf der Rust-Seite — kostet 2.043,4 µs. Das ergibt effektiv ~1,2 GB/s durch den gesamten naiven Stack (abgeleitet: 2,4 MB / 2,04 ms) — ein Unix-Domain-Socket mit längenpräfixierten Frames und einem Byte-für-Byte-Float-Parser, keine Zero-Copy-Tricks, kein Shared Memory, nichts Ausgeklügeltes.
Ein vernünftiges Modell einer einzelnen Überquerung, mit beiden gemessenen Konstanten:
Jetzt setzen wir die Schlagzeilenzahl in Kontext. Der volle Sweep dauert in-process 2,010 s. Die gesamte Datenmenge über die Grenze hin und zurück zu schicken kostet ~2,0 ms — etwa 0,1% des Jobs (abgeleitet: 2,0434 ms / 2,010 s). Wenn du einmal überquerst, in rohen Bytes, ist die Grenze ein Rundungsfehler. Das ist die Hälfte des Volksglaubens, die zuerst stirbt: die Angst betraf nie etwas so Billiges.
Die Rust-Seite dieser Überquerung ist so unglamourös wie Systemcode nur sein kann — adaptiert aus 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());
}
Eine ehrliche Anmerkung zum Umfang, bevor wir weitermachen: alle Grenzzahlen in dieser Studie stammen von einem Unix-Domain-Socket auf einem Host. Die Engine spricht auch TCP (mit TCP_NODELAY), aber wir haben es nicht gemessen; Loopback-TCP liegt etwas über diesen Böden, und ein echter Netzwerk-Hop ist ein völlig anderes Regime — Millisekunden Boden, nicht Mikrosekunden. Alles hier ist daher der bestmögliche Fall für das Überqueren einer Grenze auf diese Weise. Was die als Nächstes gemessenen Steuern umso verdammender macht: sie sind das, was man zusätzlich dazu zahlt, aus freien Stücken.
Die Serialisierungssteuer: 1348x für die Wahl von JSON

Hier zeigt sich, dass der Volksglaube über "IPC-Overhead" eine Fehlbezeichnung ist. Wir haben die Kosten der Kodierung derselben 150.000-Float-Close-Reihe auf drei Arten gemessen — genau die Payload, die jede obige Architektur verschickt:
| Kodierung | Zeit zur Kodierung von 1,2 MB Floats | vs roh |
|---|---|---|
rohe Bytes (.tobytes()) |
49,1 µs | 1,0x |
| pickle | 29,8 µs | 0,6x |
JSON (json.dumps(close.tolist())) |
66.243 µs | 1348x |
Der rohe Pfad ist ein memcpy als Funktionsaufruf verkleidet:
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 landet sogar etwas billiger als unser roher Pfad, weil astype eine Dtype-Konvertierungskopie zahlt, selbst wenn der Dtype bereits übereinstimmt; beide sind memcpy-Klasse und beide sind Rundungsfehler. Die binäre Familie als Ganzes lebt drei Größenordnungen unter der Text-Familie.)
Und der Text-Pfad ist das, was fast jedes "lass uns die Engine zu einem Microservice machen"-Deployment tatsächlich verschickt:
body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})
Sechsundsechzig Millisekunden. Nur zum Kodieren. json.dumps(close.tolist()) verpackt jeden Float in ein Python-Objekt und rendert dann jeden als Dezimaltext — 150.000 Heap-Allokationen und 150.000 Float-zu-String-Umwandlungen, wo der rohe Pfad eine einzige Blockkopie machte. Und die Wire-Payload bläht sich ebenfalls auf (ein float64 kostet 8 Bytes binär und etwa das Zwei- bis Dreifache davon als Dezimaltext — wir haben nicht einmal den zusätzlichen Transit berechnet).
Jetzt skalieren wir das so, wie es ein echtes Deployment tut. Diese 66 ms sind eine Kodierung, eine Seite, ein Aufruf. Ein JSON-Service zahlt Kodierung und Dekodierung, auf beiden Seiten der Grenze, bei jedem Aufruf. Ein einzelner batched Aufruf über JSON würde ~3,3% des gesamten Compute-Budgets des Sweeps allein für clientseitige Kodierung verbrennen (abgeleitet: 66 ms / 2,010 s). Setze JSON unter die chatty-Architektur — ein Aufruf pro Kombination, das Muster unten — und die clientseitige Kodierung allein kostet 80 × 66 ms = 5,3 s: mehr als das Zweieinhalbfache des gesamten nützlichen Jobs (abgeleitet), bevor auch nur ein Byte sich bewegt und bevor der Server irgendetwas parst.
Das ist die tatsächliche "IPC-Steuer", die die meisten Teams in der Produktion gemessen haben, ohne es zu wissen. Es war nie interprozessuale Kommunikation. Es war Textserialisierung numerischer Arrays — eine selbst zugefügte 1348x auf der billigsten Komponente der Grenze. Die kolumnare Welt hat diese Lektion vor Jahren gelernt, und es ist dieselbe, in die unsere Polars-vs-pandas-Studie von der Datenpipeline-Seite immer wieder lief. Formate wie Arrow existieren genau deshalb, damit Array-Daten Prozess- und Sprachgrenzen als rohe kolumnare Bytes überqueren können, nicht als Text. Wenn dein Engine-Service für Preisarrays JSON spricht, wird keine Socket-Optimierung dich retten — das Protokoll ist der Flaschenhals.
Chatty vs Chunky: Fowlers Gesetz, gemessen

Martin Fowlers First Law of Distributed Object Design — "verteile deine Objekte nicht" — kommt mit einem Korollar, das er im selben Atemzug ausformulierte: wenn du eine Grenze überqueren musst, muss die Schnittstelle grobkörnig sein, weil ein Remote-Aufruf Größenordnungen mehr kostet als ein lokaler. Jeder erfahrene Veteran verteilter Systeme nickt zustimmend. Fast niemand hat eine Zahl für die eigene Arbeitslast. Hier ist unsere.
Die chunky- und chatty-Architekturen laufen auf demselben Server, demselben Protokoll, denselben Daten — nur die Aufruf-Granularität unterscheidet sich:
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 langsamer (abgeleitet: 2,383 − 2,276). Um genau zu sein, was dieses Delta ist und was nicht: die Echo-Kurve liefert eine naive Vorhersage dafür — 79 zusätzliche Versendungen der vollen Reihe bei jeweils etwa der Hälfte des 2.043-µs-Volllast-Round-Trips, etwa 81 ms — was etwa 25% unter den gemessenen 107 ms landet; der Rest ist Request-Building und Framing auf der Python-Seite pro Aufruf, was die Echo-Vorhersage nicht einschließt. So oder so kommt es auf ~1,4 ms pro zusätzlicher Überquerung heraus (abgeleitet: 107 / 79); die Antworten sind vernachlässigbar — 16 Bytes pro Kombination.
Zwei Lesarten dieser 107 ms, und beide zählen.
Die milde Lesart: es sind nur ~4,5% der Wandzeit, keine Katastrophe. Wahr — und es lohnt sich zu verstehen, warum die Katastrophe der Folklore hier ausblieb. Jeder chatty-Aufruf trägt immer noch 25.130 µs echten Compute (der Gegenwert einer Kombination — die gemessenen In-Process-Kosten pro Kombination), sodass der Grenz-Overhead pro Aufruf von ~1,4 ms eine Größenordnung unter der Arbeit pro Aufruf bleibt. Chatty-Architekturen sind nicht tödlich, wenn jeder Aufruf wirklich schwer ist. Sie werden tödlich, wenn die Granularität schrumpft — was das ganze Thema des Break-Even-Abschnitts ist.
Die verdammende Lesart: diese Steuer war vollständig freiwillig, und sie skaliert mit Aufrufanzahl × Payload. Das chatty-Muster verschickt den Datensatz bei jedem Aufruf erneut aus einem einzigen Grund: der Service ist zustandslos, sodass jede Anfrage den gesamten Kontext tragen muss. Das ist die Standardform eines naiven "Sweep-Endpunkts" — und praktisch jedes REST-Microservice, das je auf einem Whiteboard skizziert wurde. Ein zustandsbehafteter Server — die Reihe einmal laden, dann 48-Byte-Parameter-Frames schicken — würde jeden Aufruf pro Kombination nahe dem Kleinlast-Ende der Echo-Kurve platzieren: etwa 16 µs pro Aufruf, ungefähr 1,3 ms für alle 80 (abgeleitet aus dem Echo-Boden; analytisch, nicht separat gemessen). Die chatty-Strafe würde nicht schrumpfen; sie würde verschwinden. Die Lektion ist präzise: das Problem ist nicht, viele Aufrufe zu machen — es ist, Zustand erneut zu verschicken, weil das Protokoll so tut, als sei jeder Aufruf der erste.
Lade die Daten vorab. Verschicke Parameter. Überquere die Grenze mit Absicht, nicht mit der ganzen Welt in deinem Koffer, jedes Mal.
Die Spawn-Kosten: die Engine pro Aufruf mieten

Das dritte Deployment-Muster ist das älteste: gar kein Server. Die Engine-Binärdatei spawnen, eine Anfrage über stdin durchleiten, die Antwort von stdout lesen, sie sterben lassen. Der Instinkt jedes Shell-Skripters, jede "ruf einfach die CLI von Python aus auf"-Integration, jedes Hyperparameter-Framework, das konfiguriert ist, eine Binärdatei pro Trial zu starten.
Gemessen: 2,300 s (1,14x) — etwa 24 ms über dem persistenten-Server-Batch (abgeleitet: 2,300 − 2,276). Diese 24 Millisekunden kaufen ein fork/exec, den dynamischen Lader, Pipe-Setup und Prozess-Teardown. Und beachte, dass dies nahe am Boden für das Muster liegt: eine kleine, abhängigkeitsfreie native Binärdatei, warm im Seitencache. Alles mit einer Laufzeitumgebung zu spawnen — eine JVM, ein Python-Interpreter mit Imports — kostet weit mehr; wir haben das hier nicht gemessen, aber die Richtung steht außer Zweifel.
Die Struktur dieser Steuer ist es, was zählt: sie ist fix pro Aufruf, unabhängig davon, wie viel Arbeit der Aufruf trägt. Amortisiert über einen vollen 80-Kombinationen-Sweep sind 24 ms etwa 1% — Rauschen. Spawne pro Kombination neu, und dieselbe Konstante wird zu 80 × ~24 ms ≈ 1,9 s — im Wesentlichen der gesamte nützliche Job verbrannt für Prozesserstellung (abgeleitet; analytisch). Spawne pro Bar neu, und die Arithmetik lohnt sich nicht mehr aufzuschreiben.
Fixe Kosten, feine Granularität: wähle eines. Das Muster, das einen Spawn zahlt, ist nur sinnvoll, wenn der Spawn selten ist und die dahinter liegende Payload riesig — genau wie unsere Ein-Spawn-pro-Sweep-Messung, und genau unähnlich der Art, wie Pro-Symbol-Subprozess-Architekturen letztlich verwendet werden, sobald die Symbolanzahl wächst.
Die Break-Even-Arithmetik: ein Boden ist eine Hürdenrate

Alles bisher Gemessene verdichtet sich zu einer Design-Regel, und die Regel ist Arithmetik, keine Meinung.
Jede Grenzüberquerung kostet mindestens den Latenzboden — 14 µs hier, der Kleinlast-Echo-Round-Trip, und nahe dem Besten, was dieser Transport bietet. Dieser Boden ist eine Hürdenrate: ein Aufruf über die Grenze lohnt sich nur, wenn der verschickte Compute die Hürde um ein komfortables Vielfaches übersteigt. Definiere das Granularitätsverhältnis
und der Grenzanteil an deiner Wandzeit ist grob — mit Payload-Transit zusätzlich, falls der Aufruf auch Daten trägt.
Nun laufen wir die Zahlen des Sweeps durch. Die gemessenen In-Process-Kosten einer Kombination betragen 25.130 µs. Bei Granularität pro Kombination:
Aufrufe pro Kombination liegen ~1.795x über dem Boden — die Grenze beansprucht deutlich weniger als ein Zehntelprozent pro Aufruf. Deshalb hat selbst die chatty-Architektur nur 107 ms verloren: bei dieser Granularität der Arbeitslast wird jedes Grenzmuster, das nicht Daten erneut verschickt oder Text spricht, sicher amortisiert. Kombinations-Ebene, Fold-Ebene, Sweep-Ebene Aufrufe liegen alle tief in der billigen Zone.
Jetzt wechseln wir zum entgegengesetzten Extrem. Dies ist eine illustrative arbeitslastübergreifende Extrapolation — keine Variante unseres Sweeps, sondern eine Arbeitslastform, die tatsächlich in der Wildnis existiert: die Engine wird pro Bar konsultiert. Ein Live-artiger Pro-Tick-Engine-Service; ein gRPC-pro-Bar-Signal-Stream; ein "Strategie-Server", der einmal für jeden von 150.000 Bars abgefragt wird. Der nützliche Compute pro Bar in diesem Kernel beträgt 25.130 µs / 150.000 ≈ 0,17 µs (abgeleitet) — jeder Aufruf würde etwa 1/84 seiner eigenen Grenzkosten an nützlicher Arbeit tragen (abgeleitet: der 14,05-µs-Boden über 0,168 µs Compute). Die Summe ist schlimmer, als das Verhältnis klingt:
— mehr als der gesamte 2,010-s-In-Process-Job, ausgegeben bevor die Remote-Engine auch nur eine einzige Zahl berechnet, und es blieben 2,1 s selbst wenn die Engine auf der anderen Seite unendlich schnell wäre (abgeleitet: 150.000 × 14 µs). Kein Compute-Vorteil überlebt eine so feine Granularität. Und erinnere dich: dieser Boden ist ein Unix-Socket auf einem Host; mache diesen Pro-Bar-Aufruf zu einem Service über ein Netzwerk, und der Boden wächst um zwei bis drei Größenordnungen, bei 150.000 Aufrufen.

Noch eine ehrliche Kalibrierung, denn 14 µs sind auch kein Naturgesetz — es ist der Preis unseres Transports: ein Python-Client, ein Kernel-Socket, Syscalls in beide Richtungen. Ein zweckgebauter Transport für dieselbe Maschine geht weit niedriger. ZigBolt — unser Open-Source-Zig-Messaging-Bus für HFT-Arbeitslasten, nativ auf derselben Maschine gebenchmarkt — macht einen Shared-Memory-Ring-Round-Trip in etwa 39 ns Mittelwert (One-Way-p50 von 10/20/30 ns bei 64/256/1024-Byte-Nachrichten). Das ist etwa 360x unter unserem Socket-Boden (abgeleitet: 14,05 µs / 39 ns). Der Vergleich ist bewusst Äpfel-mit-Birnen, und wir markieren ihn als solchen: unsere 14 µs sind ein Python-Client-Socket-Round-Trip, ZigBolts 39 ns sind natives Zig über Shared Memory, sodass die Lücke Transport und Laufzeitumgebung vermischt. Lies es nicht als Rennen zwischen den beiden, sondern als den Bereich, den der Boden auf derselben Maschine einnehmen kann: etwa drei Größenordnungen, gewählt durch Implementierung. Dies ist die alte Lightweight-RPC-Lektion (Bershad et al., 1990) in modernem Gewand — Überquerungen auf derselben Maschine werden von Protokoll-Maschinerie dominiert, und sie kollabieren, wenn der Transport für den Fall derselben Maschine gebaut ist. Die Break-Even-Arithmetik oben ändert ihre Form nicht; die Hürde verschiebt sich nur. Bei einem 39-ns-Boden würde sogar Pro-Bar-Granularität sie übersteigen (150.000 × 39 ns ≈ 5,9 ms, abgeleitet) — was genau erklärt, wie HFT-Systeme sich Grenzen leisten können, die ein REST-Service nicht kann.
Das ist die ganze Break-Even-Geschichte in einem Satz: die Grenze kümmert sich nicht darum, wie schnell deine Engine ist; sie berechnet pro Überquerung, sodass die Variablen, die du kontrollierst, sind: wie viel Arbeit jede Überquerung trägt — und woraus die Überquerung besteht. Batch pro Sweep, und liegt über hunderttausend. Batch pro Kombination, — immer noch gut. Ruf pro Bar über einen Socket auf, — die Architektur ist tot vor der ersten Optimierung, und keine Neuentwicklung der Engine, in Rust oder sonst was, kann sie wiederbeleben.
Wo die 1,13x tatsächlich herkommen — und das Urteil

Zeit, die Schlagzeilenlücke ehrlich zu sezieren, denn sie trägt den kontraintuitivsten Befund der Studie.
Die batched Rust-Architektur liegt 266 ms hinter In-Process-numba zurück (abgeleitet: 2,276 − 2,010). Die gemessenen Grenzkomponenten: ein Volllast-Round-Trip bei ~2,0 ms, rohe Serialisierung bei 49 µs, Frame-Header bei einer Handvoll Bytes — nenne die gesamte Grenzrechnung ~2 ms. Über 99% der Lücke sind daher überhaupt keine Grenze. Es ist Compute: von IPC entkleidet, verbringt der Rust-Server ~2,274 s mit dem Sweep, den numba in 2,010 s erledigt — der naive Rust-Kernel ist bei rohem Compute etwa 13% langsamer (abgeleitet).
Das verdient einen unverblümten Absatz, denn "schreib es in Rust neu und es wird schneller" ist genauso Volksglaube wie "IPC wird dich umbringen". Beide Kernel landen letztlich in LLVM — numba lowert Python-Bytecode dadurch, rustc lowert MIR dadurch — und beide laufen höchstwahrscheinlich als skalare Schleifen: die innere Summe der WMA ist eine Fließkomma-Reduktion, die LLVM ohne die Fast-Math-Reassoziationslizenz, die numbas @njit-Defaults nicht gewähren und unser Port nicht anfordert, nicht automatisch vektorisieren wird. Die ~13% sind also eine gemessene Codegen-Lücke zwischen zwei skalaren LLVM-kompilierten Schleifen — und statt eine Ursache zu behaupten, haben wir die naheliegende getestet. Der natürliche Verdächtige ist Rusts sichere Indizierung: die heiße WMA-Schleife prüft bei jedem Array-Zugriff die Grenzen, während numbas @njit mit ausgeschalteter Bounds-Prüfung kompiliert. Also bauten wir eine äquivalenzverifizierte Variante desselben Kernels mit get_unchecked — nirgendwo Bounds-Checks im heißen Pfad — und maßen sie als fünfte Architektur. Sie hat die Lücke nicht geschlossen: 2,337 s (1,16x), marginal langsamer als der bounds-geprüfte Build mit 2,276 s. Hypothese getestet, Hypothese verworfen. Der ehrliche Wissensstand: die ~13% sind real und reproduzierbar (Mediane über 10 Läufe, Spannen innerhalb von ~2%), und derzeit nicht zugeordnet — irgendein Unterschied im Allokationsverhalten, in der Schleifenstruktur oder der Befehlsplanung, den nur Assembly-Level-Profiling klären würde. Die Lektion bleibt intakt: naives Rust ist nicht automatisch schneller als gutes numba, und eine Sprachgrenze, die auf der Annahme eines kostenlosen Compute-Gewinns gekauft wird, kann mit einem angehängten Compute-Verlust ankommen. Ein getunter Rust-Kernel — vorallokierte Puffer, explizites SIMD, Threads über Kombinationen hinweg — könnte das Vorzeichen immer noch umdrehen. Aber das ist eine Compute-Frage, die durch Profiling und Kernel-Arbeit zu klären ist, und die Frage dieser Studie ist die Grenze. Die Antwort der Grenze: einmal überquert, in Bytes, kostet es ~0,1%.
Also stellen wir das vollständige Urteil zusammen, jede oben gemessene Klausel.
Ein sprachübergreifender Engine-Service gewinnt, wenn all dies zutrifft:
- Der Compute-Vorteil ist real — gemessen an deinem Kernel, nicht angenommen aus dem Ruf der Sprache. (Unserer war −13% bis zum Gegenbeweis — und die erste "offensichtliche" Erklärung für dieses Defizit starb im Test.)
- Du überquerst grobkörnig — ein Aufruf pro Sweep oder pro Fold, Tausende von Vielfachen über dem 14-µs-Boden, so wie das 1,13x-Gesamtergebnis der Batch-Architektur (~0,1% Grenze) demonstriert.
- Du sprichst binär — längenpräfixierte rohe Arrays, Arrow, alles memcpy-Klasse bei 49 µs pro 1,2 MB; niemals Text bei 66.243 µs.
- Die Daten sind vorgeladen — ein zustandsbehafteter Server nimmt Nur-Parameter-Aufrufe am ~16-µs-Ende der Echo-Kurve entgegen, statt Megabytes erneut zu verschicken.
Er verliert, wenn er so eingesetzt wird, wie Engine-Services üblicherweise eingesetzt werden:
- Ein JSON/REST-Microservice — zahlt die 1348x-Serialisierungssteuer bei jedem Aufruf, in beide Richtungen; bei chatty-Granularität sind das 5,3 s Kodierung bei einem 2-s-Job.
- RPC pro Arbeitseinheit — pro Kombination kostet es hier 107 ms und überlebt nur, weil jeder Aufruf 25.130 µs Compute trägt; pro Bar sind es ~2,1 s reine IPC, bevor überhaupt Arbeit passiert, bei einem 2,0-s-Job.
- Ein Spawn pro Aufruf — ~24 ms fixe Kosten jedes Mal, harmlos einmal pro Sweep, fast zwei Sekunden, wenn pro Kombination bezahlt.
Das heißt: die Architekturen, die scheitern, sind nicht exotisch. JSON-REST-Engine, Pro-Symbol-Subprozess, gRPC-pro-Tick — das ist eine faire Bestandsaufnahme davon, wie "lass uns die Backtest-Engine ausfaktorisieren" tatsächlich gebaut wird. Der Volksglaube ist empirisch gut begründet als Beschreibung gängiger Praxis und empirisch falsch als Naturgesetz. Die Grenze war nie das Problem. Die Standardwege, sie zu überqueren, sind es.
Ein Argument für die Grenze verdient seinen eigenen Satz, denn es ist der Grund, warum wir diese Studie überhaupt durchgeführt haben. Ein einzelner kompilierter Kernel hinter einer gut gestalteten Grenze kann sowohl den Forschungs-Sweep als auch die Live-Trading-Schleife bedienen — dieselbe Binärdatei, dieselbe Arithmetik, bitgenau. Unsere Backtest-Live-Paritätsstudie katalogisierte, wie sich Forschungs- und Produktions-Engines auseinanderdriften, wenn sie zwei Codebasen sind; ein Engine-Service ist die stärkste strukturelle Heilung für diese Drift, und diese Studie bepreist die Heilung ehrlich: richtig gemacht, etwa 0,1% der Wandzeit und ein Äquivalenz-Gate, das beweist, dass sich bei der Übersetzung nichts geändert hat. Dieser Tausch — eine dedizierte Prozessgrenze gegen Ein-Kernel-Parität — ist, nach diesen Zahlen, ein Schnäppchen. Falsch gemacht, verschickt dieselbe Idee eine 1348x-Serialisierungssteuer in die Produktion, mit deinem PnL obendrauf.
Erkenntnisse
- Die Grenze ist nahezu kostenlos; der Volksglaube scheitert an der Messung. Die gesamte 1,2-MB-Close-Reihe über einen Unix-Socket im Round-Trip zu schicken — vollständiges Parsen und Neu-Kodieren eingeschlossen — kostet 2.043,4 µs, etwa 0,1% des 2,010-s-Jobs (abgeleitet). Die batched Rust-über-Socket-Architektur landet bei 1,13x insgesamt, und ~99% selbst dieser Lücke sind keine IPC.
- "Schreib es in Rust neu" ist eine Compute-Behauptung — verifiziere sie, bevor du die Grenze kaufst. Unser Zeile-für-Zeile-Rust-Port berechnet ~13% langsamer als der numba-Kernel (abgeleitet: 2,274 s vs 2,010 s) — eine reproduzierbare Codegen-Lücke zwischen zwei skalaren LLVM-kompilierten Schleifen, die unzugeordnet bleibt: wir haben den naheliegenden Verdächtigen getestet und verworfen, da ein äquivalenzverifizierter
get_unchecked-Build ohne Bounds-Checks nicht schneller herauskam (2,337 s vs 2,276 s). Naives Rust ist nicht automatisch schneller; ein getunter Kernel könnte es durchaus sein — messen, dann entscheiden. - Die echte Steuer ist Text. Die Kodierung von 150.000 Floats als JSON kostet 66.243 µs gegenüber 49,1 µs roh — 1348x, pro Richtung, pro Aufruf, auf beiden Seiten gezahlt. Ein chatty-JSON-Deployment verbrennt 5,3 s Kodierung bei einem 2-s-Job (abgeleitet). Sprich binär über Grenzen hinweg: rohe Frames, Arrow — niemals
json.dumpsauf einem Preisarray. - Chatty vs Chunky ist messbar, und Zustandslosigkeit ist der Übeltäter. Aufrufe pro Kombination, die die Daten erneut verschicken: 1,19x gegenüber dem 1,13x des Batches (+107 ms, abgeleitet; die Ein-Weg-Vorhersage der Echo-Kurve von ~81 ms landet ~25% darunter, der Rest ist Framing pro Aufruf). Ein vorgeladener zustandsbehafteter Server würde dieselben 80 Aufrufe zu ~16 µs je nehmen — etwa 1,3 ms insgesamt (abgeleitet aus dem Echo-Boden). Verschicke Parameter, nicht den Datensatz.
- Respektiere den Boden — und wisse, dass der Boden eine Wahl ist. Unsere Python-über-Unix-Socket-Überquerung bodet bei 14 µs; Pro-Kombination-Granularität übersteigt ihn ~1.795x (25.130 µs Compute pro Aufruf) — sicher. Ein Pro-Bar-Muster (ein illustratives arbeitslastübergreifendes Extrem: eine Live-Pro-Tick-Engine, nicht dieser Sweep) würde 150.000 × 14 µs ≈ 2,1 s reine IPC bei einem 2,0-s-Job kosten (abgeleitet) — tot bei Ankunft selbst mit einer unendlich schnellen Engine. Spawnen pro Aufruf fügt fixe ~24 ms hinzu (abgeleitet). Und ein zweckgebauter Shared-Memory-Transport wie ZigBolt macht Round-Trips in ~39 ns nativ auf dieser Maschine — ~360x unter unserem Socket-Boden (abgeleitet; natives Zig vs ein Python-Client, also lies es als den Bereich, den der Boden einnehmen kann, nicht als Rennen).
- Einmal überqueren, in Bytes, mit bereits vorhandenen Daten — und die Grenze kauft dir Parität für ~0,1%. Ein Kernel, der Forschung und Live bedient, abgesichert durch eine Äquivalenzprüfung (PnL −5165,58, 57.029 Trades, identisch über Sprachen und über beide Rust-Builds hinweg), ist der ehrliche Fall für einen Engine-Service. Die unehrlichen Fälle — JSON, chatty, Spawn-pro-Aufruf — sind die, die IPC seinen Ruf eingebracht haben.
Das vollständige Experiment — die Rust-Engine, das Wire-Protokoll, die Echo- und Serialisierungs-Harnesse, das Äquivalenz-Gate und jede Zahl in diesem Artikel, aus einem deterministischen Skript regenerierbar — ist im Begleit-Paper unter ipc-tax.marketmaker.cc, mit Code und Daten unter github.com/suenot/ipc-tax.
Der Socket war nie das Problem. Zwei Millisekunden für den gesamten Datensatz, hin und zurück — die Folklore lag um drei Größenordnungen daneben, und in beide Richtungen gleichzeitig: zu pessimistisch bei Bytes, zu nachsichtig bei Text. Überquere die Grenze, als würde sie etwas kosten, und sie wird es nicht.
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.