Perseguindo microssegundos: A engenharia de latência da Lighter

@Lighter_xyz
INGLÊShá 1 dia · 08 de jul. de 2026
190K
552
79
39
266

TL;DR

Uma análise técnica da jornada da Lighter rumo à latência de sub-milissegundos, abrangendo ajuste de GC, afinidade de CPU e gerenciamento de memória para escalar uma Ethereum L2 de alto desempenho.

Introdução

Ao longo de meses de iteração, o p99 de ponta a ponta da Lighter caiu de 280 ms para estáveis 55 ms. O p99 de processamento de transações foi de picos de 20–30 ms para menos de 1 ms. O tempo de aplicação do hot path está entre 100–250 µs. Tudo isso na mainnet, em escala.

Qual escala? Em 5 de junho de 2026, a Lighter atingiu um recorde histórico de 811 milhões de transações em um único dia, com média de 9.388 TPS e picos de 20.740 TPS.

O sequenciador da Lighter é escrito em Go. As transações são executadas na Lighter L2 com provas criptográficas completas liquidadas na L1. O insight arquitetônico principal é que a prova é desacoplada da execução: a geração de provas é executada de forma assíncrona e nunca bloqueia as negociações. Isso significa que a latência que os traders experimentam é determinada inteiramente pelas camadas de execução e API da Lighter.

A camada de API da Lighter é o serviço com o qual os traders interagem diretamente. Ela mantém uma visão completa e atualizada do estado da exchange na memória e atende conexões REST e WebSocket a partir desse estado local. Uma goroutine crítica recebe atualizações de estado do mecanismo de execução e as aplica em caches na memória em tempo real. Tudo downstream (WebSocket pushes, leituras de API, simulações de transações) é servido a partir desses caches.

Principais Conceitos Abordados

Os conceitos a seguir fornecem uma visão geral de alto nível dos principais tópicos abordados no restante deste artigo técnico:

Encontre antes da mainnet: Um ambiente de teste de carga idêntico com contas sintéticas e carga realista, instrumentado de forma muito mais agressiva que a produção, detecta gargalos antes que os traders os notem.

Observabilidade de ambiente duplo: O teste de carga carrega métricas caras demais para produção; a mainnet é monitorada em tempo real quanto à atualização, latência de simulação e ciclo de vida completo das transações. Juntos, eles fornecem cobertura completa.

Morte por mil alocações: Substituição sistemática de tipos pesados de heap e cópias profundas por alternativas alocadas na pilha e snapshots imutáveis. Em uma linguagem com GC, menos alocações = menos pausas = menor latência de cauda.

Controle em nível de SO: Fixação dos threads vitais em núcleos de CPU dedicados com prioridade quase em tempo real.

Implantação com consciência de hardware: O posicionamento com consciência NUMA garante que o hot path tenha acesso rápido à memória local. A camada final na pilha de latência.

Binário em vez de conveniência: Serialização binária artesanal no hot path elimina reflexão e sobrecarga de alocação. Cada microssegundo economizado se acumula em milhares de atualizações por segundo.

Encontrando Gargalos Antes que Atingissem a Mainnet

Você não pode consertar o que não pode ver. Antes de otimizar qualquer coisa, investimos pesadamente em ferramentas para identificar exatamente onde o tempo estava sendo gasto.

O Ambiente de Teste de Carga

Quando queremos testar algo, criamos um ambiente de teste de carga dedicado: uma cópia idêntica da infraestrutura da mainnet executando os serviços com configurações idênticas sob a mesma topologia de implantação. Não está sempre em execução; nós o trazemos sob demanda para um teste específico e o desmontamos depois.

Uma vez que este ambiente está ativo, criamos contas sintéticas e geramos carga de negociação realista para simular condições reais de mercado sob estresse. Isso permite:

  • Temporização extra-granular: Temporização por etapa dentro do hot path, durações de construção de cache por operação e carimbos de data/hora do ciclo de vida da transação rastreando cada estágio, desde a submissão até a confirmação.
  • Perfil sob demanda e flight recorder: Capturamos perfis de CPU, memória e rastreamento de execução sob demanda durante a carga. O flight recorder do Go nos dá coleta de rastreamento sempre ativa. Quando um evento lento é detectado, os últimos segundos de execução são capturados automaticamente, permitindo diagnosticar picos de latência transitórios após o fato.
  • Rastreamento distribuído: Instrumentamos cada função principal no hot path com spans de rastreamento, dando-nos visibilidade refinada de exatamente onde o tempo é gasto dentro de um único ciclo de atualização, através dos limites do serviço.

Monitoramento de Ambiente Duplo

Monitoramos ambos os ambientes de perto (mainnet e teste de carga), mas em diferentes níveis de granularidade.

