La taxe IPC : mettez le moteur de backtest derrière un socket et perdez 13% — presque rien de tout cela n'est dû au socket
Fait partie de la série "Backtests sans illusions".
📄 Cet article est devenu un article de recherche. Un noyau de backtest dépendant du chemin est porté ligne par ligne de numba vers Rust et appelé à travers une frontière de processus/langage de quatre façons, avec une porte d'équivalence confirmant un PnL identique par combinaison — plus des mesures isolées de la courbe de latence IPC pure, de la taxe de sérialisation et du coût de spawn. Lisez l'article en ligne (version interactive + PDF) sur ipc-tax.marketmaker.cc, code et données sur github.com/suenot/ipc-tax.
Tout moteur de backtest qui devient rapide finit par provoquer la même conversation. Le nôtre est arrivé à l'heure. La échelle de vitesse venait de faire passer un balayage de paramètres à 80 combinaisons de 69,9 secondes de pandas à environ 2 secondes de numba mono-thread, et la démangeaison naturelle suivante était : pourquoi s'arrêter à un JIT Python ? Réécrire le noyau en Rust. En faire un véritable service moteur — un binaire compilé derrière un socket, appelable depuis chaque script de recherche, chaque langage, et aussi le trader en direct. Un noyau, une vérité, aucune logique dupliquée.
Et puis arrive le contre-argument, également à l'heure : dès que vous quittez le processus, l'IPC vous dévore. Les données doivent être sérialisées, expédiées à travers une frontière, désérialisées ; chaque appel paie des appels système et des changements de contexte ; votre magnifique noyau Rust passera sa vie à attendre sur un tuyau. Restez dans le processus. Tout le monde le sait.
Cet article mesure ce que tout le monde croit savoir, et la mesure est plus intéressante que l'un ou l'autre camp de l'argument. La croyance populaire — "un moteur multi-langage plus rapide perd contre numba en processus parce que l'IPC vous tue" — s'avère être fausse en général et vraie seulement sous des conditions spécifiques. Traverser la frontière une fois, en octets bruts, coûte environ 2 millisecondes sur une tâche de deux secondes : une erreur d'arrondi. La taxe n'est pas dans la frontière. Elle réside dans la façon dont on la traverse — et les trois façons dont les services moteurs sont habituellement déployés dans la nature (une API JSON, un appel par unité de travail, un spawn de processus par appel) sont chacune, mesurablement, un morceau du désastre que prédit le folklore.
Voici l'expérience entière d'emblée. Tout ce qui suit est l'anatomie de chaque ligne.
| Architecture | Ce qui traverse la frontière par balayage | Temps réel | vs en processus |
|---|---|---|---|
| numba en processus | rien — un appel direct | 2,010 s | 1,00x |
| Serveur Rust, groupé (socket Unix) | un aller-retour : toute la série + les 80 jeux de paramètres | 2,276 s | 1,13x |
Serveur Rust, groupé, noyau get_unchecked |
même aller-retour unique — une variante de noyau sans vérification de limites (voir le verdict) | 2,337 s | 1,16x |
| Serveur Rust, bavard (socket Unix) | 80 allers-retours : la série réexpédiée par combinaison | 2,383 s | 1,19x |
| Rust spawn (stdin/stdout) | spawn de processus + une requête acheminée par pipe | 2,300 s | 1,14x |
Apple M2 Max, Python 3.14.6, numpy 2.4.3, numba 0.64.0, rustc 1.94.0 (build release, zéro crate externe). 150 000 bougies × 80 combinaisons, frais d'aller-retour de 0,09%, graine 42 ; la série de clôture pèse 1 200 000 octets (1,2 Mo) sur le fil. Médiane de 10 exécutions par architecture ; les écarts min-max restent dans ~2%. Les cinq exécutent le même balayage stop-and-reverse HMA/HMA3, et une porte d'équivalence confirme que les résultats par combinaison (PnL, nombre de trades) des deux variantes du noyau Rust correspondent exactement à numba — empreinte PnL −5165,58 sur 57 029 trades, identique octet pour octet au noyau numba de l'étude de l'échelle de vitesse sur la même graine. Nous comparons des frontières, pas des implémentations.
Lisez attentivement la ligne "groupé", car elle porte toute la thèse. L'architecture Rust-sur-socket est 1,13x plus lente que numba en processus — 266 ms de retard sur le balayage complet (dérivé : 2,276 − 2,010). L'histoire populaire dit que ces millisecondes sont de l'IPC. Ce n'est pas le cas. Environ 2 ms de cet écart correspondent à la frontière — toute la série de clôture de 1,2 Mo envoyée, résultats renvoyés, mesuré directement. Les ~264 ms restants viennent du fait que notre noyau Rust naïf calcule simplement le balayage environ 13% plus lentement que le noyau numba (dérivé : 2,276 s moins ~2 ms de frontière ≈ 2,274 s de calcul Rust, contre 2,010 s pour numba). Rust le langage n'a pas perdu contre Python le langage ; une boucle scalaire compilée par LLVM a perdu une course de génération de code contre une autre — et nous n'avons même pas pu attribuer la perte au suspect évident : une version get_unchecked sans vérification de limites du même noyau s'est avérée pas plus rapide (2,337 s ; la section verdict dissèque cela). Le socket n'y était presque pour rien.
Retenez les deux moitiés de cette phrase. La frontière est quasi gratuite lorsqu'elle est traversée correctement — et "réécrivez-le en Rust" vous achète une frontière de déploiement, pas un gain de calcul automatique. Les deux faits vont à l'encontre de l'instinct populaire, et les deux figurent dans le tableau.
Un noyau, deux langages, quatre frontières
La charge de travail est délibérément la même que celle fixée par l'échelle de vitesse, de sorte que les deux études s'ancrent l'une à l'autre. Le noyau est un croisement HMA/HMA3 — un système stop-and-reverse sur deux moyennes mobiles de style Hull, sept passes de moyenne mobile pondérée par combinaison de paramètres plus une boucle d'événements bougie par bougie avec état qui porte une position, comptabilise le PnL moins des frais d'aller-retour de 0,09% à chaque croisement, et s'inverse. Les données sont 150 000 bougies de mouvement brownien géométrique synthétique à graine fixe (seed=42) ; la grille compte 80 longueurs HMA réparties sur . La référence en processus est le barreau numba mono-thread de l'échelle, remesuré pour cette étude : 1,98 s là-bas, 2,010 s ici — même noyau, même machine, rassurant d'ennui.
Le moteur multi-langage est un portage ligne par ligne de ce noyau numba vers Rust — mêmes boucles, même gestion des NaN, même arithmétique des frais — compilé en mode release sans crates externes, de sorte que toute l'expérience reste sans dépendance et reproductible. Il parle un protocole binaire délibérément minimal : une trame préfixée par sa longueur dans chaque sens, tout en 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
L'opcode echo est le scalpel de l'étude : un aller-retour de taille contrôlable qui ne calcule rien, de sorte que le coût pur de la frontière puisse être mesuré isolément — sérialisation, appels système, transit socket, désérialisation, et rien d'autre.
Cinq architectures mesurées — quatre schémas de frontière plus une variante de noyau :
- in_process — appeler directement le noyau numba. Pas de frontière. La référence.
- rust_batch_unix — un serveur Rust persistant sur un socket de domaine Unix. Un aller-retour expédie toute la série de clôture plus les 80 jeux de paramètres ; Rust calcule chaque combinaison ; une réponse revient. L'appel volumineux.
- rust_batch_unchecked — la même frontière groupée, mais le noyau indexe avec
get_unchecked(aucune vérification de limites dans le chemin critique). Existe pour tester une hypothèse spécifique sur l'écart de calcul ; la section verdict la consomme. - rust_chatty_unix — le même serveur, mais un aller-retour par combinaison, la série de 1,2 Mo réexpédiée à chaque fois. L'architecture RPC-par-unité-de-travail naïve.
- rust_spawn_stdin — spawner le binaire par balayage et acheminer la requête via stdin. Le schéma "appeler un moteur CLI en externe" ; paie la création de processus.
Et la porte d'équivalence, sans laquelle rien de tout cela n'aurait de sens : après la mesure du temps, le vecteur par combinaison (PnL, nombre de trades) de chaque variante Rust est comparé à celui de numba — nombre de trades exact, PnL à un absolu près. L'exécution commitée rapporte all_ok: true pour les deux builds — indexation sécurisée et get_unchecked. L'empreinte de la première combinaison — PnL −5165,58 points de pourcentage sur 57 029 trades — correspond chiffre pour chiffre au noyau numba de l'étude de l'échelle de vitesse, ce qui ancre les deux articles au même noyau sur la même graine. Les portages multi-langage sont précisément là où la divergence silencieuse adore se nicher (des frais appliqués avant plutôt qu'après la conversion en pourcentage, une comparaison de NaN qui bifurque différemment, un décalage d'un dans une fenêtre — la même espèce de bug que notre taxonomie du biais de prescience a montré capable de fabriquer un Sharpe de 15 à partir de bruit). Un benchmark de deux moteurs qui calculent des choses différentes n'est pas un benchmark ; ce sont deux programmes sans rapport qui font la course.
L'équivalence établie, chaque différence dans le tableau ci-dessus relève de la frontière et du calcul — rien d'autre.
Ce que coûte réellement la traversée : la courbe d'écho

