마이크로초를 쫓아서: Lighter의 지연 시간 엔지니어링

@Lighter_xyz
영어1일 전 · 2026년 7월 08일
190K
552
79
39
266

TL;DR

고성능 Ethereum L2 확장을 위해 GC 튜닝, CPU 어피니티, 메모리 관리 등을 다루며 1밀리초 미만의 지연 시간을 달성한 Lighter의 기술적 여정을 분석합니다.

소개

수개월의 반복 작업을 통해 Lighter 의 end-to-end p99 가 280 ms 에서 55 ms 로 안정화되었습니다. 트랜잭션 처리 p99 는 20–30 ms 의 스파이크에서 1 ms 미만으로 줄었습니다. Hot path 적용 시간은 100–250 µs 입니다. 모두 메인넷에서 대규모로 이루어졌습니다.

얼마나 큰 규모인가요? 2026 년 6 월 5 일, Lighter 는 하루에 8 억 1,100 만 건의 트랜잭션을 처리하며 역대 최고치를 기록했으며, 평균 9,388 TPS, 최고 20,740 TPS 를 기록했습니다.

Lighter 의 시퀀서는 Go 로 작성되었습니다. 트랜잭션은 Lighter L2 에서 실행되며, 완전한 암호학적 증명이 L1 에 정산됩니다. 핵심 아키텍처 통찰은 증명이 실행과 분리되어 있다는 것입니다. 증명 생성은 비동기적으로 실행되며 거래를 차단하지 않습니다. 즉, 트레이더가 경험하는 지연 시간은 전적으로 Lighter 의 실행 및 API 레이어에 의해 결정됩니다.

Lighter 의 API 레이어는 트레이더가 직접 상호작용하는 서비스입니다. 메모리에 거래소 상태의 완전하고 최신의 뷰를 유지하며, 해당 로컬 상태에서 REST 및 WebSocket 연결을 제공합니다. 중요한 고루틴이 실행 엔진으로부터 상태 업데이트를 수신하여 실시간으로 인메모리 캐시에 적용합니다. 모든 다운스트림 (WebSocket 푸시, API 읽기, 트랜잭션 시뮬레이션)은 이 캐시에서 제공됩니다.

다루는 주요 개념

다음 개념들은 이 기술 문서의 나머지 부분에서 다루는 주요 주제에 대한 개괄적인 개요를 제공합니다.

메인넷 이전에 찾아내기: 합성 계정과 현실적인 부하를 사용하는 동일한 부하 테스트 환경으로, 프로덕션보다 훨씬 공격적으로 계측되어 트레이더가 알아채기 전에 병목 지점을 잡아냅니다.

이중 환경 관찰 가능성: 부하 테스트는 프로덕션에 비용이 너무 많이 드는 메트릭을 제공합니다. 메인넷은 신선도, 시뮬레이션 지연 시간, end-to-end 트랜잭션 라이프사이클에 대해 실시간으로 모니터링됩니다. 함께 사용하면 완전한 커버리지를 제공합니다.

수천 개의 할당에 의한 죽음: 힙-헤비 타입과 딥 카피를 스택 할당 대안 및 불변 스냅샷으로 체계적으로 대체합니다. GC 언어에서 더 적은 할당 = 더 적은 일시 중지 = 더 낮은 꼬리 지연 시간.

OS 수준 제어: 중요한 스레드를 전용 CPU 코어에 고정하고 거의 실시간 우선순위를 부여합니다.

하드웨어 인식 배포: NUMA 인식 배치는 hot path 가 빠른 로컬 메모리 액세스를 갖도록 보장합니다. 지연 시간 스택의 최종 레이어입니다.

편의성보다 바이너리: Hot path 에서 수제 바이너리 직렬화를 사용하여 리플렉션과 할당 오버헤드를 제거합니다. 절약된 마이크로초마다 초당 수천 개의 업데이트에 걸쳐 누적됩니다.