Mainnet é monitorada rigorosamente em tempo real. Rastreamos tudo que importa para a experiência de negociação:

  • Rastreamento de atualização: Medimos a latência através do canal WebSocket \order_book\ nos mercados mais ativos. Essencialmente, a diferença entre quando uma atualização do livro de ofertas é produzida e quando um cliente a recebe. Esta é a métrica que reflete mais diretamente a experiência que os traders sentem: quão desatualizado está o livro de ofertas que você está olhando? Rastreamos isso em dois pontos. O mais importante é mecanismo de execução → cliente: o pipeline completo desde quando o mecanismo de execução processa uma mudança de estado até o momento em que a atualização \order_book\ resultante chega ao cliente. Também medimos camada de API → cliente: a camada de API carimba cada atualização de saída e o cliente a compara com seu próprio relógio, dando-nos a latência de último quilômetro isoladamente.
  • Latência de dry-run: A camada de API executa dry-run em cada transação (validando assinaturas, nonces e saldos) antes de passá-la ao mecanismo de execução. Cada dry-run é cronometrado e relatado.
  • Ciclo de vida completo da transação: Histogramas de latência de ciclo completo, da submissão à confirmação.
  • Eficácia do cache: Com que frequência servimos da memória vs. recorrer ao armazenamento mais lento.

Teste de carga é monitorado ainda mais granularmente. Como não está servindo traders reais, podemos aumentar a instrumentação sem nos preocupar com sobrecarga: temporização por etapa dentro do hot path, perfil de alocação, buckets extras de histograma em cada mutação de cache. Essa granularidade mais fina captura regressões de micronível que as métricas seguras para produção da mainnet não revelariam.

O monitoramento da mainnet nos diz como o sistema está se saindo para traders reais agora. O monitoramento do teste de carga captura regressões antes que elas cheguem à produção.

Eliminação de Cópia Profunda & Guerra de Alocação de Heap

O garbage collector do Go é um imposto de latência. Cada alocação de heap eventualmente se torna uma pausa de GC, e pausas de GC em um mecanismo de negociação são picos de latência para traders. Então, percorremos o hot path e cortamos alocações onde pudemos. O resultado foram menos picos de latência e desempenho mais previsível.

Eliminação de Cópia Profunda

Cópias profundas são fábricas de alocação. Nós as atacamos de algumas maneiras:

  • Snapshots imutáveis: Tornamos os caches do livro de ofertas imutáveis. As leituras retornam um ponteiro para o snapshot atual, sem cópia necessária. As atualizações criam uma nova versão e a trocam atomicamente via \atomic.Pointer\.
  • Cópias desnecessárias removidas: Encontramos caminhos de código que copiavam profundamente dados que nunca eram subsequentemente mutados. Removemo-los inteiramente.

Design Consciente de Alocação

  • Numéricos alocados na pilha: Substituímos \big.Int\ e \big.Rat\ pesados de heap por alternativas alocadas na pilha (\int128\, \float64\, divisão \int64\) no hot path: conversões de preço, mapas de profundidade do livro de ofertas, cálculos de tamanho. Até 8,3× mais rápido em funções-chave de formatação, zero alocações de heap por operação.
  • Atualizações condicionais: Pular alocação quando nada mudou.
  • Coleções pré-dimensionadas: Eliminar ciclos de crescimento-e-cópia dimensionando estruturas de dados antecipadamente.
  • Reutilização de buffer: O caminho do assinante desserializa milhares de atualizações por segundo. Reduzimos alocações com reutilização baseada em pool de buffers intermediários.

Isso achatou a cauda. Antes, o p99 de ponta a ponta da Lighter (mecanismo de execução para cliente, medido no canal WebSocket \order_book\) atingia picos de 200–280 ms durante períodos de alta alocação. Após a eliminação de cópia profunda, caches imutáveis e trabalho de alocação de heap, o p99 se estabilizou em uma faixa plana de ~50–60 ms com virtualmente nenhum pico:

Lighter - inline image

Latência de Ponta a Ponta: Mecanismo de Execução -> Cliente (p99)

A pressão de GC na camada de API também caiu mensuravelmente, mas isso não foi apenas uma mudança. Nós reformulamos fundamentalmente como os caches do servidor de API operam. Anteriormente, os caches tinham expiração baseada em TTL e alocavam novos objetos em cada atualização. Cada uma dessas alocações de curta duração se tornava trabalho de GC. Após a reformulação, o estado completo da exchange é iniciado a quente a partir de um snapshot e mantido na memória como estruturas imutáveis de longa duração que são trocadas atomicamente. Isso removeu a rotatividade de TTL e as alocações por atualização, e a pressão de GC caiu com elas. Monitoramos o uso de memória de perto e o conjunto de trabalho é limitado.