Commençons par le scalpel. L'opération echo fait un aller-retour d'une charge utile de flottants à travers le serveur Rust — Python construit la trame, le serveur parse les flottants, les réencode et les renvoie. Les deux sens paient sérialisation, appels système et transit socket. Voici la courbe mesurée (médianes sur 10 exécutions) :
| Charge utile (flottants) | Octets par sens | Aller-retour |
|---|---|---|
| 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 |
Deux faits structurels vivent dans ce tableau.
Premièrement, le plancher. Un aller-retour ne transportant essentiellement rien — 8 octets — coûte 14 µs. C'est le prix irréductible du simple fait de passer un appel sur ce transport : deux appels système write, deux appels système read, la machinerie de socket du noyau, les réveils de l'ordonnanceur. Notez à quel point la courbe est plate à gauche : de 1 flottant à 1 000 flottants, le coût bouge à peine (14,1 → 18,1 µs). En dessous d'environ 8 Ko, vous payez pour l'appel, pas pour les octets. Ce nombre — le plancher de latence — est la constante la plus importante de toute l'étude, et nous construirons l'arithmétique du seuil de rentabilité dessus plus bas.
Deuxièmement, la pente. Passé environ 10 000 flottants, la courbe devient limitée par la bande passante et à peu près linéaire. La série complète de 1,2 Mo — 2,4 Mo déplacés au total, aller-retour compris, y compris un parsing et réencodage complets de 150 000 flottants côté Rust — coûte 2 043,4 µs. Cela donne un débit effectif d'environ 1,2 Go/s à travers toute la pile naïve (dérivé : 2,4 Mo / 2,04 ms) — un socket de domaine Unix avec des trames préfixées par longueur et un parseur de flottants octet par octet, sans astuces zero-copy, sans mémoire partagée, rien de sophistiqué.
Un modèle raisonnable d'une traversée unique, avec les deux constantes mesurées :
Maintenant, replaçons le chiffre phare dans son contexte. Le balayage complet prend 2,010 s en processus. Expédier tout son ensemble de données à travers la frontière et retour coûte ~2,0 ms — environ 0,1% du travail (dérivé : 2,0434 ms / 2,010 s). Si vous traversez une fois, en octets bruts, la frontière est une erreur d'arrondi. C'est la première moitié de la croyance populaire à s'effondrer : la peur n'a jamais porté sur quelque chose d'aussi bon marché.
Le côté Rust de cette traversée est aussi peu glamour que peut l'être du code système — adapté de 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());
}
Une note honnête sur le périmètre avant de continuer : tous les chiffres de frontière de cette étude proviennent d'un socket de domaine Unix sur une seule machine. Le moteur parle aussi TCP (avec TCP_NODELAY), mais nous ne l'avons pas mesuré ; le TCP en loopback se situe quelque peu au-dessus de ces planchers, et un véritable saut réseau est un régime totalement différent — des millisecondes de plancher, pas des microsecondes. Tout ici est donc le meilleur cas possible pour traverser une frontière de cette façon. Ce qui rend les taxes mesurées ensuite d'autant plus accablantes : c'est ce que vous payez en plus de cela, par choix.
La taxe de sérialisation : 1348x pour choisir JSON

