Die Jagd nach Mikrosekunden: Lighters Latenz-Engineering

@Lighter_xyz
ENGLISCHvor 1 Tag · 08. Juli 2026
190K
552
79
39
266

TL;DR

Eine technische Analyse von Lighters Weg zur Sub-Millisekunden-Latenz, einschließlich GC-Tuning, CPU-Affinität und Speichermanagement zur Skalierung eines leistungsstarken Ethereum L2.

Einleitung

Nach monatelanger Iteration sank der End-to-End-p99 von Lighter von 280 ms auf glatte 55 ms. Die Transaktionsverarbeitung im p99-Bereich fiel von Spitzenwerten zwischen 20–30 ms auf unter 1 ms. Die Hot-Path-Apply-Zeit liegt bei 100–250 µs. All dies im Mainnet und unter Volllast.

Wie viel Volllast? Am 5. Juni 2026 erreichte Lighter mit 811 Millionen Transaktionen an einem einzigen Tag einen neuen Höchstwert, bei durchschnittlich 9.388 TPS und Spitzenwerten von 20.740 TPS.

Lighters Sequenzer ist in Go geschrieben. Transaktionen werden auf Lighter L2 ausgeführt, mit vollständigen kryptografischen Proofs, die auf L1 abgerechnet werden. Die entscheidende architektonische Erkenntnis ist, dass das Proving entkoppelt von der Ausführung ist: Die Proof-Generierung läuft asynchron und blockiert den Handel nie. Das bedeutet, dass die Latenz, die Trader erleben, vollständig von Lighters Ausführungs- und API-Schichten bestimmt wird.

Die API-Schicht von Lighter ist der Dienst, mit dem Trader direkt interagieren. Sie hält eine vollständige, aktuelle Ansicht des Börsenzustands im Speicher und bedient REST- und WebSocket-Verbindungen aus diesem lokalen Zustand. Ein kritischer Goroutine empfängt Zustandsaktualisierungen von der Ausführungsengine und wendet sie in Echtzeit auf In-Memory-Caches an. Alles nachgelagerte (WebSocket-Pushes, API-Reads, Transaktionssimulationen) wird aus diesen Caches bedient.

Behandelte Schlüsselkonzepte

Die folgenden Konzepte geben einen allgemeinen Überblick über die wichtigsten Themen, die im weiteren Verlauf dieses technischen Berichts behandelt werden:

Finde es vor dem Mainnet: Eine identische Loadtest-Umgebung mit synthetischen Konten und realistischer Last, die weitaus aggressiver instrumentiert ist als die Produktion, erkennt Engpässe, bevor Trader sie bemerken.

Dual-Umgebungs-Beobachtbarkeit: Loadtest führt Metriken, die für die Produktion zu teuer sind; das Mainnet wird in Echtzeit auf Aktualität, Simulationslatenz und den End-to-End-Transaktionslebenszyklus überwacht. Zusammen bieten sie eine vollständige Abdeckung.

Tod durch tausend Allokationen: Systematischer Ersatz von heap-intensiven Typen und Deep Copies durch stack-allozierte Alternativen und unveränderliche Snapshots. In einer Sprache mit Garbage Collector bedeuten weniger Allokationen = weniger Pausen = niedrigere Tail-Latenz.

Kontrolle auf Betriebssystemebene: Bindung der kritischen Threads an dedizierte CPU-Kerne mit nahezu Echtzeit-Priorität.

Hardware-bewusste Bereitstellung: NUMA-bewusste Platzierung stellt sicher, dass der Hot Path schnellen, lokalen Speicherzugriff hat. Die letzte Schicht im Latenz-Stack.

Binär statt Komfort: Handgefertigte binäre Serialisierung auf dem Hot Path eliminiert Reflection- und Allokations-Overhead. Jede eingesparte Mikrosekunde summiert sich bei Tausenden von Aktualisierungen pro Sekunde.

Engpässe finden, bevor sie ins Mainnet gelangen

