Introducción
A lo largo de meses de iteración, el p99 de extremo a extremo de Lighter pasó de 280 ms a unos estables 55 ms. El p99 de procesamiento de transacciones pasó de picos de 20–30 ms a menos de 1 ms. El tiempo de aplicación de la ruta crítica se sitúa entre 100 y 250 µs. Todo en mainnet, a escala.
¿Cuánta escala? El 5 de junio de 2026, Lighter alcanzó un máximo histórico de 811 millones de transacciones en un solo día, con un promedio de 9,388 TPS y picos de 20,740 TPS.
El secuenciador de Lighter está escrito en Go. Las transacciones se ejecutan en Lighter L2 con pruebas criptográficas completas liquidadas en L1. La idea arquitectónica clave es que la demostración está desacoplada de la ejecución: la generación de pruebas se ejecuta de forma asíncrona y nunca bloquea las operaciones. Esto significa que la latencia que experimentan los traders está determinada únicamente por las capas de ejecución y API de Lighter.
La capa de API de Lighter es el servicio con el que los traders interactúan directamente. Mantiene una vista completa y actualizada del estado del exchange en memoria y sirve conexiones REST y WebSocket desde ese estado local. Una goroutine crítica recibe actualizaciones de estado del motor de ejecución y las aplica a las cachés en memoria en tiempo real. Todo lo que está aguas abajo (envíos WebSocket, lecturas de API, simulaciones de transacciones) se sirve desde estas cachés.
Conceptos Clave Cubiertos
Los siguientes conceptos proporcionan una visión general de alto nivel de los temas clave cubiertos en el resto de este informe técnico:
Encuéntralo antes de mainnet: Un entorno de prueba de carga idéntico con cuentas sintéticas y carga realista, instrumentado mucho más agresivamente que la producción, detecta cuellos de botella antes de que los traders los noten.
Observabilidad de doble entorno: La prueba de carga lleva métricas demasiado costosas para producción; mainnet se monitorea en tiempo real para frescura, latencia de simulación y ciclo de vida completo de transacciones. Juntos, brindan una cobertura completa.
Muerte por mil asignaciones: Reemplazo sistemático de tipos pesados en heap y copias profundas con alternativas asignadas en la pila e instantáneas inmutables. En un lenguaje con GC, menos asignaciones = menos pausas = menor latencia de cola.
Control a nivel de SO: Fijar los hilos vitales a núcleos de CPU dedicados con prioridad casi en tiempo real.
Despliegue consciente del hardware: La colocación consciente de NUMA asegura que la ruta crítica tenga acceso rápido a memoria local. La capa final en la pila de latencia.
Binario sobre conveniencia: Serialización binaria hecha a mano en la ruta crítica elimina la reflexión y la sobrecarga de asignación. Cada microsegundo ahorrado se acumula en miles de actualizaciones por segundo.
Encontrar Cuellos de Botella Antes de que Lleguen a Mainnet
No puedes arreglar lo que no puedes ver. Antes de optimizar cualquier cosa, invertimos fuertemente en herramientas para identificar exactamente dónde se estaba gastando el tiempo.
El Entorno de Prueba de Carga
Cuando queremos probar algo, levantamos un entorno de prueba de carga dedicado: una copia idéntica de la infraestructura de mainnet ejecutando los servicios con configuraciones idénticas bajo la misma topología de despliegue. No siempre está funcionando; lo levantamos bajo demanda para una prueba específica y lo derribamos después.
Una vez que este entorno está activo, creamos cuentas sintéticas y generamos carga de trading realista para simular condiciones de mercado reales bajo estrés. Esto permite:
- Temporización extra-granular: Temporización por paso dentro de la ruta crítica, duraciones de construcción de caché por operación y marcas de tiempo del ciclo de vida de la transacción rastreando cada etapa desde el envío hasta la confirmación.
- Perfilado bajo demanda y grabador de vuelo: Capturamos perfiles de CPU, memoria y traza de ejecución a solicitud durante la carga. El grabador de vuelo de Go nos da recolección de trazas siempre activa. Cuando se detecta un evento lento, los últimos segundos de ejecución se capturan automáticamente, permitiéndonos diagnosticar picos de latencia transitorios después del hecho.
- Trazado distribuido: Instrumentamos cada función importante en la ruta crítica con spans de traza, dándonos visibilidad detallada de exactamente dónde se gasta el tiempo dentro de un solo ciclo de actualización, a través de los límites del servicio.
Monitoreo de Doble Entorno
Monitoreamos ambos entornos de cerca (mainnet y prueba de carga) pero en diferentes niveles de granularidad.
Mainnet se monitorea estrechamente en tiempo real. Rastreamos todo lo que importa para la experiencia de trading:
- Seguimiento de frescura: Medimos la latencia a través del canal WebSocket
order_booken los mercados más activos. Esencialmente, la deriva entre cuándo se produce una actualización del libro de órdenes y cuándo un cliente la recibe. Esta es la métrica que refleja más directamente la experiencia que sienten los traders: ¿qué tan desactualizado está el libro de órdenes que estás viendo? Rastreamos esto en dos puntos. El más importante es motor de ejecución → cliente: el pipeline completo desde que el motor de ejecución procesa un cambio de estado hasta que la actualizaciónorder_bookresultante llega al cliente. También medimos capa de API → cliente: la capa de API marca la hora de cada actualización saliente y el cliente la compara con su propio reloj, dándonos la latencia de última milla de forma aislada. - Latencia de simulación: La capa de API simula cada transacción (validando firmas, nonces y saldos) antes de pasarla al motor de ejecución. Cada simulación se cronometra y reporta.
- Ciclo de vida completo de la transacción: Histogramas de latencia de ciclo completo desde el envío hasta la confirmación.
- Efectividad de la caché: Con qué frecuencia servimos desde memoria vs. recurrir a almacenamiento más lento.
Prueba de carga se monitorea aún más granularmente. Como no está sirviendo a traders reales, podemos aumentar la instrumentación sin preocuparnos por la sobrecarga: temporización por paso dentro de la ruta crítica, perfilado de asignaciones, cubos de histograma extra en cada mutación de caché. Esta granularidad más fina detecta regresiones a nivel micro que las métricas seguras para producción de mainnet no revelarían.
El monitoreo de mainnet nos dice cómo está funcionando el sistema para los traders reales ahora mismo. El monitoreo de prueba de carga detecta regresiones antes de que lleguen a producción.
Eliminación de Copias Profundas y Guerra de Asignaciones en Heap
El recolector de basura de Go es un impuesto a la latencia. Cada asignación en heap eventualmente se convierte en una pausa de GC, y las pausas de GC en un motor de trading son picos de latencia para los traders. Así que recorrimos la ruta crítica y redujimos las asignaciones donde pudimos. El resultado fue menos picos de latencia y un rendimiento más predecible.
Eliminación de Copias Profundas
Las copias profundas son fábricas de asignaciones. Las atacamos de varias maneras:
- Instantáneas inmutables: Hicimos que las cachés del libro de órdenes fueran inmutables. Las lecturas devuelven un puntero a la instantánea actual, sin necesidad de copia. Las actualizaciones crean una nueva versión y la intercambian atómicamente mediante
atomic.Pointer. - Copias innecesarias eliminadas: Encontramos rutas de código que copiaban profundamente datos que nunca se mutaban posteriormente. Las eliminamos por completo.
Diseño Consciente de Asignaciones
- Numéricos asignados en la pila: Reemplazamos
big.Intybig.Rat(pesados en heap) con alternativas asignadas en la pila (int128,float64, divisiónint64) en toda la ruta crítica: conversiones de precios, mapas de profundidad del libro de órdenes, cálculos de tamaño. Hasta 8.3× más rápido en funciones clave de formato, cero asignaciones en heap por operación. - Actualizaciones condicionales: Omitir asignación cuando nada ha cambiado.
- Colecciones pre-dimensionadas: Eliminar ciclos de crecimiento y copia dimensionando las estructuras de datos de antemano.
- Reutilización de buffers: La ruta del suscriptor deserializa miles de actualizaciones por segundo. Redujimos las asignaciones con reutilización basada en pool de buffers intermedios.
Esto aplanó la cola. Antes, el p99 de extremo a extremo de Lighter (motor de ejecución a cliente, medido en el canal WebSocket order_book) se disparaba a 200–280 ms durante períodos de muchas asignaciones. Después de la eliminación de copias profundas, las cachés inmutables y el trabajo de asignaciones en heap, el p99 se estabilizó en una banda plana de ~50–60 ms sin prácticamente picos:

Latencia de Extremo a Extremo: Motor de Ejecución -> Cliente (p99)
La presión del GC en la capa de API también disminuyó notablemente, pero esto no fue solo un cambio. Rediseñamos fundamentalmente cómo operan las cachés del servidor de API. Anteriormente, las cachés tenían caducidad basada en TTL y asignaban nuevos objetos en cada actualización. Cada una de esas asignaciones de corta duración se convertía en trabajo de GC. Después del rediseño, el estado completo del exchange se inicia en caliente desde una instantánea y se mantiene en memoria como estructuras inmutables de larga duración que se intercambian atómicamente. Eso eliminó la rotación de TTL y las asignaciones por actualización, y la presión del GC disminuyó con ellas. Monitoreamos el uso de memoria de cerca y el conjunto de trabajo está acotado.
Ejecutamos el servidor de API antiguo y el rediseñado lado a lado en tráfico de mainnet. La duración de la pausa de GC (p75) del servidor rediseñado rondaba ~3 ms, en comparación con ~5–6 ms en el servidor antiguo. Aproximadamente la mitad del tiempo de pausa de GC:

Duración de Pausa de GC: Servidor API Estándar vs. Instantánea
La frecuencia cuenta una historia aún más clara. El servidor API antiguo activaba ciclos de GC ~2.2× más a menudo que el rediseñado, reflejando directamente menos asignaciones de corta duración y menos presión general de GC:

Frecuencia de Ciclos de GC: Servidor API Estándar vs. Servidor API de Instantánea
Ajuste de GOGC en el Motor de Ejecución
Aplicamos un pensamiento similar sobre GC al propio motor de ejecución. El parámetro GOGC de Go controla cuán agresivamente se ejecuta el recolector de basura. El valor predeterminado intercambia tiempo de CPU por eficiencia de memoria, pero para una ruta crítica de latencia, la compensación era incorrecta.
Después de ajustar GOGC, la duración del GC del motor de ejecución cayó de un promedio de ~30 µs con picos que alcanzaban 100 µs, a una banda estable de ~10 µs. Una reducción de ~3× sin prácticamente picos:

Duración de GC del Motor de Ejecución Después del Ajuste de GOGC
Tres meses de datos de mainnet confirman que la mejora se mantuvo: el período posterior es plano y predecible.
El procesamiento de transacciones mejoró junto con ello. En 90 días de datos de mainnet cubriendo cada tipo de transacción (crear orden, cancelar, liquidar, desapalancar, transferir y más), el p99 pasó de frecuentes picos de 20–30 ms antes del ajuste de GOGC a principalmente por debajo de 1 ms después, con valores atípicos ocasionales de 3–4 ms. La mejora se ha mantenido durante más de dos meses:

Tiempos de Procesamiento de Transacciones - p99 (Últimos 90 días)
Servicio de Instantáneas — Menos Datos, Despliegues en Caliente
Después de eliminar la sobrecarga de asignaciones, abordamos el siguiente cuello de botella: el volumen de datos que fluye a través del bus de mensajes interno. Construimos un servicio de instantáneas que mantiene instantáneas completas del estado en memoria. Esto tuvo dos efectos principales:
- Menos datos en el cable. Con el servicio de instantáneas manteniendo el estado completo, el motor de ejecución ya no necesita enviar el estado completo a través del bus de mensajes en cada actualización. Escribe menos datos, lo que significa menos ancho de banda de red consumido y menos trabajo de deserialización en el extremo receptor.
- Despliegues sin calentamiento. Al desplegar, la capa de API arranca desde una instantánea (cuentas, libros de órdenes, información de mercado, claves públicas de API) y está inmediatamente lista para servir. No hay período de calentamiento mientras las cachés se llenan. Después de cargar la instantánea, la capa de API se suscribe al flujo de actualizaciones y aplica los deltas en tiempo real.
Además de la base de instantáneas, las cachés en memoria se actualizan continuamente:
- Información de cuenta: Lecturas sin bloqueo mediante
sync.Map, intercambios atómicos de puntero para actualizaciones. - Libros de órdenes: Almacenados como instantáneas inmutables. Las lecturas obtienen un puntero, las actualizaciones intercambian una nueva versión. Sin bloqueos en la ruta de lectura.
- Caché de claves de API: Todas las claves caben en memoria. Eliminamos las búsquedas externas por completo.
El resultado: todo el estado del exchange vive en memoria local, actualizado en tiempo real, y cada despliegue comienza en caliente.
El efecto dominó en el motor de ejecución fue significativo. Anteriormente, el motor de ejecución escribía claves de caché a través de la red, actualizaciones que los servicios leían de vez en cuando. Con todo viviendo en memoria y el servicio de instantáneas manejando la distribución del estado, esas escrituras de red se volvieron innecesarias. Las eliminamos. El resultado: los tiempos de bloque p99 cayeron de ~2.6 ms a ~1.2–1.8 ms, simplemente porque el motor de ejecución escribe mucho menos ahora:

Tiempos de Bloque - p99
Planificación de CPU
Una vez que optimizamos todo en el espacio de usuario, llegamos al kernel.
Tenemos muchas goroutines y necesitan ser programadas en la CPU para ejecutarse. Minimizar la sobrecarga de planificación es importante para sistemas de baja latencia. Por defecto, el runtime de Go multiplexa las goroutines a través de hilos del SO, y el SO puede migrar hilos libremente entre núcleos de CPU. Ambos introducen latencia impredecible.
Eliminamos esto apilando cuatro mecanismos:
- `runtime.LockOSThread()`: Bloquea la goroutine a un solo hilo del SO, evitando que el planificador de Go la migre.
- Afinidad de CPU mediante `sched_setaffinity`: Fija ese hilo del SO a un núcleo de CPU específico (Linux). Esto evita que el kernel lo migre entre núcleos, evitando la invalidación de la caché L1/L2.
- Planificación de alta prioridad mediante SCHED_FIFO: Eleva la prioridad de planificación del hilo, asegurando que el kernel lo favorezca sobre otro trabajo.
- Bucle de espera activa (spin loop): La ruta crítica ejecuta un
selectcon un casodefaultvacío, por lo que la goroutine nunca se estaciona. Sin esto, Go mueve la goroutine a un estado "ejecutable" cuando no hay datos disponibles, y la re-planificación añade latencia de activación. Con el bucle de espera activa, la goroutine permanece ejecutándose en su núcleo fijado y recoge nuevas actualizaciones con cero demora de planificación.
Medir el impacto correctamente es importante aquí. El tiempo de aplicación varía con las condiciones del tráfico, por lo que los números absolutos cambian con la carga. Para aislar el efecto de la fijación, ejecutamos dos grupos de servidores de API lado a lado bajo el mismo tráfico. Un grupo permaneció sin fijar como control, y cambiamos el otro a fijación de CPU. La diferencia porcentual entre ellos, medida al mismo tiempo bajo carga idéntica, cuenta la historia real.
Antes de la fijación, ambos grupos se mueven juntos. Misma latencia base, mismo comportamiento de picos:

Tiempo de Aplicación de Ruta Crítica - Antes de la Planificación de CPU
Después de habilitar la fijación en un grupo, este se sitúa consistentemente por debajo de la línea base sin fijar. Mismo tráfico, menor latencia. Los picos del grupo fijado también están limitados más abajo, porque la fluctuación por migración de hilos y la invalidación de caché L1/L2 se eliminan:

Tiempo de Aplicación de Ruta Crítica - Después de la Planificación de CPU
Despliegue Consciente de NUMA
La fijación de CPU por sí sola no es suficiente si los accesos a memoria del núcleo fijado cruzan los límites de NUMA. Un nodo NUMA es un grupo de CPUs con su propia memoria local. Acceder a memoria desde un nodo NUMA remoto conlleva una penalización de 10× en comparación con el acceso local.
Los servidores de API de Lighter se ejecutaban originalmente en máquinas más grandes con 2 nodos 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 matriz de distancias cuenta la historia: el acceso local cuesta 10, el acceso entre nodos cuesta 100. Una penalización de 10×. Con N servidores de API compartiendo la máquina, algunos inevitablemente tenían su CPU fijada en un nodo NUMA y su memoria de trabajo (cachés en memoria, buffers de actualización) en el otro. Cada iteración de la ruta crítica estaba pagando el impuesto entre nodos.
La solución fue contraintuitiva: movimos los servidores de API a máquinas más pequeñas con un solo nodo NUMA. La mitad de las especificaciones, pero ahora todos los accesos a memoria están garantizados como locales:
1$ lscpu | grep NUMA2NUMA node(s): 13NUMA node0 CPU(s): 0-95
Esto redujo costos y mejoró la latencia al mismo tiempo. El tiempo de aplicación de la ruta crítica cayó aún más al rango de ~100–250 µs, con períodos de baja actividad bajando a ~100 µs. Compárese con el rango de ~200–520 µs con solo fijación de CPU en las máquinas de 2 nodos NUMA:

Tiempo de Aplicación de Ruta Crítica - Optimización NUMA
Serialización Binaria Personalizada
El motor de ejecución de Lighter publica actualizaciones de estado a la capa de API a través de un bus de mensajes interno. Cada actualización del sistema fluye a través de esta ruta. La serialización original usaba una biblioteca de codificación de propósito general, pero la reflexión, los cambios de tipo y las asignaciones por campo creaban una sobrecarga innecesaria en una ruta crítica.
Reemplazamos eso con serialización binaria hecha a mano: diseño de diseño fijo, codificación/decodificación sin reflexión para cada tipo de entidad en el sistema. Cada tipo tiene un codificador/decodificador dedicado que lee y escribe campos en desplazamientos de bytes conocidos. Sin reflexión ni cambios de tipo, y asignaciones mínimas. Cada códec tiene pruebas de ida y vuelta y pruebas difusas para detectar regresiones.
El resultado fue significativamente menos sobrecarga de serialización en la ruta que alimenta cada caché aguas abajo.
Hay evaluaciones comparativas independientes disponibles a través de un panel construido y mantenido por el miembro de la comunidad @UngusTrade, que compara las latencias de transacciones en vivo entre venues de trading perpetuo: latency.perps.trading





