Rust의 메모리 최적화 (새 탭에서 열림)
Figma는 실시간 협업 파일의 서버 측 로딩 성능과 메모리 사용량을 개선하기 위해 Rust 자료구조를 최적화했다. 특히 노드 속성을 저장하던 BTreeMap을 작고 정렬된 벡터로 바꾸어 대용량 파일의 메모리 사용량을 약 25% 줄이고 역직렬화 속도도 높였다. 또한 포인터의 사용되지 않는 상위 비트에 필드 ID를 저장하는 비트 패킹 방식도 검토했지만, 이는 아직 프로덕션에 적용되지 않았다.
파일 로딩과 메모리 사용량의 문제
- Figma의 멀티플레이어 시스템은 파일 로딩, 협업자 업데이트 전파, 파일 상태 스냅샷 저장을 담당한다.
- 복잡한 노드 그래프를 실시간으로 처리하기 위해 파일의 상당 부분을 메모리에 올린다.
- 동적 페이지 로딩을 도입한 뒤 서버에서 디코딩해야 하는 파일 수가 약 30% 증가했다.
- 이에 따라 로딩 경로에 있는 Rust 자료구조와 메모리 배치를 최적화할 필요가 생겼다.
BTreeMap이 차지한 메모리
- Figma 파일은 삼각형, 프레임, 텍스트 등 다양한 노드의 집합으로 표현된다.
- 각 노드는 색상, 타입, 부모 노드, 위치 같은 속성을 가진다.
- 기존에는 속성을 다음과 같은 형태로 저장했다.
BTreeMap<u16, pointer>
u16은 속성 ID, 포인터는 속성 값의 위치를 의미한다.- 이 맵은 파일 로딩의 핵심 경로에 있었고, 전체 파일 메모리 사용량의 60% 이상을 차지했다.
- 스키마의 속성 수는 200개 미만이며, 하나의 노드에 실제로 존재하는 속성은 평균 약 60개에 불과했다.
- 속성 키가 작고 제한적이며, 대부분 특정 노드 유형에만 묶여 있다는 점에서 범용 맵이 과도하다고 판단했다.
정렬된 벡터로 자료구조 변경
BTreeMap대신 속성 ID와 포인터를 저장하는 정렬된 평탄 벡터를 사용했다.
Vec<(u16, pointer)>
- 이론적인 복잡도만 보면 벡터가 불리하다.
- 검색:
O(n) - 중간 삽입:
O(n) - 수정: 위치 탐색 비용 발생
BTreeMap은 일반적으로O(log n)수준의 연산 제공
- 검색:
- 하지만 실제 파일 로딩에서는 벡터의 연속적인 메모리 배치가 더 유리했다.
- CPU는 작은 선형 메모리 영역을 순차적으로 읽고 계산할 때 캐시 효율이 높다.
- 결과적으로 역직렬화 속도가 향상됐고, 대용량 파일의 메모리 사용량은 약 25% 감소했다.
- 이 사례는 Big O 복잡도만으로 실제 성능을 판단하기보다 데이터 크기와 메모리 지역성까지 고려해야 함을 보여준다.
포인터에 필드 ID를 함께 저장하는 비트 패킹
- 일반적인 포인터는 64비트지만, x86 시스템에서는 실제 주소 지정에 하위 48비트만 사용하는 경우가 많다.
- 따라서 상위 16비트가 사용되지 않는다는 점에 착안해, 여기에 속성 필드 ID를 저장하는 방법을 검토했다.
- 기존에는 속성 ID와 포인터를 별도 값으로 저장했지만, 비트 패킹 후에는 하나의
u64에 둘을 함께 담을 수 있다.
Vec<u64> {
[field_id_u16, pointer_u48],
}
- 속성 ID가 정확히 16비트로 표현 가능하다는 점과 포인터의 남는 상위 비트가 맞아떨어졌다.
- 이 방식은 메모리 접근과 데이터 구조의 크기를 더 줄일 가능성이 있다.
- 다만 포인터의 상위 비트가 항상 사용 가능하다는 보장은 없으며, 하드웨어나 운영체제의 주소 체계가 바뀔 수 있다.
- 따라서 해당 최적화는 조사 단계이며 아직 프로덕션에는 적용되지 않았다.
작은 데이터 집합에서는 범용 트리나 맵보다 단순한 연속 배열이 더 빠르고 효율적일 수 있다. Rust에서 성능을 최적화할 때는 이론적 복잡도뿐 아니라 실제 데이터 크기, CPU 캐시 지역성, 포인터 표현 방식, 플랫폼 호환성까지 함께 측정하고 판단하는 것이 중요하다.