Man kann nicht beheben, was man nicht sehen kann. Bevor wir etwas optimierten, investierten wir stark in Werkzeuge, um genau zu identifizieren, wo Zeit verbraucht wurde.

Die Loadtest-Umgebung

Wenn wir etwas testen wollen, starten wir eine dedizierte Loadtest-Umgebung: eine identische Kopie der Mainnet-Infrastruktur, die die Dienste mit identischen Konfigurationen unter derselben Bereitstellungstopologie ausführt. Sie läuft nicht ständig; wir bringen sie bei Bedarf für einen bestimmten Test hoch und bauen sie danach wieder ab.

Sobald diese Umgebung läuft, erstellen wir synthetische Konten und generieren realistische Handelslast, um reale Marktbedingungen unter Stress zu simulieren. Dies ermöglicht:

  • Extragranulare Zeitmessung: Schrittweise Zeitmessung innerhalb des Hot Path, Dauer des Cache-Aufbaus pro Operation und Zeitstempel des Transaktionslebenszyklus, die jede Phase von der Einreichung bis zur Bestätigung verfolgen.
  • On-Demand-Profiling & Flugschreiber: Wir erfassen CPU-, Speicher- und Ausführungstrace-Profile auf Anfrage während der Last. Gos Flugschreiber gibt uns eine ständig aktive Trace-Erfassung. Wenn ein langsames Ereignis erkannt wird, werden die letzten Sekunden der Ausführung automatisch erfasst, sodass wir transiente Latenzspitzen im Nachhinein diagnostizieren können.
  • Verteiltes Tracing: Wir haben jede wichtige Funktion im Hot Path mit Trace-Spans instrumentiert, was uns eine feinkörnige Sichtbarkeit darüber gibt, wo genau Zeit innerhalb eines einzelnen Aktualisierungszyklus über Dienstgrenzen hinweg verbracht wird.

Dual-Umgebungs-Überwachung

Wir überwachen beide Umgebungen genau (Mainnet und Loadtest), jedoch mit unterschiedlicher Granularität.

Mainnet wird in Echtzeit eng überwacht. Wir verfolgen alles, was für das Handelserlebnis wichtig ist:

  • Aktualitätsverfolgung: Wir messen die Latenz über den WebSocket-Kanal \order_book\ auf den aktivsten Märkten. Im Wesentlichen die Abweichung zwischen dem Zeitpunkt, an dem ein Orderbuch-Update erstellt wird, und dem Zeitpunkt, an dem ein Client es erhält. Dies ist die Metrik, die die Erfahrung der Trader am direktesten widerspiegelt: Wie veraltet ist das Orderbuch, das Sie gerade betrachten? Wir verfolgen dies an zwei Punkten. Der wichtigste ist Ausführungsengine → Client: die gesamte Pipeline vom Zeitpunkt, an dem die Ausführungsengine eine Zustandsänderung verarbeitet, bis zu dem Moment, an dem das resultierende \order_book\-Update beim Client ankommt. Wir messen auch API-Schicht → Client: Die API-Schicht versieht jedes ausgehende Update mit einem Zeitstempel, und der Client vergleicht ihn mit seiner eigenen Uhr, was uns die Letzte-Meile-Latenz isoliert liefert.
  • Dry-Run-Latenz: Die API-Schicht führt einen Dry-Run für jede Transaktion durch (Validierung von Signaturen, Nonces und Salden), bevor sie an die Ausführungsengine weitergeleitet wird. Jeder Dry-Run wird zeitlich erfasst und gemeldet.
  • End-to-End-Transaktionslebenszyklus: Histogramme der vollständigen Zykluslatenz von der Einreichung bis zur Bestätigung.
  • Cache-Effektivität: Wie oft wir aus dem Speicher bedienen, im Vergleich zum Rückgriff auf langsameren Speicher.

Loadtest wird noch granularer überwacht. Da es keine echten Trader bedient, können wir die Instrumentierung hochfahren, ohne uns um Overhead sorgen zu müssen: schrittweise Zeitmessung innerhalb des Hot Path, Allokations-Profiling, zusätzliche Histogramm-Buckets bei jeder Cache-Mutation. Diese feinere Granularität erfasst mikroskopische Regressionen, die die produktionssicheren Metriken des Mainnets nicht aufdecken würden.