Executamos o apiserver de estilo antigo e o apiserver reformulado lado a lado no tráfego da mainnet. A duração da pausa de GC (p75) do servidor reformulado ficou em torno de ~3 ms, comparado a ~5–6 ms no servidor antigo. Aproximadamente metade do tempo de pausa de GC:

Lighter - inline image

Duração da Pausa de GC: Servidor de API Padrão vs. Snapshot

A frequência conta uma história ainda mais clara. O apiserver antigo acionava ciclos de GC ~2,2× mais frequentemente que o reformulado, refletindo diretamente menos alocações de curta duração e menos pressão geral de GC:

Lighter - inline image

Frequência de Ciclo de GC: Servidor de API Padrão vs. Servidor de API Snapshot

Ajuste de GOGC no Mecanismo de Execução

Aplicamos raciocínio semelhante de GC ao próprio mecanismo de execução. O parâmetro \GOGC\ do Go controla quão agressivamente o garbage collector é executado. O padrão troca tempo de CPU por eficiência de memória, mas para um caminho crítico de latência, a troca estava errada.

Após ajustar \GOGC\, a duração de GC do mecanismo de execução caiu de uma média de ~30 µs com picos atingindo 100 µs, para uma faixa estável de ~10 µs. Uma redução de ~3× com virtualmente nenhum pico:

Lighter - inline image

Duração de GC do Mecanismo de Execução Após Ajuste de GOGC

Três meses de dados da mainnet confirmam que a melhoria se manteve: o período posterior é plano e previsível.

O processamento de transações melhorou junto. Ao longo de 90 dias de dados da mainnet cobrindo todos os tipos de transação (criar ordem, cancelar, liquidar, desalavancar, transferir e mais), o p99 foi de picos frequentes de 20–30 ms antes do ajuste de GOGC para principalmente abaixo de 1 ms depois, com outliers ocasionais de 3–4 ms. A melhoria se manteve por mais de dois meses:

Lighter - inline image

Tempos de Processamento de Transações - p99 (Últimos 90 dias)

Serviço de Snapshot — Menos Dados, Implantações a Quente

Após eliminarmos a sobrecarga de alocação, atacamos o próximo gargalo: o volume de dados fluindo através do barramento de mensagens interno. Construímos um serviço de snapshot que mantém snapshots completos de estado na memória. Isso teve dois efeitos principais:

  • Menos dados no fio. Com o serviço de snapshot mantendo o estado completo, o mecanismo de execução não precisa mais enviar estado completo através do barramento de mensagens em cada atualização. Ele escreve menos dados, o que significa menos largura de banda de rede consumida e menos trabalho de desserialização no lado receptor.
  • Implantações com zero aquecimento. Na implantação, a camada de API inicializa a partir de um snapshot (contas, livros de ofertas, informações de mercado, chaves públicas de API) e está imediatamente pronta para servir. Não há período de aquecimento enquanto os caches são preenchidos. Após carregar o snapshot, a camada de API assina o fluxo de atualizações e aplica deltas em tempo real.

Além da base de snapshot, os caches na memória são atualizados continuamente:

  • Informações da conta: Leituras lock-free via \sync.Map\, trocas de ponteiro atômico para atualizações.
  • Livros de ofertas: Armazenados como snapshots imutáveis. Leituras obtêm um ponteiro, atualizações trocam por uma nova versão. Sem locks no caminho de leitura.
  • Cache de chave de API: Todas as chaves cabem na memória. Eliminamos consultas externas inteiramente.

O resultado: o estado completo da exchange reside na memória local, atualizado em tempo real, e cada implantação começa a quente.

O efeito cascata no mecanismo de execução foi significativo. Anteriormente, o mecanismo de execução estava escrevendo chaves de cache através da rede, atualizações que os serviços liam de tempos em tempos. Com tudo residindo na memória e o serviço de snapshot lidando com a distribuição de estado, essas escritas de rede se tornaram desnecessárias. Nós as deletamos. O resultado: o p99 dos tempos de bloco caiu de ~2,6 ms para ~1,2–1,8 ms, simplesmente porque o mecanismo de execução escreve muito menos agora:

Lighter - inline image

Tempos de Bloco - p99

Planejamento de CPU

Uma vez que otimizamos tudo no userspace, alcançamos o kernel.

Temos muitas goroutines e elas precisam ser escalonadas na CPU para executar. Minimizar a sobrecarga de escalonamento é importante para sistemas de baixa latência. Por padrão, o runtime do Go multiplexa goroutines através de threads do SO, e o SO pode migrar threads livremente entre núcleos de CPU. Ambos introduzem latência imprevisível.