C'est là que la croyance populaire sur le "surcoût IPC" s'avère être une erreur d'étiquetage. Nous avons mesuré le coût d'encodage de la même série de clôture de 150 000 flottants de trois façons — exactement la charge utile que chaque architecture ci-dessus expédie :
| Encodage | Temps pour encoder 1,2 Mo de flottants | vs brut |
|---|---|---|
octets bruts (.tobytes()) |
49,1 µs | 1,0x |
| pickle | 29,8 µs | 0,6x |
JSON (json.dumps(close.tolist())) |
66 243 µs | 1348x |
Le chemin brut est un memcpy déguisé en appel de fonction :
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 atterrit même légèrement moins cher que notre chemin brut car astype paie une copie de conversion de type même quand le type correspond déjà ; les deux sont de classe memcpy et les deux sont des erreurs d'arrondi. La famille binaire dans son ensemble vit trois ordres de grandeur en dessous de la famille texte.)
Et le chemin texte est ce que presque tout déploiement "faisons du moteur un microservice" expédie réellement :
body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})
Soixante-six millisecondes. Juste pour encoder. json.dumps(close.tolist()) emballe chaque flottant dans un objet Python, puis rend chacun sous forme de texte décimal — 150 000 allocations de tas et 150 000 conversions flottant-vers-chaîne là où le chemin brut faisait une seule copie de bloc. Et la charge utile sur le fil gonfle aussi (un float64 coûte 8 octets en binaire et environ deux à trois fois cela en texte décimal — nous n'avons même pas facturé le transit supplémentaire).
Maintenant, mettons cela à l'échelle comme le fait un vrai déploiement. Ces 66 ms représentent un encodage, un côté, un appel. Un service JSON paie l'encodage et le décodage, des deux côtés de la frontière, à chaque appel. Un seul appel groupé en JSON brûlerait ~3,3% de tout le budget de calcul du balayage rien que pour l'encodage côté client (dérivé : 66 ms / 2,010 s). Placez JSON sous l'architecture bavarde — un appel par combinaison, le schéma ci-dessous — et l'encodage côté client seul coûte 80 × 66 ms = 5,3 s : plus de deux fois et demie le travail utile entier (dérivé), avant qu'un seul octet ne bouge et avant que le serveur ne parse quoi que ce soit.
C'est la véritable "taxe IPC" que la plupart des équipes ont mesurée en production sans le savoir. Ce n'a jamais été de la communication inter-processus. C'était de la sérialisation texte de tableaux numériques — un 1348x auto-infligé sur le composant le moins cher de la frontière. Le monde columnaire a appris cette leçon il y a des années, et c'est la même que notre étude Polars vs pandas rencontrait sans cesse du côté du pipeline de données. Des formats comme Arrow existent précisément pour que les données de tableaux puissent traverser des frontières de processus et de langage sous forme d'octets columnaires bruts, pas de texte. Si votre service moteur parle JSON pour les tableaux de prix, aucun réglage de socket ne vous sauvera — le protocole est le goulot d'étranglement.
Bavard vs volumineux : la loi de Fowler, mesurée

La première loi de la conception d'objets distribués de Martin Fowler — "ne distribuez pas vos objets" — vient avec un corollaire qu'il a énoncé dans le même souffle : si vous devez traverser une frontière, l'interface doit être à gros grain, car un appel distant coûte des ordres de grandeur de plus qu'un appel local. Tout vétéran des systèmes distribués acquiesce. Presque personne n'a de chiffre pour sa propre charge de travail. Voici le nôtre.
Les architectures volumineuse et bavarde exécutent le même serveur, même protocole, mêmes données — seule la granularité des appels diffère :
srv.call(0, close, params)
[srv.call(0, close, [params[k]]) for k in range(n)]
Volumineuse : 2,276 s (1,13x). Bavarde : 2,383 s (1,19x) — 107 ms plus lente (dérivé : 2,383 − 2,276). Pour être précis sur ce que ce delta est et n'est pas : la courbe d'écho donne une prédiction naïve pour cela — 79 expéditions supplémentaires de la série complète à environ la moitié de l'aller-retour complet de 2 043 µs chacune, environ 81 ms — ce qui atterrit environ 25% en dessous des 107 ms mesurées ; le reste est la construction de requête et le framing côté Python par appel, que la prédiction d'écho n'inclut pas. Dans les deux cas, cela revient à ~1,4 ms par traversée supplémentaire (dérivé : 107 / 79) ; les réponses sont négligeables — 16 octets par combinaison.
Deux lectures de ces 107 ms, et les deux comptent.
La lecture clémente : ce n'est que ~4,5% du temps réel, pas une catastrophe. Vrai — et il vaut la peine de comprendre pourquoi le désastre du folklore ne s'est pas matérialisé ici. Chaque appel bavard porte toujours 25 130 µs de calcul réel (l'équivalent d'une combinaison — le coût mesuré en processus par combinaison), de sorte que le surcoût de frontière par appel d'environ 1,4 ms reste un ordre de grandeur en dessous du travail par appel. Les architectures bavardes ne sont pas fatales quand chaque appel est véritablement lourd. Elles deviennent fatales à mesure que la granularité rétrécit — ce qui est tout le sujet de la section sur le seuil de rentabilité.
La lecture accablante : cette taxe était entièrement volontaire, et elle s'échelonne avec le nombre d'appels × la charge utile. Le schéma bavard réexpédie l'ensemble de données à chaque appel pour une seule raison : le service est sans état, donc chaque requête doit porter tout le contexte. C'est la forme par défaut d'un "endpoint de balayage" naïf — et de pratiquement tout microservice REST jamais esquissé sur un tableau blanc. Un serveur avec état — charger la série une fois, puis envoyer des trames de paramètres de 48 octets — placerait chaque appel par combinaison près de l'extrémité charge-utile-minuscule de la courbe d'écho : environ 16 µs par appel, environ 1,3 ms pour les 80 (dérivé du plancher d'écho ; analytique, non mesuré séparément). La pénalité bavarde ne rétrécirait pas ; elle disparaîtrait. La leçon est précise : le problème n'est pas de faire beaucoup d'appels — c'est de réexpédier l'état parce que le protocole prétend que chaque appel est le premier.
Précharger les données. Expédier les paramètres. Traverser la frontière avec intention, pas avec le monde entier dans votre valise à chaque fois.
Le coût du spawn : louer le moteur à l'appel