Die Mainnet-Überwachung sagt uns, wie das System für echte Trader jetzt gerade läuft. Die Loadtest-Überwachung erfasst Regressionen, bevor sie in die Produktion gelangen.

Deep-Copy-Eliminierung & Heap-Allokations-Kampf

Gos Garbage Collector ist eine Latenzsteuer. Jede Heap-Allokation wird irgendwann zu einer GC-Pause, und GC-Pausen in einer Handelsengine sind Latenzspitzen für Trader. Also gingen wir den Hot Path durch und reduzierten Allokationen, wo immer wir konnten. Das Ergebnis waren weniger Latenzspitzen und eine vorhersagbarere Leistung.

Deep-Copy-Eliminierung

Deep Copies sind Allokationsfabriken. Wir gingen sie auf verschiedene Weise an:

  • Unveränderliche Snapshots: Machte Orderbuch-Caches unveränderlich. Lesevorgänge geben einen Zeiger auf den aktuellen Snapshot zurück, keine Kopie erforderlich. Aktualisierungen erstellen eine neue Version und tauschen sie atomar über \atomic.Pointer\ aus.
  • Entfernung unnötiger Kopien: Fand Codepfade, die Daten tief kopierten, die danach nie mutiert wurden. Entfernte sie vollständig.

Allokationsbewusstes Design

  • Stack-allozierte Numerik: Ersetzte heap-intensive \big.Int\ und \big.Rat\ durch stack-allozierte Alternativen (\int128\, \float64\, \int64\-Division) auf dem Hot Path: Preisumrechnungen, Orderbuch-Tiefenkarten, Größenberechnungen. Bis zu 8,3× schneller bei wichtigen Formatierungsfunktionen, null Heap-Allokationen pro Operation.
  • Bedingte Aktualisierungen: Überspringe Allokation, wenn sich nichts geändert hat.
  • Vorgröße Sammlungen: Eliminiere Wachstums-und-Kopier-Zyklen, indem Datenstrukturen im Voraus dimensioniert werden.
  • Pufferwiederverwendung: Der Subscriber-Pfad deserialisiert Tausende von Aktualisierungen pro Sekunde. Wir reduzierten Allokationen durch pool-basierte Wiederverwendung von Zwischenpuffern.

Dies glättete den Tail. Zuvor spitzte sich Lighters End-to-End-p99 (Ausführungsengine zu Client, gemessen auf dem WebSocket-Kanal \order_book\) während allokationsintensiver Perioden auf 200–280 ms zu. Nach der Deep-Copy-Eliminierung, den unveränderlichen Caches und der Heap-Allokationsarbeit stabilisierte sich der p99 auf ein flaches Band von ~50–60 ms mit praktisch keinen Spitzen:

Lighter - inline image

End-to-End-Latenz: Ausführungsengine -> Client (p99)

Der GC-Druck auf der API-Schicht sank ebenfalls messbar, aber dies war nicht nur eine einzige Änderung. Wir haben grundlegend überarbeitet, wie die API-Server-Caches funktionieren. Zuvor hatten Caches eine TTL-basierte Ablaufzeit und allokierten bei jeder Aktualisierung neue Objekte. Jede dieser kurzlebigen Allokationen wurde zu GC-Arbeit. Nach der Überarbeitung wird der vollständige Börsenzustand aus einem Snapshot kaltgestartet und als langlebige, unveränderliche Strukturen im Speicher gehalten, die atomar ausgetauscht werden. Dies entfernte den TTL-Verschleiß und die Allokationen pro Aktualisierung, und der GC-Druck sank mit ihnen. Wir überwachen die Speichernutzung genau, und der Working Set ist begrenzt.

Wir ließen den alten API-Server und den überarbeiteten API-Server nebeneinander mit Mainnet-Datenverkehr laufen. Die GC-Pausendauer (p75) des überarbeiteten Servers lag bei etwa ~3 ms, verglichen mit ~5–6 ms beim alten Server. Ungefähr die Hälfte der GC-Pausenzeit:

