Introduzione
Dopo mesi di iterazioni, il p99 end-to-end di Lighter è sceso da 280 ms a un valore stabile di 55 ms. Il p99 dell'elaborazione delle transazioni è passato da picchi di 20–30 ms a meno di 1 ms. Il tempo di applicazione del percorso critico si attesta tra 100 e 250 µs. Il tutto sulla mainnet, su larga scala.
Quanta scala? Il 5 giugno 2026, Lighter ha raggiunto il massimo storico di 811 milioni di transazioni in un singolo giorno, con una media di 9.388 TPS e picchi di 20.740 TPS.
Il sequencer di Lighter è scritto in Go. Le transazioni vengono eseguite su Lighter L2 con prove crittografiche complete, consolidate su L1. L'intuizione architetturale chiave è che la prova è disaccoppiata dall'esecuzione: la generazione delle prove viene eseguita in modo asincrono e non blocca mai le negoziazioni. Ciò significa che la latenza sperimentata dai trader è determinata esclusivamente dall'esecuzione e dai layer API di Lighter.
Il layer API di Lighter è il servizio con cui i trader interagiscono direttamente. Mantiene una visione completa e aggiornata dello stato dello scambio in memoria e serve connessioni REST e WebSocket da quello stato locale. Una goroutine critica riceve gli aggiornamenti di stato dal motore di esecuzione e li applica alle cache in memoria in tempo reale. Tutto ciò che è a valle (push WebSocket, letture API, simulazioni di transazioni) viene servito da queste cache.
Concetti Chiave Trattati
I seguenti concetti forniscono una panoramica di alto livello degli argomenti chiave trattati nel resto di questo documento tecnico:
Trovalo prima della mainnet: Un ambiente di loadtest identico con conti sintetici e carico realistico, strumentato in modo molto più aggressivo della produzione, individua i colli di bottiglia prima che i trader li notino.
Osservabilità a doppio ambiente: Il loadtest porta metriche troppo costose per la produzione; la mainnet viene monitorata in tempo reale per freschezza, latenza di simulazione e ciclo di vita end-to-end delle transazioni. Insieme, forniscono una copertura completa.
Morte da mille allocazioni: Sostituzione sistematica dei tipi pesanti sull'heap e delle copie profonde con alternative allocate sullo stack e snapshot immutabili. In un linguaggio con garbage collector, meno allocazioni = meno pause = latenza di coda inferiore.
Controllo a livello di OS: Ancoraggio dei thread vitali a core CPU dedicati con priorità quasi in tempo reale.
Deployment hardware-aware: Il posizionamento NUMA-aware garantisce che il percorso critico abbia un accesso rapido alla memoria locale. L'ultimo strato nello stack della latenza.
Binario invece della comodità: La serializzazione binaria artigianale sul percorso critico elimina la riflessione e il sovraccarico di allocazione. Ogni microsecondo risparmiato si accumula su migliaia di aggiornamenti al secondo.
Individuare i Colli di Bottiglia Prima che Colpiscano la Mainnet
Non puoi risolvere ciò che non vedi. Prima di ottimizzare qualsiasi cosa, abbiamo investito molto in strumenti per identificare esattamente dove veniva speso il tempo.
L'Ambiente di Loadtest
Quando vogliamo testare qualcosa, lanciamo un ambiente di loadtest dedicato: una copia identica dell'infrastruttura della mainnet che esegue i servizi con configurazioni identiche sotto la stessa topologia di deployment. Non è sempre in esecuzione; lo avviamo su richiesta per un test specifico e lo smontiamo dopo.
Una volta che questo ambiente è attivo, creiamo conti sintetici e generiamo un carico di trading realistico per simulare le condizioni di mercato reali sotto stress. Questo permette:
- Temporizzazione extra-granulare: Tempistica per ogni passo all'interno del percorso critico, durate di costruzione della cache per operazione e timestamp del ciclo di vita delle transazioni che tracciano ogni fase, dalla presentazione alla conferma.
- Profilazione su richiesta e registratore di volo: Catturiamo profili di CPU, memoria e traccia di esecuzione su richiesta durante il carico. Il registratore di volo di Go ci fornisce una raccolta di tracce sempre attiva. Quando viene rilevato un evento lento, gli ultimi secondi di esecuzione vengono catturati automaticamente, permettendoci di diagnosticare i picchi di latenza transitori dopo il fatto.
- Tracciamento distribuito: Abbiamo strumentato ogni funzione principale nel percorso critico con span di traccia, offrendoci una visibilità granulare su esattamente dove viene speso il tempo all'interno di un singolo ciclo di aggiornamento, attraverso i confini del servizio.
Monitoraggio a Doppio Ambiente
Monitoriamo entrambi gli ambienti attentamente (mainnet e loadtest) ma a diversi livelli di granularità.
La mainnet è strettamente monitorata in tempo reale. Tracciamo tutto ciò che conta per l'esperienza di trading:
- Tracciamento della freschezza: Misuriamo la latenza attraverso il canale WebSocket \
order_book\sui mercati più attivi. Essenzialmente, lo scostamento tra quando un aggiornamento del book degli ordini viene prodotto e quando un cliente lo riceve. Questa è la metrica che riflette più direttamente l'esperienza che i trader percepiscono: quanto è obsoleto il book degli ordini che stai guardando? Lo tracciamo in due punti. Il più importante è motore di esecuzione → cliente: l'intera pipeline da quando il motore di esecuzione elabora un cambiamento di stato fino al momento in cui l'aggiornamento \order_book\risultante arriva al cliente. Misuriamo anche layer API → cliente: il layer API timestampa ogni aggiornamento in uscita e il cliente lo confronta con il proprio orologio, dandoci la latenza dell'ultimo miglio in isolamento. - Latenza di dry-run: Il layer API esegue il dry-run di ogni transazione (validando firme, nonce e saldi) prima di passarla al motore di esecuzione. Ogni dry-run viene temporizzato e segnalato.
- Ciclo di vita end-to-end delle transazioni: Istogrammi di latenza del ciclo completo dalla presentazione alla conferma.
- Efficacia della cache: Quanto spesso serviamo dalla memoria rispetto al ricadere su uno storage più lento.
Il loadtest è monitorato in modo ancora più granulare. Poiché non serve trader reali, possiamo aumentare la strumentazione senza preoccuparci del sovraccarico: temporizzazione per passo all'interno del percorso critico, profilazione delle allocazioni, bucket di istogrammi extra su ogni mutazione della cache. Questa granularità più fine cattura regressioni a micro-livello che le metriche di produzione sicure della mainnet non rivelerebbero.
Il monitoraggio della mainnet ci dice come si sta comportando il sistema per i trader reali in questo momento. Il monitoraggio del loadtest cattura le regressioni prima che raggiungano la produzione.
Eliminazione delle Copie Profonde e Guerra alle Allocazioni sull'Heap
Il garbage collector di Go è una tassa sulla latenza. Ogni allocazione sull'heap diventa eventualmente una pausa del GC, e le pause del GC su un motore di trading sono picchi di latenza per i trader. Così abbiamo esaminato il percorso critico e tagliato le allocazioni dove potevamo. Il risultato è stato meno picchi di latenza e prestazioni più prevedibili.
Eliminazione delle Copie Profonde
Le copie profonde sono fabbriche di allocazioni. Le abbiamo affrontate in alcuni modi:
- Snapshot immutabili: Abbiamo reso immutabili le cache dei book degli ordini. Le letture restituiscono un puntatore allo snapshot corrente, nessuna copia necessaria. Gli aggiornamenti creano una nuova versione e la sostituiscono atomicamente tramite \
atomic.Pointer\. - Rimosse copie non necessarie: Trovati percorsi di codice che copiavano profondamente dati che non venivano mai successivamente mutati. Li abbiamo rimossi completamente.
Design Attento alle Allocazioni
- Numerici allocati sullo stack: Sostituiti \
big.Int\e \big.Rat\pesanti sull'heap con alternative allocate sullo stack (\int128\, \float64\, divisione \int64\) attraverso il percorso critico: conversioni di prezzo, mappe di profondità del book degli ordini, calcoli di dimensione. Fino a 8,3× più veloci sulle funzioni di formattazione chiave, zero allocazioni sull'heap per operazione. - Aggiornamenti condizionali: Salta l'allocazione quando non è cambiato nulla.
- Collezioni pre-dimensionate: Elimina i cicli di crescita e copia dimensionando le strutture dati in anticipo.
- Riutilizzo dei buffer: Il percorso degli abbonati deserializza migliaia di aggiornamenti al secondo. Abbiamo ridotto le allocazioni con il riutilizzo basato su pool dei buffer intermedi.
Questo ha appiattito la coda. Prima, il p99 end-to-end di Lighter (motore di esecuzione a cliente, misurato sul canale WebSocket \order_book\) aveva picchi di 200–280 ms durante i periodi di forti allocazioni. Dopo l'eliminazione delle copie profonde, le cache immutabili e il lavoro sulle allocazioni sull'heap, il p99 si è stabilizzato su una banda piatta di ~50–60 ms praticamente senza picchi:

Latenza End-to-End: Motore di Esecuzione -> Cliente (p99)
Anche la pressione del GC sul layer API è diminuita sensibilmente, ma questo non è stato un singolo cambiamento. Abbiamo fondamentalmente rielaborato il modo in cui operano le cache del server API. In precedenza, le cache avevano una scadenza basata su TTL e allocavano nuovi oggetti a ogni aggiornamento. Ognuna di quelle allocazioni di breve durata diventava lavoro per il GC. Dopo la rielaborazione, l'intero stato dello scambio viene avviato a caldo da uno snapshot e mantenuto in memoria come strutture immutabili di lunga durata che vengono scambiate atomicamente. Questo ha rimosso il logorio del TTL e le allocazioni per aggiornamento, e la pressione del GC è diminuita di conseguenza. Monitoriamo attentamente l'utilizzo della memoria e il working set è limitato.
Abbiamo eseguito il vecchio apiserver e l'apiserver rielaborato fianco a fianco sul traffico della mainnet. La durata della pausa GC (p75) del server rielaborato si attestava intorno a ~3 ms, rispetto a ~5–6 ms sul vecchio server. Circa la metà del tempo di pausa GC:

Durata Pausa GC: Server API Standard vs. Snapshot
La frequenza racconta una storia ancora più chiara. Il vecchio apiserver attivava i cicli GC ~2,2× più spesso di quello rielaborato, riflettendo direttamente meno allocazioni di breve durata e meno pressione complessiva del GC:

