Introduction
Après des mois d'itérations, le p99 de bout en bout de Lighter est passé de 280 ms à 55 ms. Le p99 du traitement des transactions, qui atteignait des pics de 20–30 ms, est désormais inférieur à 1 ms. Le temps d'application du chemin chaud se situe entre 100 et 250 µs. Le tout sur le mainnet, à l'échelle.
Quelle échelle ? Le 5 juin 2026, Lighter a atteint un record absolu de 811 millions de transactions en une seule journée, avec une moyenne de 9 388 TPS et des pics à 20 740 TPS.
Le séquenceur de Lighter est écrit en Go. Les transactions sont exécutées sur Lighter L2 avec des preuves cryptographiques complètes réglées sur L1. L'idée architecturale clé est que la preuve est découplée de l'exécution : la génération de preuves s'exécute de manière asynchrone et ne bloque jamais le trading. Cela signifie que la latence perçue par les traders est entièrement déterminée par les couches d'exécution et d'API de Lighter.
La couche API de Lighter est le service avec lequel les traders interagissent directement. Elle maintient une vue complète et à jour de l'état de l'échange en mémoire et sert les connexions REST et WebSocket à partir de cet état local. Une goroutine critique reçoit les mises à jour d'état du moteur d'exécution et les applique aux caches en mémoire en temps réel. Tout ce qui est en aval (push WebSocket, lectures API, simulations de transactions) est servi à partir de ces caches.
Concepts clés abordés
Les concepts suivants offrent un aperçu des sujets clés abordés dans la suite de cet article technique :
Le trouver avant le mainnet : Un environnement de test de charge identique, avec des comptes synthétiques et une charge réaliste, instrumenté de manière bien plus agressive que la production, permet de détecter les goulots d'étranglement avant que les traders ne les remarquent.
Observabilité à double environnement : Le test de charge embarque des métriques trop coûteuses pour la production ; le mainnet est surveillé en temps réel pour la fraîcheur, la latence de simulation et le cycle de vie complet des transactions. Ensemble, ils offrent une couverture complète.
Mourir de mille allocations : Remplacement systématique des types lourds sur le tas et des copies profondes par des alternatives allouées sur la pile et des instantanés immuables. Dans un langage avec GC, moins d'allocations = moins de pauses = latence de queue inférieure.
Contrôle au niveau du système d'exploitation : Attribution des threads critiques à des cœurs CPU dédiés avec une priorité quasi temps réel.
Déploiement adapté au matériel : Un placement NUMA-aware garantit que le chemin chaud bénéficie d'un accès mémoire local rapide. La dernière couche de la pile de latence.
Le binaire plutôt que la commodité : Une sérialisation binaire artisanale sur le chemin chaud élimine la réflexion et la surcharge d'allocation. Chaque microseconde économisée se cumule sur des milliers de mises à jour par seconde.
Trouver les goulots d'étranglement avant qu'ils n'atteignent le mainnet
On ne peut pas réparer ce qu'on ne voit pas. Avant d'optimiser quoi que ce soit, nous avons investi massivement dans des outils pour identifier exactement où le temps était passé.
L'environnement de test de charge
Lorsque nous voulons tester quelque chose, nous mettons en place un environnement de test de charge dédié : une copie identique de l'infrastructure du mainnet exécutant les services avec les mêmes configurations sous la même topologie de déploiement. Il n'est pas toujours actif ; nous le lançons à la demande pour un test spécifique et le démantelons ensuite.
Une fois cet environnement opérationnel, nous créons des comptes synthétiques et générons une charge de trading réaliste pour simuler les conditions réelles du marché sous stress. Cela permet :
- Chronométrage extra-granulaire : Chronométrage par étape à l'intérieur du chemin chaud, durées de construction du cache par opération, et horodatage du cycle de vie des transactions suivant chaque étape, de la soumission à la confirmation.
- Profilage à la demande et enregistreur de vol : Nous capturons les profils CPU, mémoire et trace d'exécution sur demande pendant la charge. L'enregistreur de vol de Go nous donne une collecte de traces toujours active. Lorsqu'un événement lent est détecté, les dernières secondes d'exécution sont automatiquement capturées, ce qui permet de diagnostiquer les pics de latence transitoires après coup.
- Traçage distribué : Nous avons instrumenté chaque fonction majeure du chemin chaud avec des spans de trace, offrant une visibilité fine sur l'endroit exact où le temps est passé dans un cycle de mise à jour unique, à travers les limites de service.
Surveillance à double environnement
Nous surveillons les deux environnements de près (mainnet et test de charge), mais avec des niveaux de granularité différents.
Le mainnet est étroitement surveillé en temps réel. Nous suivons tout ce qui compte pour l'expérience de trading :
- Suivi de la fraîcheur : Nous mesurons la latence via le canal WebSocket
order_booksur les marchés les plus actifs. Il s'agit essentiellement du décalage entre le moment où une mise à jour du carnet d'ordres est produite et celui où un client la reçoit. C'est la métrique qui reflète le plus directement l'expérience ressentie par les traders : à quel point le carnet d'ordres que vous consultez est-il obsolète ? Nous suivons cela à deux points. Le plus important est moteur d'exécution → client : le pipeline complet depuis le traitement d'un changement d'état par le moteur d'exécution jusqu'au moment où la mise à jourorder_bookrésultante arrive chez le client. Nous mesurons également couche API → client : la couche API horodate chaque mise à jour sortante et le client la compare à sa propre horloge, ce qui donne la latence du dernier kilomètre isolément. - Latence de simulation : La couche API simule chaque transaction (validation des signatures, nonces et soldes) avant de la transmettre au moteur d'exécution. Chaque simulation est chronométrée et rapportée.
- Cycle de vie complet des transactions : Histogrammes de latence de bout en bout, de la soumission à la confirmation.
- Efficacité du cache : Fréquence à laquelle nous servons depuis la mémoire par rapport à un repli vers un stockage plus lent.
Le test de charge est surveillé de manière encore plus granulaire. Comme il ne sert pas de vrais traders, nous pouvons augmenter l'instrumentation sans nous soucier de la surcharge : chronométrage par étape dans le chemin chaud, profilage des allocations, compartiments d'histogramme supplémentaires pour chaque mutation de cache. Cette granularité plus fine détecte les régressions au niveau micro que les métriques de production du mainnet ne révéleraient pas.
La surveillance du mainnet nous indique comment le système se comporte pour les vrais traders à l'instant présent. La surveillance du test de charge détecte les régressions avant qu'elles n'atteignent la production.
Élimination des copies profondes et guerre des allocations tas
Le ramasse-miettes de Go est une taxe sur la latence. Chaque allocation tas finit par devenir une pause GC, et les pauses GC sur un moteur de trading sont des pics de latence pour les traders. Nous avons donc parcouru le chemin chaud et réduit les allocations partout où c'était possible. Le résultat : moins de pics de latence et des performances plus prévisibles.
Élimination des copies profondes
Les copies profondes sont des usines à allocations. Nous les avons traitées de plusieurs manières :
- Instantanés immuables : Les caches des carnets d'ordres sont devenus immuables. Les lectures renvoient un pointeur vers l'instantané actuel, sans copie nécessaire. Les mises à jour créent une nouvelle version et la remplacent atomiquement via
atomic.Pointer. - Suppression des copies inutiles : Nous avons trouvé des chemins de code qui copiaient profondément des données qui n'étaient jamais modifiées par la suite. Nous les avons entièrement supprimés.
Conception consciente des allocations
- Numériques alloués sur la pile : Remplacement des
big.Intetbig.Ratlourds sur le tas par des alternatives allouées sur la pile (int128,float64, divisionint64) sur le chemin chaud : conversions de prix, cartes de profondeur de carnet d'ordres, calculs de taille. Jusqu'à 8,3× plus rapide sur les fonctions de formatage clés, zéro allocation tas par opération. - Mises à jour conditionnelles : Ignorer l'allocation lorsque rien n'a changé.
- Collections pré-dimensionnées : Éliminer les cycles d'agrandissement et de copie en dimensionnant les structures de données à l'avance.
- Réutilisation des tampons : Le chemin des abonnés désérialise des milliers de mises à jour par seconde. Nous avons réduit les allocations en utilisant une réutilisation basée sur un pool de tampons intermédiaires.
Cela a aplani la queue. Auparavant, le p99 de bout en bout de Lighter (moteur d'exécution vers client, mesuré sur le canal WebSocket order_book) atteignait des pics de 200–280 ms pendant les périodes d'allocations intensives. Après l'élimination des copies profondes, les caches immuables et le travail sur les allocations tas, le p99 s'est stabilisé dans une bande plate d'environ 50–60 ms, pratiquement sans pics :

Latence de bout en bout : Moteur d'exécution -> Client (p99)
La pression GC sur la couche API a également baissé de manière mesurable, mais ce n'était pas qu'un seul changement. Nous avons fondamentalement repensé le fonctionnement des caches du serveur API. Auparavant, les caches avaient une expiration basée sur TTL et allouaient de nouveaux objets à chaque mise à jour. Chacune de ces allocations à courte durée de vie devenait un travail pour le GC. Après la refonte, l'état complet de l'échange est démarré à chaud à partir d'un instantané et conservé en mémoire sous forme de structures immuables à longue durée de vie qui sont échangées atomiquement. Cela a supprimé le churn TTL et les allocations par mise à jour, et la pression GC a diminué avec. Nous surveillons de près l'utilisation de la mémoire et l'ensemble de travail est borné.
Nous avons exécuté l'ancien serveur API et le serveur API refondu côte à côte sur le trafic du mainnet. La durée de pause GC (p75) du serveur refondu se situait autour de ~3 ms, contre ~5–6 ms sur l'ancien serveur. Soit environ la moitié du temps de pause GC :

Durée des pauses GC : Serveur API standard vs. instantané
La fréquence raconte une histoire encore plus claire. L'ancien serveur API déclenchait des cycles GC ~2,2× plus souvent que le serveur refondu, ce qui reflète directement moins d'allocations à courte durée de vie et une pression GC globale réduite :

Fréquence des cycles GC : Serveur API standard vs. serveur API instantané
Réglage GOGC sur le moteur d'exécution
Nous avons appliqué une réflexion similaire sur le GC au moteur d'exécution lui-même. Le paramètre GOGC de Go contrôle l'agressivité du ramasse-miettes. La valeur par défaut échange du temps CPU contre de l'efficacité mémoire, mais pour un chemin critique en latence, ce compromis n'était pas le bon.
Après avoir réglé GOGC, la durée GC du moteur d'exécution est passée d'une moyenne d'environ ~30 µs avec des pics atteignant 100 µs, à une bande stable d'environ ~10 µs. Une réduction d'environ 3× avec pratiquement aucun pic :

Durée GC du moteur d'exécution après réglage GOGC
Trois mois de données du mainnet confirment que l'amélioration s'est maintenue : la période après est plate et prévisible.
Le traitement des transactions s'est amélioré en conséquence. Sur 90 jours de données du mainnet couvrant tous les types de transactions (créer un ordre, annuler, liquider, déleverage, transférer, etc.), le p99 est passé de fréquents pics de 20–30 ms avant le réglage GOGC à principalement sous 1 ms après, avec des valeurs aberrantes occasionnelles de 3–4 ms. L'amélioration s'est maintenue pendant plus de deux mois :

Temps de traitement des transactions - p99 (90 derniers jours)
Service d'instantanés — Moins de données, déploiements à chaud
Après avoir éliminé la surcharge d'allocation, nous nous sommes attaqués au goulot d'étranglement suivant : le volume de données transitant par le bus de messages interne. Nous avons construit un service d'instantanés qui maintient des instantanés complets de l'état en mémoire. Cela a eu deux effets majeurs :
- Moins de données sur le fil. Grâce au service d'instantanés détenant l'état complet, le moteur d'exécution n'a plus besoin de pousser l'état complet via le bus de messages à chaque mise à jour. Il écrit moins de données, ce qui signifie moins de bande passante réseau consommée et moins de travail de désérialisation côté récepteur.
- Déploiements sans préchauffage. Lors du déploiement, la couche API démarre à partir d'un instantané (comptes, carnets d'ordres, informations de marché, clés API publiques) et est immédiatement prête à servir. Il n'y a pas de période de préchauffage pendant que les caches se remplissent. Après avoir chargé l'instantané, la couche API s'abonne au flux de mises à jour et applique les deltas en temps réel.
En plus de la base d'instantanés, les caches en mémoire sont mis à jour en continu :
- Informations sur le compte : Lectures sans verrou via
sync.Map, échanges atomiques de pointeurs pour les mises à jour. - Carnets d'ordres : Stockés sous forme d'instantanés immuables. Les lectures obtiennent un pointeur, les mises à jour remplacent par une nouvelle version. Pas de verrou sur le chemin de lecture.
- Cache de clés API : Toutes les clés tiennent en mémoire. Les recherches externes ont été complètement éliminées.
Le résultat : l'état complet de l'échange vit en mémoire locale, mis à jour en temps réel, et chaque déploiement démarre à chaud.
L'effet d'entraînement sur le moteur d'exécution a été significatif. Auparavant, le moteur d'exécution écrivait des clés de cache sur le réseau, des mises à jour que les services lisaient de temps en temps. Avec tout en mémoire et le service d'instantanés gérant la distribution de l'état, ces écritures réseau sont devenues inutiles. Nous les avons supprimées. Le résultat : les temps de bloc p99 sont passés d'environ ~2,6 ms à ~1,2–1,8 ms, simplement parce que le moteur d'exécution écrit désormais beaucoup moins :