Lighter - inline image

GC-Pausendauer: Standard- vs. Snapshot-API-Server

Die Häufigkeit erzählt eine noch klarere Geschichte. Der alte API-Server löste GC-Zyklen ~2,2× häufiger aus als der überarbeitete, was direkt weniger kurzlebige Allokationen und insgesamt weniger GC-Druck widerspiegelt:

Lighter - inline image

GC-Zyklus-Häufigkeit: Standard-API-Server vs. Snapshot-API-Server

GOGC-Tuning auf der Ausführungsengine

Wir haben ähnliche GC-Überlegungen auf die Ausführungsengine selbst angewendet. Gos \GOGC\-Parameter steuert, wie aggressiv der Garbage Collector läuft. Der Standardwert tauscht CPU-Zeit gegen Speichereffizienz, aber für einen latenzkritischen Pfad war der Kompromiss falsch.

Nach dem Tuning von \GOGC\ sank die GC-Dauer der Ausführungsengine von durchschnittlich ~30 µs mit Spitzen von 100 µs auf ein stabiles Band von ~10 µs. Eine ~3× Reduktion mit praktisch keinen Spitzen:

Lighter - inline image

GC-Dauer der Ausführungsengine nach GOGC-Tuning

Drei Monate Mainnet-Daten bestätigen, dass die Verbesserung anhielt: Der Zeitraum danach ist flach und vorhersagbar.

Die Transaktionsverarbeitung verbesserte sich damit. Über 90 Tage Mainnet-Daten, die jeden Transaktionstyp abdecken (Order erstellen, stornieren, liquidieren, deleveragen, übertragen und mehr), fiel der p99 von häufigen 20–30 ms-Spitzen vor dem GOGC-Tuning auf meist unter 1 ms danach, mit gelegentlichen Ausreißern von 3–4 ms. Die Verbesserung hält seit über zwei Monaten an:

Lighter - inline image

Transaktionsverarbeitungszeiten - p99 (letzte 90 Tage)

Snapshot-Dienst — Weniger Daten, heiße Deployments

Nachdem wir den Allokations-Overhead beseitigt hatten, gingen wir den nächsten Engpass an: die Datenmenge, die durch den internen Message Bus fließt. Wir bauten einen Snapshot-Dienst, der vollständige In-Memory-Zustandssnapshots unterhält. Dies hatte zwei wesentliche Auswirkungen:

  • Weniger Daten auf der Leitung. Da der Snapshot-Dienst den vollständigen Zustand hält, muss die Ausführungsengine nicht mehr bei jeder Aktualisierung den vollständigen Zustand durch den Message Bus schieben. Sie schreibt weniger Daten, was weniger verbrauchte Netzwerkbandbreite und weniger Deserialisierungsarbeit auf der Empfängerseite bedeutet.
  • Keine Aufwärmphase bei Deployments. Beim Deployment startet die API-Schicht aus einem Snapshot (Konten, Orderbücher, Marktinformationen, API-öffentliche Schlüssel) und ist sofort bereit zu bedienen. Es gibt keine Aufwärmphase, während sich Caches füllen. Nach dem Laden des Snapshots abonniert die API-Schicht den Aktualisierungsstream und wendet Deltas in Echtzeit an.

Zusätzlich zur Snapshot-Basis werden In-Memory-Caches kontinuierlich aktualisiert:

  • Kontoinformationen: Lock-freie Lesevorgänge über \sync.Map\, atomare Zeiger-Swaps für Aktualisierungen.
  • Orderbücher: Gespeichert als unveränderliche Snapshots. Lesevorgänge erhalten einen Zeiger, Aktualisierungen tauschen eine neue Version ein. Keine Locks auf dem Lesepfad.
  • API-Schlüssel-Cache: Alle Schlüssel passen in den Speicher. Externe Lookups vollständig eliminiert.