Eliminamos isso empilhando quatro mecanismos:

  1. `runtime.LockOSThread()`: Trava a goroutine a um único thread do SO, impedindo que o escalonador do Go o migre.
  2. Afinidade de CPU via `sched_setaffinity`: Fixa esse thread do SO a um núcleo de CPU específico (Linux). Isso impede que o kernel o migre entre núcleos, evitando invalidação de cache L1/L2.
  3. Escalonamento de alta prioridade via SCHED_FIFO: Eleva a prioridade de escalonamento do thread, garantindo que o kernel o favoreça sobre outro trabalho.
  4. Loop de spin busy-wait: O hot path executa um \select\ com um caso \default\ vazio, então a goroutine nunca estaciona. Sem isso, o Go move a goroutine para um estado "executável" quando nenhum dado está disponível, e o reescalonamento adiciona latência de ativação. Com o loop de spin, a goroutine permanece em execução em seu núcleo fixado e capta novas atualizações com zero atraso de escalonamento.

Medir o impacto corretamente importa aqui. O tempo de aplicação varia com as condições de tráfego, então números absolutos mudam com a carga. Para isolar o efeito da fixação, executamos dois grupos de servidores de API lado a lado sob o mesmo tráfego. Um grupo permaneceu não fixado como controle, e mudamos o outro para fixação de CPU. A diferença percentual entre eles, medida ao mesmo tempo sob carga idêntica, conta a história real.

Antes da fixação, ambos os grupos se acompanham. Mesma latência base, mesmo comportamento de pico:

Lighter - inline image

Tempo de Aplicação do Hot Path - Antes do Planejamento de CPU

Após habilitar a fixação em um grupo, ele consistentemente fica abaixo da linha de base não fixada. Mesmo tráfego, menor latência. Os picos do grupo fixado também são limitados mais baixos, porque a instabilidade de migração de thread e a invalidação de cache L1/L2 são eliminadas:

Lighter - inline image

Tempo de Aplicação do Hot Path - Após o Planejamento de CPU

Implantação com Consciência NUMA

A fixação de CPU sozinha não é suficiente se os acessos de memória do núcleo fixado cruzarem limites NUMA. Um nó NUMA é um grupo de CPUs com sua própria memória local. Acessar memória de um nó NUMA remoto carrega uma penalidade de 10× comparada ao acesso local.

Os servidores de API da Lighter estavam originalmente rodando em máquinas maiores com 2 nós NUMA:

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

A matriz de distância conta a história: acesso local custa 10, acesso entre nós custa 100. Uma penalidade de 10×. Com N servidores de API compartilhando a máquina, alguns inevitavelmente tinham sua CPU fixada em um nó NUMA e sua memória de trabalho (caches na memória, buffers de atualização) no outro. Cada iteração do hot path estava pagando o imposto entre nós.

A correção foi contraintuitiva: movemos os servidores de API para máquinas menores com um único nó NUMA. Metade da especificação, mas todos os acessos de memória agora são garantidamente locais:

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

Isso reduziu custos e melhorou a latência ao mesmo tempo. O tempo de aplicação do hot path caiu ainda mais para a faixa de ~100–250 µs, com fora de pico chegando a ~100 µs. Compare isso com a faixa de ~200–520 µs com fixação de CPU sozinha nas máquinas de 2 nós NUMA:

Lighter - inline image

Tempo de Aplicação do Hot Path - Otimização NUMA

Serialização Binária Personalizada

O mecanismo de execução da Lighter publica atualizações de estado para a camada de API através de um barramento de mensagens interno. Cada atualização do sistema flui através deste caminho. A serialização original usava uma biblioteca de codificação de propósito geral, mas reflexão, switches de tipo e alocações por campo criavam sobrecarga desnecessária em um hot path.

Substituímos isso por serialização binária artesanal: layout fixo, codificação/decodificação sem reflexão para cada tipo de entidade no sistema. Cada tipo tem um codificador/decodificador dedicado que lê e escreve campos em offsets de byte conhecidos. Sem reflexão ou switches de tipo, e alocações mínimas. Cada codec tem testes de ida-e-volta e fuzz para capturar regressões.

O resultado foi significativamente menos sobrecarga de serialização no caminho que alimenta cada cache downstream.

Benchmarking independente está disponível através de um dashboard construído e mantido pelo membro da comunidade @UngusTrade, que compara latências de transação ao vivo entre venues de perpétuos: latency.perps.trading

Salvar com um clique

Faça leitura profunda de artigos virais com IA no YouMind

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

Explore YouMind
Para criadores

Transforme seu Markdown em um artigo 𝕏 impecável

Quando você publica seus próprios textos longos, formatar imagens, tabelas e blocos de código para o 𝕏 é uma dor de cabeça. O YouMind transforma um rascunho completo em Markdown em um artigo 𝕏 impecável e pronto para publicar.

Experimente Markdown para 𝕏

Mais padrões para decifrar

Artigos virais recentes

Explorar mais artigos virais