Le troisième schéma de déploiement est le plus ancien : aucun serveur du tout. Spawner le binaire du moteur, acheminer une requête via stdin, lire la réponse depuis stdout, le laisser mourir. L'instinct de tout scripteur shell, toute intégration "appelons simplement le CLI depuis Python", tout framework d'hyperparamètres configuré pour lancer un binaire par essai.
Mesuré : 2,300 s (1,14x) — environ 24 ms au-dessus du lot du serveur persistant (dérivé : 2,300 − 2,276). Ces 24 millisecondes achètent un fork/exec, le chargeur dynamique, la configuration des tuyaux et le démontage du processus. Et notez que ce qui est mesuré ici est proche du plancher pour ce schéma : un petit binaire natif sans dépendance, chaud dans le cache de pages. Spawner quoi que ce soit avec un runtime — une JVM, un interpréteur Python avec des imports — coûte bien plus ; nous ne l'avons pas mesuré ici, mais la direction ne fait aucun doute.
La structure de cette taxe est ce qui compte : elle est fixe par appel, indifférente à la quantité de travail que porte l'appel. Amorti sur un balayage complet de 80 combinaisons, 24 ms représentent environ 1% — du bruit. Respawnez par combinaison et la même constante devient 80 × ~24 ms ≈ 1,9 s — essentiellement tout le travail utile brûlé en création de processus (dérivé ; analytique). Respawnez par bougie et l'arithmétique ne vaut pas la peine d'être écrite.
Coût fixe, granularité fine : choisissez-en un. Le schéma qui paie un spawn n'est sensé que lorsque le spawn est rare et que la charge utile derrière est énorme — exactement comme notre mesure d'un-spawn-par-balayage, et exactement à l'opposé de la façon dont les architectures de sous-processus par symbole finissent par être utilisées une fois que le nombre de symboles augmente.
L'arithmétique du seuil de rentabilité : un plancher est un taux plancher