Das Ergebnis: Der gesamte Börsenzustand lebt im lokalen Speicher, wird in Echtzeit aktualisiert, und jedes Deployment startet heiß.

Der Nebeneffekt auf die Ausführungsengine war signifikant. Zuvor schrieb die Ausführungsengine Cache-Schlüssel über das Netzwerk, Aktualisierungen, die Dienste von Zeit zu Zeit lasen. Da jetzt alles im Speicher lebt und der Snapshot-Dienst die Zustandsverteilung übernimmt, wurden diese Netzwerkschreibvorgänge unnötig. Wir löschten sie. Das Ergebnis: Die Blockzeiten (p99) sanken von ~2,6 ms auf ~1,2–1,8 ms, einfach weil die Ausführungsengine jetzt weit weniger schreibt:

Lighter - inline image

Blockzeiten - p99

CPU-Planung

Nachdem wir alles im Userspace optimiert hatten, griffen wir zum Kernel.

Wir haben viele Goroutinen, und sie müssen auf der CPU eingeplant werden, um zu laufen. Die Minimierung des Scheduling-Overheads ist wichtig für latenzkritische Systeme. Standardmäßig multiplexiert Gos Laufzeitumgebung Goroutinen über OS-Threads, und das OS kann Threads frei über CPU-Kerne migrieren. Beides führt zu unvorhersagbarer Latenz.

Wir eliminierten dies durch das Stapeln von vier Mechanismen:

  1. `runtime.LockOSThread()`: Sperrt die Goroutine an einen einzelnen OS-Thread und verhindert, dass Gos Scheduler sie migriert.
  2. CPU-Affinität über `sched_setaffinity`: Bindet diesen OS-Thread an einen bestimmten CPU-Kern (Linux). Dies verhindert, dass der Kernel ihn zwischen Kernen migriert, und vermeidet L1/L2-Cache-Invalidierung.
  3. Hochprioritäts-Scheduling über SCHED_FIFO: Erhöht die Scheduling-Priorität des Threads und stellt sicher, dass der Kernel ihn gegenüber anderer Arbeit bevorzugt.
  4. Busy-Wait-Spin-Loop: Der Hot Path führt ein \select\ mit einem leeren \default\-Fall aus, sodass die Goroutine nie parkt. Ohne dies verschiebt Go die Goroutine in einen "ausführbaren" Zustand, wenn keine Daten verfügbar sind, und das erneute Einplanen fügt Aufwachlatenz hinzu. Mit der Spin-Loop bleibt die Goroutine auf ihrem zugewiesenen Kern laufen und nimmt neue Aktualisierungen ohne Scheduling-Verzögerung auf.

Die Auswirkungen korrekt zu messen ist hier wichtig. Die Apply-Zeit variiert mit den Verkehrsbedingungen, daher verschieben sich absolute Zahlen mit der Last. Um den Effekt des Pinnings zu isolieren, ließen wir zwei Gruppen von API-Servern nebeneinander unter demselben Datenverkehr laufen. Eine Gruppe blieb als Kontrolle ungepinnt, und wir schalteten die andere auf CPU-Pinning um. Der prozentuale Unterschied zwischen ihnen, gemessen zur gleichen Zeit unter identischer Last, erzählt die wahre Geschichte.

Vor dem Pinning folgen beide Gruppen einander. Gleiche Basis-Latenz, gleiches Spitzenverhalten:

Lighter - inline image

Hot-Path-Apply-Zeit - Vor CPU-Planung

Nach dem Aktivieren des Pinnings in einer Gruppe liegt diese konsistent unter der ungepinnten Basislinie. Gleicher Datenverkehr, niedrigere Latenz. Die Spitzen der gepinnten Gruppe sind ebenfalls niedriger begrenzt, da Thread-Migrations-Jitter und L1/L2-Cache-Invalidierung eliminiert sind:

Lighter - inline image

Hot-Path-Apply-Zeit - Nach CPU-Planung

NUMA-bewusste Bereitstellung

