TL;DR

  • Rust에서 8바이트 숫자를 파싱할 때 Vec로 복사하지 않고 입력 버퍼에서 직접 읽으면, 힙 할당을 피하고 불필요한 런타임 비용과 코드 복잡성을 줄임.
  • &[u8]에서 필요한 길이를 검증한 뒤 고정 크기 배열로 변환하는 방식이 제로 카피(zero-copy) 파싱의 기본임.
  • 주문 엔진 명령을 고정 너비 필드와 Copy 타입으로 설계하면, 열거형의 각 변형을 입력 버퍼에서 직접 디코딩할 수 있음.
  • 같은 설계 원칙을 인코딩에도 적용하면 호출자가 소유한 버퍼에 바로 기록해 중간 할당을 피함.
  • 제시된 벤치마크에서 명령 디코딩 처리량은 유형에 따라 초당 약 2억 3,700만~2억 4,500만 건이며, strace 비교에서는 할당 버전이 시스템 호출 31회, 제로 카피 버전이 29회로 관측됨.

8바이트 파싱에서 불필요한 할당 없애기

  • 짧고 안전해 보이는 기존 parse_id 구현은 입력 길이를 확인한 뒤 첫 8바이트를 to_vec()로 복사하고, 그 벡터를 u64로 변환함.
  • Rust의 Vec는 항상 힙을 사용하며, 할당이 기존 여유 공간에서 처리될 수도 있고 메모리가 더 필요해 운영체제에 요청할 수도 있음. 부하가 있는 상황에서는 시스템 호출이 필요할 수 있음.
  • 숫자처럼 단순한 값에 힙을 사용하는 것이 적절한지, 시스템 호출이 필요한지 검토할 필요가 있음. 일부 애플리케이션은 이 세부 사항에 신경 쓰지 않지만, 일부는 중요하게 다룸.
  • 입력 버퍼의 8바이트는 이미 u64를 나타내며 from_le_bytes가 이를 읽을 수 있으므로, 중간 벡터 할당은 결과를 얻기 위한 불필요한 복잡성임.
  • bytes.get(..8)로 필요한 범위를 검사하고 이를 [u8; 8]로 변환한 뒤 u64::from_le_bytes에 전달하면, 힙을 사용하지 않고 입력 위치에서 읽을 수 있음. 길이가 부족하거나 변환에 실패하는 경우는 각각 오류로 처리함.
  • 데이터가 이미 있는 곳에서 처리하고 시작 단계의 복사를 요구하지 않는 방식이 제로 카피임. 8바이트는 from_le_bytes 처리 과정에서 스택으로 이동하지만, 힙 작업은 생략됨.
  • Rust의 타입 계약을 통해 올바른 데이터 유형을 읽고 오류 상태를 모델링하면, 제로 비용 추상화의 이점을 런타임에서도 활용할 수 있음.

명령 디코딩에 적용한 제로 카피

  • 더 긴 메시지도 데이터 레이아웃에 맞춘 계약을 정의하고 같은 기법을 반복 적용하면 됨.
  • 작성 중인 주문 흐름(orderflow) 명령 코덱에서 EngineCommand는 열거형이며, 각 변형은 엔진 명령에 따라 서로 다른 바이트 너비를 가짐.
  • 지정가 주문(NewLimit)은 계정, 클라이언트 주문 식별자, 상품 식별자, 방향, 가격, 수량을 포함함.
  • 시장가 주문(NewMarket)은 가격 필드 없이 계정, 클라이언트 주문 식별자, 상품 식별자, 방향, 수량을 포함함.
  • 주문 식별자 취소(CancelByOrder)는 두 정수로 구성됨.
  • 종류 바이트(kind byte)가 데이터 레이아웃을 지정하며, 각 레이아웃에는 고정 길이가 있음. 해당 길이를 검사한 다음 필드를 새 타입으로 읽음.
  • NewLimit 디코더는 페이로드 길이가 57바이트인지 확인하고, 정해진 오프셋에서 계정·클라이언트 주문·상품 식별자, 방향, 가격, 수량을 읽음. 가격은 틱 수를 나타내는 i64, 수량은 랏 수를 나타내는 정수이며, 그 밖의 필드도 의도적으로 Copy 타입으로 구성됨.
  • EngineCommand의 어떤 변형도 버퍼를 소유하지 않음. 필드가 모두 Copy이므로 열거형 전체도 Copy가 되며, 엔진은 명령 파싱과 이후 처리를 입력 데이터가 있는 위치에서 수행할 수 있음.
  • 프레임에는 시퀀스 번호와 종류가 포함되며, 종류에 따라 NewLimit, NewMarket, CancelByOrder, CancelByClient, Replace 디코더로 분기함.
  • NewLimit은 57바이트, CancelByOrder는 16바이트임. 읽을 방식은 각 변형에 따라 결정되며 컴파일러가 이를 보장함. 남은 과제는 각 실패 가능성을 확장 가능하게 처리해 잘못된 데이터가 입력되지 않도록 하는 것임.