Temps de bloc - p99
Planification CPU
Une fois que nous avons optimisé tout ce qui était dans l'espace utilisateur, nous nous sommes tournés vers le noyau.
Nous avons de nombreuses goroutines et elles doivent être planifiées sur le CPU pour s'exécuter. Minimiser la surcharge de planification est important pour les systèmes à faible latence. Par défaut, le runtime de Go multiplexe les goroutines sur les threads OS, et l'OS peut migrer librement les threads entre les cœurs CPU. Les deux introduisent une latence imprévisible.
Nous avons éliminé cela en empilant quatre mécanismes :
- `runtime.LockOSThread()` : Verrouille la goroutine sur un seul thread OS, empêchant le planificateur de Go de la migrer.
- Affinité CPU via `sched_setaffinity` : Épingle ce thread OS sur un cœur CPU spécifique (Linux). Cela empêche le noyau de le migrer entre les cœurs, évitant l'invalidation des caches L1/L2.
- Planification haute priorité via SCHED_FIFO : Élève la priorité de planification du thread, garantissant que le noyau le favorise par rapport aux autres tâches.
- Boucle d'attente active (busy-wait) : Le chemin chaud exécute un
selectavec un casdefaultvide, de sorte que la goroutine ne se met jamais en pause. Sans cela, Go déplace la goroutine dans un état "exécutable" lorsqu'aucune donnée n'est disponible, et la reprogrammation ajoute de la latence de réveil. Avec la boucle d'attente, la goroutine reste en cours d'exécution sur son cœur dédié et récupère les nouvelles mises à jour sans délai de planification.
Mesurer correctement l'impact est important ici. Le temps d'application varie selon les conditions de trafic, donc les chiffres absolus changent avec la charge. Pour isoler l'effet du pinning, nous avons exécuté deux groupes de serveurs API côte à côte sous le même trafic. Un groupe est resté non épinglé comme contrôle, et nous avons basculé l'autre sur le pinning CPU. La différence en pourcentage entre eux, mesurée au même moment sous charge identique, raconte la véritable histoire.
Avant le pinning, les deux groupes évoluent ensemble. Même latence de base, même comportement des pics :

