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회의 시스템 호출을 기록함. 이 중
mmap14회,mprotect7회,munmap7회,brk3회이며, 표시된 총 소요 시간은 0.000309초임. - 제로 카피 버전은 총 29회를 기록함.
brk3회,munmap6회,mmap13회,mprotect7회이며, 표시된 총 소요 시간은 0초임. - 표의
calls는 시스템 호출 횟수,errors는 실패 횟수이며 빈칸은 실패가 없음을 뜻함.seconds는 해당 시스템 호출에 사용된 총 시간,usecs/call은 호출당 평균 시간(마이크로초),% time은 표에 나타난 전체 시간 중 해당 호출이 차지하는 비율임. brk(NULL)은 현재 힙 끝을 확인하며, 이어지는brk는 시작 과정에서 할당자가 힙을 만드는 동작임. 20,480바이트mmap은MAP_STACK으로 표시된 스택 할당이며, 이에 대응하는munmap은 출력 이후 프로그램 종료 중 발생함.- 할당 버전에서 추가로 관측된
munmap(0xffff953ef000, 1052672)은 1MiB를 예약한Vec의 해제이며, 이에 대응하는 추가mmap이 필요했음을 보여줌.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요