인코딩과 처리량

  • 쓰기에도 제로 카피와 같은 설계 의도를 적용함. 인코더는 호출자가 이미 소유한 버퍼의 정해진 오프셋에 필드를 기록함.
  • 각 변형은 데이터 버퍼에 예측 가능한 크기로 들어가는 정수 필드의 묶음이며, 쓰기 과정에서 중간 할당이 필요하지 않음.
  • 저널 프레임과 데이터그램은 페이로드 영역을 넘겨주고 하나의 쓰기 경로를 공유하며, 어느 경로도 명령의 별도 복사본을 만들지 않음.
  • 열거형의 변형에 따라 메모리 레이아웃은 달라지지만 힙은 사용하지 않음. 지정가 주문은 정해진 위치의 57바이트이며, 시퀀스 번호와 종류 바이트가 포함된 외피(envelope)가 데이터를 안전하고 일관된 순서로 읽는 위치를 알려줌.
  • 제시된 디코딩 벤치마크 결과는 다음과 같음.
  • decode_frame/stream/cancel_by_order: 4.0990~4.1243ns, 초당 2억 4,246만~2억 4,396만 건
  • decode_frame/stream/new_limit: 4.1835~4.2058ns, 초당 2억 3,776만~2억 3,903만 건
  • decode_frame/stream/new_market: 4.0677~4.1051ns, 초당 2억 4,360만~2억 4,584만 건
  • Melem/s는 초당 메가 요소 수를 뜻함.

시스템 호출 측정으로 확인하기

  • Rust와 Docker가 설치된 환경에서 count-mem-syscalls-check라는 새 Cargo 프로그램을 만들고, 할당 방식과 제로 카피 방식의 parse_id를 각각 실행해 strace로 시스템 호출을 측정할 수 있음.
  • 할당 버전은 입력의 첫 8바이트를 Vec로 복사한 뒤 1MiB를 추가 예약함. 주석에 따르면 glibc는 128KiB를 넘는 할당에 mmap을 사용하므로, 큰 할당을 유도해 차이를 관찰하는 구성임. black_box는 벡터가 측정 전에 제거되지 않도록 사용함.
  • 제로 카피 버전은 입력의 첫 8바이트를 배열로 변환해 바로 u64로 읽음. 두 경우 모두 같은 숫자 81985529216486895를 출력함.
  • Docker에서 rust:1-slim 이미지를 사용해 strace를 설치하고 릴리스 빌드를 수행한 뒤, 메모리 관련 시스템 호출 통계와 mmap, brk, munmap, write 호출을 각각 추적함.
  • 측정 결과, 할당 버전은 총 31회의 시스템 호출을 기록함. 이 중 mmap 14회, mprotect 7회, munmap 7회, brk 3회이며, 표시된 총 소요 시간은 0.000309초임.
  • 제로 카피 버전은 총 29회를 기록함. brk 3회, munmap 6회, mmap 13회, mprotect 7회이며, 표시된 총 소요 시간은 0초임.
  • 표의 calls는 시스템 호출 횟수, errors는 실패 횟수이며 빈칸은 실패가 없음을 뜻함. seconds는 해당 시스템 호출에 사용된 총 시간, usecs/call은 호출당 평균 시간(마이크로초), % time은 표에 나타난 전체 시간 중 해당 호출이 차지하는 비율임.
  • brk(NULL)은 현재 힙 끝을 확인하며, 이어지는 brk는 시작 과정에서 할당자가 힙을 만드는 동작임. 20,480바이트 mmap은 MAP_STACK으로 표시된 스택 할당이며, 이에 대응하는 munmap은 출력 이후 프로그램 종료 중 발생함.
  • 할당 버전에서 추가로 관측된 munmap(0xffff953ef000, 1052672)은 1MiB를 예약한 Vec의 해제이며, 이에 대응하는 추가 mmap이 필요했음을 보여줌.