메인넷에 도달하기 전에 병목 지점 찾기

볼 수 없는 것은 고칠 수 없습니다. 최적화를 시작하기 전에, 시간이 어디에 소비되는지 정확히 파악하기 위해 도구에 많은 투자를 했습니다.

부하 테스트 환경

무언가를 테스트하고 싶을 때, 전용 부하 테스트 환경을 가동합니다. 동일한 배포 토폴로지에서 동일한 구성으로 서비스를 실행하는 메인넷 인프라의 복사본입니다. 항상 실행되는 것은 아니며, 특정 테스트를 위해 필요할 때 가져오고 테스트 후에 종료합니다.

이 환경이 가동되면 합성 계정을 생성하고 현실적인 거래 부하를 생성하여 스트레스 상황에서 실제 시장 조건을 시뮬레이션합니다. 이를 통해 다음이 가능합니다:

  • 초세분화된 타이밍: Hot path 내의 단계별 타이밍, 작업별 캐시 구축 기간, 제출부터 확인까지의 모든 단계를 추적하는 트랜잭션 라이프사이클 타임스탬프.
  • 온디맨드 프로파일링 및 비행 기록기: 부하 중에 요청 시 CPU, 메모리 및 실행 추적 프로파일을 캡처합니다. Go 의 비행 기록기는 항상 켜져 있는 추적 수집을 제공합니다. 느린 이벤트가 감지되면 마지막 몇 초의 실행이 자동으로 캡처되어 일시적인 지연 시간 스파이크를 사후에 진단할 수 있습니다.
  • 분산 추적: Hot path 의 모든 주요 함수에 추적 스팬을 계측하여 단일 업데이트 주기 내에서 서비스 경계를 넘어 시간이 정확히 어디에 소비되는지 세밀하게 파악할 수 있습니다.

이중 환경 모니터링

두 환경 (메인넷 및 부하 테스트)을 면밀히 모니터링하지만, 세분화 수준은 다릅니다.

메인넷은 실시간으로 긴밀하게 모니터링됩니다. 거래 경험에 중요한 모든 것을 추적합니다:

  • 신선도 추적: 가장 활발한 시장에서 order_book WebSocket 채널을 통한 지연 시간을 측정합니다. 본질적으로, 오더북 업데이트가 생성된 시점과 클라이언트가 수신하는 시점 사이의 차이입니다. 이는 트레이더가 느끼는 경험을 가장 직접적으로 반영하는 메트릭입니다: 보고 있는 오더북이 얼마나 오래되었는가? 우리는 이를 두 지점에서 추적합니다. 가장 중요한 것은 실행 엔진 → 클라이언트입니다. 실행 엔진이 상태 변경을 처리하는 순간부터 결과 order_book 업데이트가 클라이언트에 도달하는 순간까지의 전체 파이프라인입니다. 또한 API 레이어 → 클라이언트도 측정합니다. API 레이어는 각 발신 업데이트에 타임스탬프를 찍고 클라이언트는 자체 시계와 비교하여 마일 지연 시간을 독립적으로 제공합니다.
  • 드라이런 지연 시간: API 레이어는 각 트랜잭션을 실행 엔진에 전달하기 전에 드라이런 (서명, 논스, 잔액 검증)을 수행합니다. 모든 드라이런은 시간이 측정되고 보고됩니다.
  • End-to-end 트랜잭션 라이프사이클: 제출부터 확인까지의 전체 주기 지연 시간 히스토그램.
  • 캐시 효율성: 메모리에서 제공하는 빈도와 느린 스토리지로 폴백하는 빈도.

부하 테스트는 더욱 세분화되어 모니터링됩니다. 실제 트레이더를 대상으로 하지 않기 때문에 오버헤드를 걱정하지 않고 계측을 강화할 수 있습니다: hot path 내의 단계별 타이밍, 할당 프로파일링, 모든 캐시 변경에 대한 추가 히스토그램 버킷. 이러한 더 미세한 세분화는 메인넷의 프로덕션-안전 메트릭으로는 드러나지 않는 미세 수준의 회귀를 잡아냅니다.