Tout ce qui a été mesuré jusqu'ici se comprime en une seule règle de conception, et la règle est de l'arithmétique, pas une opinion.
Chaque traversée de frontière coûte au moins le plancher de latence — 14 µs ici, l'aller-retour d'écho à charge utile minuscule, et proche du meilleur que ce transport offre. Ce plancher est un taux plancher : un appel à travers la frontière ne vaut la peine que si le calcul qu'il expédie franchit ce taux avec une marge confortable. Définissez le ratio de granularité
et la part de la frontière dans votre temps réel est approximativement — avec le transit de charge utile en plus si l'appel transporte aussi des données.
Faisons maintenant passer les chiffres du balayage à travers cela. Le coût mesuré en processus d'une combinaison est de 25 130 µs. À la granularité par combinaison :
Les appels par combinaison se situent ~1795x au-dessus du plancher — la frontière ne réclame que bien moins d'un dixième de pourcent par appel. C'est pourquoi même l'architecture bavarde n'a perdu que 107 ms : à cette granularité de charge de travail, tout schéma de traversée qui ne réexpédie pas les données ou ne parle pas texte est amorti en toute sécurité. Les appels au niveau combinaison, fold, balayage sont tous profondément dans la zone bon marché.
Passons maintenant à l'extrême opposé. Celui-ci est une extrapolation illustrative inter-charges de travail — pas une variante de notre balayage, mais une forme de charge de travail qui existe véritablement dans la nature : le moteur est consulté par bougie. Un service moteur par-tick de style temps réel ; un flux de signaux gRPC-par-bougie ; un "serveur de stratégie" interrogé une fois pour chacune des 150 000 bougies. Le calcul utile par bougie dans ce noyau est 25 130 µs / 150 000 ≈ 0,17 µs (dérivé) — chaque appel porterait environ 1/84 de son propre coût de frontière en travail utile (dérivé : le plancher de 14,05 µs sur 0,168 µs de calcul). Le total est pire que ne le suggère le ratio :
— plus que le travail entier en processus de 2,010 s, dépensé avant que le moteur distant ne calcule un seul nombre, et cela resterait 2,1 s même si le moteur de l'autre côté était infiniment rapide (dérivé : 150 000 × 14 µs). Aucun avantage de calcul ne survit à une granularité aussi fine. Et rappelez-vous que ce plancher est un socket Unix sur une seule machine ; faites cet appel par bougie vers un service à travers un réseau, et le plancher croît de deux à trois ordres de grandeur, sur 150 000 appels.