Frequenza Cicli GC: Server API Standard vs. Server API Snapshot
Ottimizzazione GOGC sul Motore di Esecuzione
Abbiamo applicato un ragionamento simile sul GC al motore di esecuzione stesso. Il parametro \GOGC\ di Go controlla quanto aggressivamente viene eseguito il garbage collector. Il default scambia tempo CPU per efficienza di memoria, ma per un percorso critico per la latenza, il compromesso era sbagliato.
Dopo aver ottimizzato \GOGC\, la durata del GC del motore di esecuzione è scesa da una media di ~30 µs con picchi che raggiungevano 100 µs, fino a una banda stabile di ~10 µs. Una riduzione di ~3× praticamente senza picchi:

Durata GC del Motore di Esecuzione Dopo l'Ottimizzazione GOGC
Tre mesi di dati dalla mainnet confermano che il miglioramento è duraturo: il periodo successivo è piatto e prevedibile.
Anche l'elaborazione delle transazioni è migliorata di conseguenza. Oltre 90 giorni di dati dalla mainnet che coprono ogni tipo di transazione (crea ordine, cancella, liquida, deleveraggi, trasferisci e altro), il p99 è passato da frequenti picchi di 20–30 ms prima dell'ottimizzazione GOGC a per lo più sotto 1 ms dopo, con occasionali outlier di 3–4 ms. Il miglioramento si è mantenuto per oltre due mesi:

Tempi di Elaborazione delle Transazioni - p99 (Ultimi 90 giorni)
Servizio Snapshot — Meno Dati, Deployment a Caldo
Dopo aver eliminato il sovraccarico delle allocazioni, abbiamo affrontato il prossimo collo di bottiglia: il volume di dati che scorre attraverso il bus di messaggi interno. Abbiamo costruito un servizio snapshot che mantiene snapshot completi dello stato in memoria. Questo ha avuto due effetti principali:
- Meno dati sul filo. Con il servizio snapshot che mantiene lo stato completo, il motore di esecuzione non ha più bisogno di inviare lo stato completo attraverso il bus di messaggi a ogni aggiornamento. Scrive meno dati, il che significa meno larghezza di banda di rete consumata e meno lavoro di deserializzazione sul lato ricevente.
- Deploy a caldo senza riscaldamento. Al deploy, il layer API si avvia da uno snapshot (conti, book degli ordini, informazioni di mercato, chiavi pubbliche API) ed è immediatamente pronto a servire. Non c'è un periodo di riscaldamento mentre le cache si riempiono. Dopo aver caricato lo snapshot, il layer API si iscrive al flusso di aggiornamenti e applica i delta in tempo reale.
Oltre alla base dello snapshot, le cache in memoria vengono aggiornate continuamente:
- Informazioni conto: Letture lock-free tramite \
sync.Map\, scambi di puntatore atomico per gli aggiornamenti. - Book degli ordini: Memorizzati come snapshot immutabili. Le letture ottengono un puntatore, gli aggiornamenti scambiano una nuova versione. Nessun lock sul percorso di lettura.
- Cache delle chiavi API: Tutte le chiavi stanno in memoria. Ricerche esterne eliminate completamente.
Il risultato: l'intero stato dello scambio risiede nella memoria locale, aggiornato in tempo reale, e ogni deploy parte a caldo.
L'effetto a catena sul motore di esecuzione è stato significativo. In precedenza, il motore di esecuzione scriveva chiavi di cache attraverso la rete, aggiornamenti che i servizi leggevano di tanto in tanto. Con tutto ciò che vive in memoria e il servizio snapshot che gestisce la distribuzione dello stato, quelle scritture di rete sono diventate non necessarie. Le abbiamo eliminate. Il risultato: il p99 dei tempi di blocco è sceso da ~2,6 ms a ~1,2–1,8 ms, semplicemente perché il motore di esecuzione ora scrive molto meno:

Tempi di Blocco - p99
Pianificazione CPU
Una volta ottimizzato tutto nello spazio utente, ci siamo rivolti al kernel.
Abbiamo molte goroutine e devono essere pianificate sulla CPU per essere eseguite. Ridurre al minimo il sovraccarico di pianificazione è importante per i sistemi a bassa latenza. Per impostazione predefinita, il runtime di Go multiplexa le goroutine su thread del sistema operativo, e il sistema operativo può migrare liberamente i thread tra i core CPU. Entrambi introducono latenza imprevedibile.
Abbiamo eliminato questo problema impilando quattro meccanismi:
- `runtime.LockOSThread()`: Blocca la goroutine a un singolo thread del sistema operativo, impedendo allo scheduler di Go di migrarla.
- Affinità CPU tramite `sched_setaffinity`: Ancora quel thread del sistema operativo a un core CPU specifico (Linux). Impedisce al kernel di migrarlo tra i core, evitando l'invalidazione della cache L1/L2.
- Pianificazione ad alta priorità tramite SCHED_FIFO: Aumenta la priorità di pianificazione del thread, assicurando che il kernel lo favorisca rispetto ad altro lavoro.
- Ciclo di spin busy-wait: Il percorso critico esegue un \
select\con un caso \default\vuoto, quindi la goroutine non va mai in pausa. Senza di esso, Go sposta la goroutine in uno stato "eseguibile" quando non sono disponibili dati, e la ri-pianificazione aggiunge latenza di risveglio. Con il ciclo di spin, la goroutine rimane in esecuzione sul suo core ancorato e raccoglie nuovi aggiornamenti con zero ritardo di pianificazione.
Misurare l'impatto correttamente è importante qui. Il tempo di applicazione varia con le condizioni del traffico, quindi i numeri assoluti cambiano con il carico. Per isolare l'effetto dell'ancoraggio, abbiamo eseguito due gruppi di server API fianco a fianco sotto lo stesso traffico. Un gruppo è rimasto non ancorato come controllo, e abbiamo commutato l'altro all'ancoraggio CPU. La differenza percentuale tra di loro, misurata allo stesso momento sotto carico identico, racconta la storia reale.
Prima dell'ancoraggio, entrambi i gruppi si muovono insieme. Stessa latenza di base, stesso comportamento dei picchi:

Tempo di Applicazione del Percorso Critico - Prima della Pianificazione CPU
Dopo aver abilitato l'ancoraggio su un gruppo, esso si posiziona costantemente al di sotto della linea di base non ancorata. Stesso traffico, latenza inferiore. Anche i picchi del gruppo ancorato sono limitati più in basso, perché il jitter di migrazione dei thread e l'invalidazione della cache L1/L2 sono eliminati:

Tempo di Applicazione del Percorso Critico - Dopo la Pianificazione CPU
Deployment NUMA-Aware
L'ancoraggio CPU da solo non è sufficiente se gli accessi alla memoria del core ancorato attraversano i confini NUMA. Un nodo NUMA è un gruppo di CPU con la propria memoria locale. Accedere alla memoria da un nodo NUMA remoto comporta una penalità di 10× rispetto all'accesso locale.
I server API di Lighter erano originariamente eseguiti su macchine più grandi con 2 nodi 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 delle distanze racconta la storia: l'accesso locale costa 10, l'accesso tra nodi costa 100. Una penalità di 10×. Con N server API che condividono la macchina, alcuni avevano inevitabilmente la loro CPU ancorata su un nodo NUMA e la loro memoria di lavoro (cache in memoria, buffer di aggiornamento) sull'altro. Ogni iterazione del percorso critico stava pagando la tassa cross-nodo.
La soluzione è stata controintuitiva: abbiamo spostato i server API su macchine più piccole con un singolo nodo NUMA. Metà della specifica, ma ora tutti gli accessi alla memoria sono garantiti locali:
1$ lscpu | grep NUMA2NUMA node(s): 13NUMA node0 CPU(s): 0-95
Questo ha ridotto i costi e migliorato la latenza allo stesso tempo. Il tempo di applicazione del percorso critico è sceso ulteriormente nell'intervallo ~100–250 µs, con periodi non di punta che scendono a ~100 µs. Confrontalo con l'intervallo ~200–520 µs con il solo ancoraggio CPU sulle macchine a 2 nodi NUMA:

Tempo di Applicazione del Percorso Critico - Ottimizzazione NUMA
Serializzazione Binaria Personalizzata
Il motore di esecuzione di Lighter pubblica gli aggiornamenti di stato al layer API attraverso un bus di messaggi interno. Ogni aggiornamento di sistema scorre attraverso questo percorso. La serializzazione originale utilizzava una libreria di codifica per scopi generali, ma la riflessione, gli switch di tipo e le allocazioni per campo creavano un sovraccarico non necessario su un percorso critico.
L'abbiamo sostituita con una serializzazione binaria artigianale: codifica/decodifica a layout fisso e zero riflessione per ogni tipo di entità nel sistema. Ogni tipo ha un codificatore/decodificatore dedicato che legge e scrive i campi a offset di byte noti. Nessuna riflessione o switch di tipo, e allocazioni minime. Ogni codec ha test di round-trip e fuzz per catturare le regressioni.
Il risultato è stato un sovraccarico di serializzazione significativamente inferiore sul percorso che alimenta ogni cache a valle.
Benchmark indipendenti sono disponibili attraverso una dashboard costruita e mantenuta dal membro della community @UngusTrade, che confronta le latenze delle transazioni in tempo reale tra venue di perpetual trading: latency.perps.trading