CPU-Pinning allein reicht nicht, wenn die Speicherzugriffe des gepinnten Kerns NUMA-Grenzen überschreiten. Ein NUMA-Knoten ist eine Gruppe von CPUs mit eigenem lokalen Speicher. Der Zugriff auf Speicher von einem entfernten NUMA-Knoten hat eine 10-fache Strafe im Vergleich zum lokalen Zugriff.

Lighters API-Server liefen ursprünglich auf größeren Maschinen mit 2 NUMA-Knoten:

text
1$ lscpu | grep NUMA
2NUMA node(s): 2
3NUMA node0 CPU(s): 0-95
4NUMA node1 CPU(s): 96-191
5
6$ cat /sys/devices/system/node/node0/distance
710 100

Die Distanzmatrix erzählt die Geschichte: Lokaler Zugriff kostet 10, Cross-Node-Zugriff kostet 100. Eine 10-fache Strafe. Wenn N API-Server die Maschine teilen, hatte zwangsläufig ein Teil seinen gepinnten CPU auf einem NUMA-Knoten und seinen Arbeitspeicher (In-Memory-Caches, Aktualisierungspuffer) auf dem anderen. Jede Hot-Path-Iteration zahlte die Cross-Node-Steuer.

Die Lösung war kontraintuitiv: Wir verlegten die API-Server auf kleinere Maschinen mit einem einzigen NUMA-Knoten. Die halbe Spezifikation, aber alle Speicherzugriffe sind jetzt garantiert lokal:

text
1$ lscpu | grep NUMA
2NUMA node(s): 1
3NUMA node0 CPU(s): 0-95

Dies senkte die Kosten und verbesserte gleichzeitig die Latenz. Die Hot-Path-Apply-Zeit fiel weiter auf den Bereich von ~100–250 µs, mit außerhalb der Spitzenzeiten auf ~100 µs. Vergleichen Sie dies mit dem Bereich von ~200–520 µs mit alleinigem CPU-Pinning auf den Maschinen mit 2 NUMA-Knoten:

Lighter - inline image

Hot-Path-Apply-Zeit - NUMA-Optimierung

Benutzerdefinierte binäre Serialisierung

Lighters Ausführungsengine veröffentlicht Zustandsaktualisierungen über einen internen Message Bus an die API-Schicht. Jede Systemaktualisierung fließt durch diesen Pfad. Die ursprüngliche Serialisierung verwendete eine Allzweck-Kodierungsbibliothek, aber Reflection, Typ-Switches und Allokationen pro Feld erzeugten unnötigen Overhead auf einem Hot Path.

Wir ersetzten dies durch handgefertigte binäre Serialisierung: festes Layout, Null-Reflection-Encode/Decode für jeden Entitätstyp im System. Jeder Typ hat einen dedizierten Encoder/Decoder, der Felder an bekannten Byte-Offsets liest und schreibt. Keine Reflection oder Typ-Switches und minimale Allokationen. Jeder Codec hat Round-Trip- und Fuzz-Tests, um Regressionen zu erkennen.

Das Ergebnis war ein deutlich geringerer Serialisierungs-Overhead auf dem Pfad, der jeden nachgelagerten Cache speist.

Unabhängige Benchmarks sind über ein Dashboard verfügbar, das vom Community-Mitglied @UngusTrade erstellt und gepflegt wird und Live-Transaktionslatenzen über Perpetual-Handelsplätze hinweg vergleicht: latency.perps.trading

Mit einem Klick speichern

Virale Artikel mit YouMind per KI tief lesen

Save the source, ask focused questions, summarize the argument, and turn a viral article into reusable notes in one AI workspace.

Explore YouMind
Für Creator

Verwandle dein Markdown in einen sauberen 𝕏-Artikel

Wenn du eigene Langtexte veröffentlichst, wird die 𝕏-Formatierung von Bildern, Tabellen und Codeblöcken mühsam. YouMind macht aus einem ganzen Markdown-Entwurf einen sauberen, sofort postbaren 𝕏-Artikel.

Markdown zu 𝕏 testen

Mehr Muster zum Entschlüsseln

Aktuelle virale Artikel

Mehr virale Artikel entdecken