Une calibration honnête de plus, car 14 µs n'est pas non plus une loi physique — c'est le prix de notre transport : un client Python, un socket noyau, des appels système dans les deux sens. Un transport spécifiquement conçu pour la même machine va bien plus bas. ZigBolt — notre bus de messagerie Zig open source pour les charges de travail HFT, benchmarké nativement sur cette même machine — effectue un aller-retour d'anneau en mémoire partagée en environ 39 ns en moyenne (p50 unidirectionnel de 10/20/30 ns à des messages de 64/256/1024 octets). C'est environ 360x en dessous de notre plancher de socket (dérivé : 14,05 µs / 39 ns). La comparaison est délibérément pomme contre orange, et nous le signalons comme tel : nos 14 µs sont un aller-retour socket avec client Python, les 39 ns de ZigBolt sont du Zig natif sur mémoire partagée, donc l'écart mélange transport et runtime. Lisez cela non pas comme une course entre les deux, mais comme la plage que peut occuper le plancher de la même machine : environ trois ordres de grandeur, choisis par l'implémentation. C'est la vieille leçon du RPC léger (Bershad et al., 1990) sous des habits modernes — les traversées sur la même machine sont dominées par la machinerie du protocole, et elles s'effondrent quand le transport est construit pour le cas de la même machine. L'arithmétique du seuil de rentabilité ci-dessus ne change pas de forme ; le taux plancher se déplace simplement. À un plancher de 39 ns, même la granularité par bougie le franchirait (150 000 × 39 ns ≈ 5,9 ms, dérivé) — ce qui est précisément comment les systèmes HFT peuvent se permettre des frontières qu'un service REST ne peut pas.
Voici toute l'histoire du seuil de rentabilité en une phrase : la frontière ne se soucie pas de la vitesse de votre moteur ; elle facture par traversée, donc les variables que vous contrôlez sont la quantité de travail que porte chaque traversée — et de quoi est faite la traversée. Groupez par balayage et dépasse la centaine de milliers. Groupez par combinaison, — toujours correct. Appelez par bougie sur un socket, — l'architecture est morte avant la première optimisation, et aucune réécriture du moteur, en Rust ou autre chose, ne peut la ressusciter.
Où réside réellement le 1,13x — et le verdict