메인넷 모니터링은 시스템이 실제 트레이더를 위해 지금 당장 어떻게 성능을 발휘하는지 알려줍니다. 부하 테스트 모니터링은 회귀를 프로덕션에 도달하기 전에 잡아냅니다.

딥 카피 제거 및 힙 할당 전쟁

Go 의 가비지 컬렉터는 지연 시간에 대한 세금입니다. 모든 힙 할당은 결국 GC 일시 중지가 되고, 거래 엔진에서의 GC 일시 중지는 트레이더에게 지연 시간 스파이크가 됩니다. 그래서 우리는 hot path 를 살펴보고 가능한 모든 곳에서 할당을 줄였습니다. 그 결과 지연 시간 스파이크가 줄어들고 더 예측 가능한 성능을 얻었습니다.

딥 카피 제거

딥 카피는 할당 공장입니다. 몇 가지 방법으로 이를 해결했습니다:

  • 불변 스냅샷: 오더북 캐시를 불변으로 만들었습니다. 읽기는 현재 스냅샷에 대한 포인터를 반환하며 복사가 필요하지 않습니다. 업데이트는 새 버전을 생성하고 atomic.Pointer를 통해 원자적으로 교체합니다.
  • 불필요한 복사 제거: 이후에 변경되지 않는 데이터를 딥 카피하는 코드 경로를 발견했습니다. 이를 완전히 제거했습니다.

할당 인식 설계

  • 스택 할당 숫자: 힙-헤비 big.Intbig.Rat을 스택 할당 대안 (int128, float64, int64 나눗셈)으로 hot path 전반에 걸쳐 교체했습니다: 가격 변환, 오더북 깊이 맵, 크기 계산. 주요 포맷팅 함수에서 최대 8.3 배 더 빠르며, 작업당 힙 할당이 0입니다.
  • 조건부 업데이트: 변경된 사항이 없으면 할당을 건너뜁니다.
  • 사전 크기 조정된 컬렉션: 데이터 구조를 미리 크기 조정하여 증가-복사 주기를 제거합니다.
  • 버퍼 재사용: 구독자 경로는 초당 수천 개의 업데이트를 역직렬화합니다. 풀 기반의 중간 버퍼 재사용을 통해 할당을 줄였습니다.

이로 인해 꼬리가 평탄해졌습니다. 이전에는 Lighter 의 end-to-end p99 (실행 엔진에서 클라이언트까지, order_book WebSocket 채널에서 측정)가 할당이 많은 기간 동안 200–280 ms 로 스파이크가 발생했습니다. 딥 카피 제거, 불변 캐시 및 힙 할당 작업 이후 p99 는 약 50–60 ms 의 안정적인 대역으로 스파이크가 거의 없어졌습니다:

Lighter - inline image

End-to-End Latency: Execution Engine -> Client (p99)

API 레이어의 GC 압력도 눈에 띄게 감소했지만, 이는 단일 변경만으로 이루어진 것이 아닙니다. API 서버 캐시의 작동 방식을 근본적으로 재설계했습니다. 이전에는 캐시에 TTL 기반 만료가 있었고 모든 업데이트마다 새 객체를 할당했습니다. 이러한 단기 할당 각각이 GC 작업이 되었습니다. 재설계 후, 전체 거래소 상태는 스냅샷에서 핫 스타트되고 메모리에 장기간 유지되는 불변 구조로 유지되며 원자적으로 교체됩니다. 이로 인해 TTL 변동과 업데이트별 할당이 제거되었고 GC 압력도 함께 감소했습니다. 우리는 메모리 사용량을 면밀히 모니터링하며 작업 세트는 제한되어 있습니다.