Temps d'application du chemin chaud - Avant la planification CPU
Après avoir activé le pinning sur un groupe, il se situe constamment en dessous de la ligne de base non épinglée. Même trafic, latence plus faible. Les pics du groupe épinglé sont également plafonnés plus bas, car la gigue de migration des threads et l'invalidation des caches L1/L2 sont éliminées :

Temps d'application du chemin chaud - Après la planification CPU
Déploiement NUMA-aware
Le pinning CPU seul ne suffit pas si les accès mémoire du cœur épinglé traversent les frontières NUMA. Un nœud NUMA est un groupe de CPUs avec leur propre mémoire locale. Accéder à la mémoire depuis un nœud NUMA distant entraîne une pénalité de 10× par rapport à un accès local.
Les serveurs API de Lighter fonctionnaient à l'origine sur des machines plus grandes avec 2 nœuds NUMA :
1$ lscpu | grep NUMA2NUMA node(s): 23NUMA node0 CPU(s): 0-954NUMA node1 CPU(s): 96-19156$ cat /sys/devices/system/node/node0/distance710 100
La matrice de distance raconte l'histoire : le coût d'accès local est de 10, l'accès inter-nœud est de 100. Une pénalité de 10×. Avec N serveurs API partageant la machine, certains inévitablement avaient leur CPU épinglé sur un nœud NUMA et leur mémoire de travail (caches en mémoire, tampons de mise à jour) sur l'autre. Chaque itération du chemin chaud payait la taxe inter-nœud.
La solution était contre-intuitive : nous avons déplacé les serveurs API vers des machines plus petites avec un seul nœud NUMA. Spécifications réduites de moitié, mais tous les accès mémoire sont désormais garantis locaux :
1$ lscpu | grep NUMA2NUMA node(s): 13NUMA node0 CPU(s): 0-95
Cela a réduit les coûts et amélioré la latence en même temps. Le temps d'application du chemin chaud a encore diminué pour atteindre la plage ~100–250 µs, avec des creux hors pointe descendant à ~100 µs. Comparez cela à la plage ~200–520 µs avec le seul pinning CPU sur les machines à 2 nœuds NUMA :

Temps d'application du chemin chaud - Optimisation NUMA
Sérialisation binaire personnalisée
Le moteur d'exécution de Lighter publie les mises à jour d'état vers la couche API via un bus de messages interne. Chaque mise à jour du système transite par ce chemin. La sérialisation d'origine utilisait une bibliothèque d'encodage à usage général, mais la réflexion, les commutateurs de type et les allocations par champ créaient une surcharge inutile sur un chemin chaud.
Nous avons remplacé cela par une sérialisation binaire artisanale : encodage/décodage à mise en page fixe, sans réflexion, pour chaque type d'entité du système. Chaque type possède un encodeur/décodeur dédié qui lit et écrit les champs à des décalages d'octets connus. Pas de réflexion ni de commutateurs de type, et un minimum d'allocations. Chaque codec dispose de tests de non-régression (round-trip et fuzz) pour détecter les régressions.
Le résultat : une surcharge de sérialisation considérablement réduite sur le chemin qui alimente tous les caches en aval.
Des benchmarks indépendants sont disponibles via un tableau de bord construit et maintenu par le membre de la communauté @UngusTrade , qui compare les latences de transactions en direct sur les plateformes de trading perpétuel : latency.perps.trading