Il est temps de disséquer honnêtement l'écart phare, car il porte le résultat le plus contre-intuitif de l'étude.
L'architecture Rust groupée accuse un retard de 266 ms sur numba en processus (dérivé : 2,276 − 2,010). Les composants de frontière mesurés : un aller-retour à charge utile complète à ~2,0 ms, sérialisation brute à 49 µs, en-têtes de trame à une poignée d'octets — appelons toute la facture de frontière ~2 ms. Plus de 99% de l'écart n'est donc pas la frontière du tout. C'est du calcul : dépouillé de l'IPC, le serveur Rust passe ~2,274 s à faire le balayage que numba fait en 2,010 s — le noyau Rust naïf est environ 13% plus lent en calcul brut (dérivé).
Cela mérite un paragraphe sans détour, car "réécrivez-le en Rust et ce sera plus rapide" est tout autant une croyance populaire que "l'IPC vous tuera". Les deux noyaux finissent par toucher LLVM — numba abaisse le bytecode Python à travers lui, rustc abaisse MIR à travers lui — et les deux tournent très probablement comme des boucles scalaires : la somme interne du WMA est une réduction en virgule flottante, que LLVM ne vectorisera pas automatiquement sans la licence de réassociation fast-math que les valeurs par défaut de @njit de numba n'accordent pas et que notre portage ne demande pas. Donc le ~13% est un écart de génération de code mesuré entre deux boucles scalaires compilées par LLVM — et plutôt que d'affirmer une cause, nous avons testé la suspecte évidente. Le suspect naturel est l'indexation sécurisée de Rust : la boucle chaude du WMA vérifie les limites à chaque accès au tableau, alors que le @njit de numba compile avec la vérification des limites désactivée. Nous avons donc construit une variante vérifiée par équivalence du même noyau utilisant get_unchecked — aucune vérification de limites nulle part dans le chemin critique — et l'avons mesurée comme cinquième architecture. Elle n'a pas comblé l'écart : 2,337 s (1,16x), marginalement plus lente que le build avec vérification de limites à 2,276 s. Hypothèse testée, hypothèse rejetée. L'état honnête des connaissances : le ~13% est réel et reproductible (médianes sur 10 exécutions, écarts dans ~2%), et actuellement non attribué — une différence dans le comportement d'allocation, la structure de boucle, ou l'ordonnancement des instructions que seul le profilage au niveau assembleur pourrait trancher. La leçon reste intacte : le Rust naïf n'est pas automatiquement plus rapide qu'un bon numba, et une frontière de langage achetée sur l'hypothèse d'un gain de calcul gratuit peut arriver avec une perte de calcul attachée. Un noyau Rust optimisé — buffers préalloués, SIMD explicite, threads à travers les combinaisons — pourrait encore inverser le signe. Mais c'est une question de calcul, à résoudre par du profilage et du travail sur le noyau, et la question de cette étude est la frontière. La réponse de la frontière : traversée une fois, en octets, elle coûte ~0,1%.
Assemblons donc le verdict complet, chaque clause mesurée ci-dessus.
Un service moteur multi-langage gagne quand tout cela est vrai :
- L'avantage de calcul est réel — mesuré sur votre noyau, pas supposé à partir de la réputation du langage. (Le nôtre était de −13% jusqu'à preuve du contraire — et la première explication "évidente" pour ce déficit est morte lors des tests.)
- Vous traversez à gros grain — un appel par balayage ou par fold, des milliers de multiples au-dessus du plancher de 14 µs, comme le démontre le 1,13x total de l'architecture groupée (~0,1% de frontière).
- Vous parlez binaire — tableaux bruts préfixés par longueur, Arrow, tout ce qui est de classe memcpy à 49 µs par 1,2 Mo ; jamais du texte à 66 243 µs.
- Les données sont préchargées — un serveur avec état prend des appels seulement-paramètres à l'extrémité ~16 µs de la courbe d'écho au lieu de réexpédier des mégaoctets.
Il perd quand il est déployé de la façon dont les services moteurs le sont habituellement :
- Un microservice JSON/REST — paie la taxe de sérialisation de 1348x à chaque appel, dans les deux sens ; sous granularité bavarde, cela fait 5,3 s d'encodage sur un travail de 2 s.
- RPC par unité de travail — par combinaison, cela coûte 107 ms ici et survit seulement parce que chaque appel porte 25 130 µs de calcul ; par bougie, c'est ~2,1 s d'IPC pure avant qu'aucun travail ne se produise, sur un travail de 2,0 s.
- Un spawn par appel — ~24 ms de coût fixe à chaque fois, inoffensif une fois par balayage, presque deux secondes lorsqu'il est payé par combinaison.
Autrement dit : les architectures qui échouent ne sont pas exotiques. Moteur JSON REST, sous-processus par symbole, gRPC par tick — c'est un recensement juste de la façon dont "factorisons le moteur de backtest" est réellement construit. La croyance populaire est empiriquement bien fondée en tant que description de la pratique courante et empiriquement erronée en tant que loi de la nature. La frontière n'a jamais été le problème. Les façons par défaut de la traverser le sont.
Un argument en faveur de la frontière mérite sa propre phrase, car c'est la raison pour laquelle nous avons mené cette étude. Un seul noyau compilé derrière une frontière bien conçue peut servir à la fois le balayage de recherche et la boucle de trading en direct — le même binaire, la même arithmétique, bit pour bit. Notre étude de parité backtest-live a catalogué comment les moteurs de recherche et de production divergent lorsqu'ils sont deux bases de code ; un service moteur est le remède structurel le plus fort à cette dérive, et cette étude évalue honnêtement le prix du remède : bien fait, environ 0,1% du temps réel et une porte d'équivalence prouvant que rien n'a changé dans la traduction. Cet échange — une frontière de processus dédiée contre une parité à noyau unique — est, selon ces chiffres, une bonne affaire. Mal fait, la même idée expédie une taxe de sérialisation de 1348x en production avec votre PnL monté dessus.
Points à retenir
- La frontière est quasi gratuite ; la croyance populaire échoue à la mesure. Faire l'aller-retour de toute la série de clôture de 1,2 Mo à travers un socket Unix — parsing et réencodage complets inclus — coûte 2 043,4 µs, environ 0,1% du travail de 2,010 s (dérivé). L'architecture Rust-sur-socket groupée atterrit à 1,13x au total, et ~99% même de cet écart n'est pas de l'IPC.
- "Réécrivez-le en Rust" est une affirmation de calcul — vérifiez-la avant d'acheter la frontière. Notre portage Rust ligne par ligne calcule ~13% plus lentement que le noyau numba (dérivé : 2,274 s vs 2,010 s) — un écart de génération de code reproductible entre deux boucles scalaires compilées par LLVM qui reste non attribué : nous avons testé la suspecte évidente et l'avons rejetée, puisqu'un build
get_uncheckedvérifié par équivalence sans vérification de limites n'est pas ressorti plus rapide (2,337 s vs 2,276 s). Le Rust naïf n'est pas automatiquement plus rapide ; un noyau optimisé pourrait bien l'être — mesurez, puis décidez. - La vraie taxe, c'est le texte. Encoder 150 000 flottants en JSON coûte 66 243 µs contre 49,1 µs en brut — 1348x, payé par sens, par appel, des deux côtés. Un déploiement JSON bavard brûle 5,3 s d'encodage sur un travail de 2 s (dérivé). Parlez binaire à travers les frontières : trames brutes, Arrow — jamais
json.dumpssur un tableau de prix. - Bavard vs volumineux est mesurable, et l'absence d'état est le coupable. Les appels par combinaison qui réexpédient les données : 1,19x contre le 1,13x du groupé (+107 ms, dérivé ; la prédiction unidirectionnelle de la courbe d'écho d'environ 81 ms atterrit ~25% en dessous, le reste étant du framing par appel). Un serveur avec état préchargé prendrait les mêmes 80 appels à ~16 µs chacun — environ 1,3 ms au total (dérivé du plancher d'écho). Expédiez des paramètres, pas l'ensemble de données.
- Respectez le plancher — et sachez que le plancher est un choix. Notre traversée Python-sur-socket-Unix touche le plancher à 14 µs ; la granularité par combinaison le franchit ~1795x (25 130 µs de calcul par appel) — sûr. Un schéma par bougie (un extrême illustratif inter-charges de travail : un moteur en direct par-tick, pas ce balayage) paierait 150 000 × 14 µs ≈ 2,1 s d'IPC pure sur un travail de 2,0 s (dérivé) — mort à l'arrivée même avec un moteur infiniment rapide. Spawner par appel ajoute ~24 ms fixes (dérivé). Et un transport en mémoire partagée spécifiquement conçu comme ZigBolt fait des allers-retours en ~39 ns nativement sur cette machine — ~360x en dessous de notre plancher de socket (dérivé ; Zig natif vs un client Python, donc lisez cela comme la plage que peut occuper le plancher, pas une course).
- Traversez une fois, en octets, avec les données déjà présentes — et la frontière vous achète la parité pour ~0,1%. Un noyau servant à la fois la recherche et le direct, protégé par une vérification d'équivalence (PnL −5165,58, 57 029 trades, identique entre langages et entre les deux builds Rust), est le cas honnête pour un service moteur. Les cas malhonnêtes — JSON, bavard, spawn-par-appel — sont ceux qui ont donné à l'IPC sa réputation.
L'expérience complète — le moteur Rust, le protocole de fil, les harnais d'écho et de sérialisation, la porte d'équivalence, et chaque chiffre de cet article régénérable à partir d'un script déterministe unique — se trouve dans l'article compagnon sur ipc-tax.marketmaker.cc, avec code et données sur github.com/suenot/ipc-tax.
Le socket n'a jamais été le problème. Deux millisecondes pour tout l'ensemble de données, aller-retour — le folklore s'est trompé de trois ordres de grandeur, et dans les deux sens à la fois : trop pessimiste sur les octets, trop indulgent sur le texte. Traversez la frontière comme si elle coûtait quelque chose, et elle ne coûtera rien.
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.