기존 스타일의 API 서버와 재설계된 API 서버를 메인넷 트래픽에서 나란히 실행했습니다. 재설계된 서버의 GC 일시 중지 시간 (p75)은 약 3 ms 였고, 기존 서버는 약 5–6 ms 였습니다. GC 일시 중지 시간이 약 절반으로 줄었습니다:

Lighter - inline image

GC Pause Duration: Standard vs. Snapshot API Server

빈도는 더욱 명확한 이야기를 보여줍니다. 기존 API 서버는 재설계된 서버보다 약 2.2 배 더 자주 GC 주기를 트리거했으며, 이는 더 적은 단기 할당과 전반적으로 더 낮은 GC 압력을 직접적으로 반영합니다:

Lighter - inline image

GC Cycle Frequency: Standard API Server vs. Snapshot API Server

실행 엔진의 GOGC 튜닝

실행 엔진 자체에도 유사한 GC 사고를 적용했습니다. Go 의 GOGC 매개변수는 가비지 컬렉터가 얼마나 공격적으로 실행되는지 제어합니다. 기본값은 CPU 시간을 메모리 효율성과 교환하지만, 지연 시간에 민감한 경로에서는 그 트레이드오프가 잘못되었습니다.

GOGC를 튜닝한 후, 실행 엔진의 GC 시간은 평균 약 30 µs (스파이크가 100 µs 에 도달)에서 안정적인 약 10 µs 대역으로 감소했습니다. 약 3 배 감소이며 스파이크가 거의 없습니다:

Lighter - inline image

Execution Engine GC Duration After GOGC Tuning

3 개월의 메인넷 데이터는 개선이 유지되었음을 확인해줍니다: 이후 기간은 평탄하고 예측 가능합니다.

트랜잭션 처리도 함께 개선되었습니다. 모든 트랜잭션 유형 (주문 생성, 취소, 청산, 레버리지 감소, 전송 등)을 포함한 90 일간의 메인넷 데이터에서 p99 는 GOGC 튜닝 전에는 빈번한 20–30 ms 스파이크에서 튜닝 후에는 대부분 1 ms 미만으로, 가끔 3–4 ms 의 이상치가 발생했습니다. 이러한 개선은 2 개월 이상 유지되었습니다:

Lighter - inline image

Transaction Processing Times - p99 (Last 90 days)

스냅샷 서비스 — 더 적은 데이터, 핫 배포

할당 오버헤드를 제거한 후, 다음 병목 지점인 내부 메시지 버스를 통해 흐르는 데이터의 양을 해결했습니다. 전체 인메모리 상태 스냅샷을 유지하는 스냅샷 서비스를 구축했습니다. 이로 인해 두 가지 주요 효과가 있었습니다:

  • 와이어 상의 데이터 감소. 스냅샷 서비스가 전체 상태를 보유함에 따라 실행 엔진은 더 이상 모든 업데이트마다 전체 상태를 메시지 버스로 푸시할 필요가 없습니다. 더 적은 데이터를 쓰므로 네트워크 대역폭 소비가 줄어들고 수신 측의 역직렬화 작업도 줄어듭니다.
  • 제로 웜업 배포. 배포 시 API 레이어는 스냅샷 (계정, 오더북, 시장 정보, API 공개 키)에서 부팅되며 즉시 서비스를 제공할 준비가 됩니다. 캐시가 채워지는 동안의 웜업 기간이 없습니다. 스냅샷을 로드한 후 API 레이어는 업데이트 스트림을 구독하고 실시간으로 델타를 적용합니다.

스냅샷 기반 위에 인메모리 캐시가 지속적으로 업데이트됩니다:

  • 계정 정보: sync.Map을 통한 락-프리 읽기, 업데이트를 위한 원자적 포인터 스왑.
  • 오더북: 불변 스냅샷으로 저장됩니다. 읽기는 포인터를 얻고, 업데이트는 새 버전을 교체합니다. 읽기 경로에 락이 없습니다.
  • API 키 캐시: 모든 키가 메모리에 맞습니다. 외부 조회가 완전히 제거되었습니다.

결과: 전체 거래소 상태가 로컬 메모리에 상주하고 실시간으로 업데이트되며, 모든 배포가 핫 스타트됩니다.

실행 엔진에 대한 파급 효과는 상당했습니다. 이전에는 실행 엔진이 네트워크를 통해 캐시 키를 쓰고 있었고, 서비스는 때때로 이러한 업데이트를 읽었습니다. 모든 것이 메모리에 상주하고 스냅샷 서비스가 상태 분배를 처리하면서 이러한 네트워크 쓰기가 불필요해졌습니다. 우리는 이를 삭제했습니다. 결과: 블록 시간 p99 가 약 2.6 ms 에서 약 1.2–1.8 ms 로 감소했습니다. 단순히 실행 엔진이 이제 훨씬 적게 쓰기 때문입니다:

Lighter - inline image

Block Times - p99

CPU 계획

사용자 공간에서 모든 것을 최적화한 후, 커널로 손을 뻗었습니다.

많은 고루틴이 있으며 이를 CPU에서 실행되도록 스케줄링해야 합니다. 스케줄링 오버헤드를 최소화하는 것은 저지연 시스템에 중요합니다. 기본적으로 Go 런타임은 고루틴을 OS 스레드에 멀티플렉스하고, OS는 스레드를 CPU 코어 간에 자유롭게 마이그레이션할 수 있습니다. 이 두 가지 모두 예측할 수 없는 지연 시간을 초래합니다.

우리는 네 가지 메커니즘을 쌓아서 이를 제거했습니다:

  1. `runtime.LockOSThread()`: 고루틴을 단일 OS 스레드에 고정하여 Go 스케줄러가 마이그레이션하지 못하도록 합니다.
  2. `sched_setaffinity` 를 통한 CPU 친화성: 해당 OS 스레드를 특정 CPU 코어 (Linux)에 고정합니다. 이는 커널이 코어 간에 마이그레이션하는 것을 방지하여 L1/L2 캐시 무효화를 피합니다.
  3. SCHED_FIFO 를 통한 높은 우선순위 스케줄링: 스레드의 스케줄링 우선순위를 높여 커널이 다른 작업보다 선호하도록 합니다.
  4. 바쁜 대기 스핀 루프: Hot path 는 빈 default 케이스가 있는 select를 실행하므로 고루틴이 절대 대기 상태에 들어가지 않습니다. 이것이 없으면 Go 는 사용 가능한 데이터가 없을 때 고루틴을 "실행 가능" 상태로 전환하고, 재스케줄링은 웨이크업 지연 시간을 추가합니다. 스핀 루프를 사용하면 고루틴은 고정된 코어에서 계속 실행되며 제로 스케줄링 지연으로 새 업데이트를 처리합니다.

영향을 올바르게 측정하는 것이 중요합니다. 적용 시간은 트래픽 조건에 따라 달라지므로 절대 수치는 부하에 따라 변동됩니다. 고정의 효과를 분리하기 위해 동일한 트래픽 하에서 두 그룹의 API 서버를 나란히 실행했습니다. 한 그룹은 고정되지 않은 상태로 대조군으로 두고, 다른 그룹을 CPU 고정으로 전환했습니다. 동일한 부하에서 동시에 측정된 두 그룹 간의 백분율 차이가 실제 결과를 보여줍니다.

고정 전에는 두 그룹이 함께 움직입니다. 동일한 기본 지연 시간, 동일한 스파이크 동작:

Lighter - inline image

Hot Path Apply Time - Before CPU Planning

한 그룹에서 고정을 활성화한 후, 일관되게 고정되지 않은 기준선 아래에 위치합니다. 동일한 트래픽, 더 낮은 지연 시간. 고정된 그룹의 스파이크도 더 낮게 제한되는데, 이는 스레드 마이그레이션 지터와 L1/L2 캐시 무효화가 제거되었기 때문입니다:

Lighter - inline image

Hot Path Apply Time - After CPU Planning

NUMA 인식 배포

고정된 코어의 메모리 액세스가 NUMA 경계를 넘는 경우 CPU 고정만으로는 충분하지 않습니다. NUMA 노드는 자체 로컬 메모리를 가진 CPU 그룹입니다. 원격 NUMA 노드에서 메모리에 액세스하면 로컬 액세스에 비해 10 배의 패널티가 발생합니다.

Lighter 의 API 서버는 원래 2 개의 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

거리 행렬이 이야기를 알려줍니다: 로컬 액세스 비용은 10, 노드 간 액세스 비용은 100입니다. 10 배의 패널티입니다. N 개의 API 서버가 머신을 공유할 때, 일부 서버는 고정된 CPU가 한 NUMA 노드에 있고 작업 메모리 (인메모리 캐시, 업데이트 버퍼)가 다른 노드에 있을 수밖에 없습니다. 모든 hot path 반복이 노드 간 세금을 지불하고 있었습니다.

해결책은 직관에 반하는 것이었습니다: API 서버를 단일 NUMA 노드가 있는 더 작은 머신으로 옮겼습니다. 사양은 절반이지만, 모든 메모리 액세스가 이제 로컬임이 보장됩니다:

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

이를 통해 비용을 절감하는 동시에 지연 시간을 개선했습니다. Hot path 적용 시간은 약 100–250 µs 범위로 더 떨어졌고, 비성수기에는 약 100 µs 까지 내려갑니다. 2-NUMA 노드 머신에서 CPU 고정만으로는 약 200–520 µs 범위였던 것과 비교하십시오:

Lighter - inline image

Hot Path Apply Time - NUMA Optimization

커스텀 바이너리 직렬화

Lighter 의 실행 엔진은 내부 메시지 버스를 통해 API 레이어에 상태 업데이트를 게시합니다. 모든 시스템 업데이트가 이 경로를 통해 흐릅니다. 원래 직렬화는 범용 인코딩 라이브러리를 사용했지만, 리플렉션, 타입 스위치 및 필드별 할당이 hot path 에 불필요한 오버헤드를 만들었습니다.

우리는 이를 수제 바이너리 직렬화로 교체했습니다: 시스템의 모든 엔티티 타입에 대해 고정 레이아웃, 제로 리플렉션 인코딩/디코딩입니다. 각 타입은 알려진 바이트 오프셋에서 필드를 읽고 쓰는 전용 인코더/디코더를 가지고 있습니다. 리플렉션이나 타입 스위치가 없으며, 할당도 최소화됩니다. 모든 코덱에는 회귀를 잡기 위한 라운드 트립 및 퍼즈 테스트가 있습니다.

결과적으로 모든 다운스트림 캐시를 공급하는 경로에서 직렬화 오버헤드가 크게 감소했습니다.

독립적인 벤치마킹은 커뮤니티 멤버 @UngusTrade 가 구축하고 유지 관리하는 대시보드를 통해 이용할 수 있으며, 이 대시보드는 무기한 선물 거래소 간의 실시간 트랜잭션 지연 시간을 비교합니다: latency.perps.trading

원클릭 저장

YouMind로 바이럴 글을 AI 심층 읽기

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

Explore YouMind
크리에이터를 위해

당신의 Markdown을 깔끔한 𝕏 글로

직접 쓴 장문을 올릴 때 이미지, 표, 코드 블록을 𝕏에 맞게 정리하는 일은 번거롭습니다. YouMind는 전체 Markdown 초안을 깔끔하고 바로 게시할 수 있는 𝕏 글로 바꿔 줍니다.

Markdown → 𝕏 사용해 보기

분석할 패턴 더 보기

최근 바이럴 아티클

더 많은 바이럴 아티클 보기