본문으로 건너뛰기

Ruby C 익스텐션의 메모리 관리 전략 분석: 임베디드 방식과 개별 할당 방식의 성능 비교

A Deep Dive into Ruby C Extension Memory Management: embedded vs. separate (2025)

작성자
HackerNews
발행일
2025년 06월 22일

핵심 요약

  • 1 Ruby C 익스텐션에서 대규모 데이터를 다룰 때 임베디드 할당 방식(Variable-width allocation)이 항상 성능 우위를 점하는 것은 아니며, 데이터 크기에 따라 신중한 선택이 필요합니다.
  • 2 실험 결과 768차원 이상의 대형 벡터 데이터를 Ruby 힙 메모리에 직접 임베디드할 경우, 개별 할당 방식보다 메모리 사용량이 약 4배 증가하고 처리 속도도 저하되는 현상이 관찰되었습니다.
  • 3 최적화 기법을 적용하기 전후에는 반드시 실제 벤치마크를 통해 성능을 측정해야 하며, Ruby 가비지 컬렉터의 힙 관리 메커니즘이 대형 객체에 미치는 영향을 깊이 있게 이해해야 합니다.

도입

이 글은 Ruby C 익스텐션 개발 시 성능 최적화를 위해 시도된 두 가지 메모리 관리 방식, 즉 임베디드 할당(Embedded Allocation)과 개별 할당(Separate Allocation)을 심층적으로 비교 분석합니다. 저자는 AI 임베딩 벡터를 저장하는 rag_embeddings 라이브러리를 개발하며, Ruby의 가변 너비 할당 기능을 활용해 성능을 개선하려 시도했습니다. 이론적으로는 단일 메모리 블록 할당이 캐시 효율성과 할당 오버헤드 측면에서 유리해 보였으나, 실제 대규모 고차원 벡터 데이터를 다루는 과정에서 예상치 못한 성능 저하와 메모리 폭증 문제에 직면하게 된 배경을 설명합니다.

1. 메모리 할당 방식의 비교: Embedded vs Separate

Ruby C 익스텐션에서 C 구조체 데이터를 관리하는 방법은 크게 두 가지로 나뉩니다.

  • 개별 할당 (Separate Allocation): Ruby 객체와 실제 데이터(C 구조체)를 별도의 메모리 영역에 할당합니다. Ruby 객체는 C 데이터의 포인터만을 가지며, 데이터는 xmalloc을 통해 할당됩니다.
  • 임베디드 할당 (Embedded Allocation): Ruby 3.x에서 도입된 가변 너비 할당(Variable-width allocation)을 사용하여 Ruby 객체 헤더 바로 뒤에 실제 데이터를 연속적으로 배치합니다.

2. 임베디드 방식의 이론적 기대 효과

저자는 rb_data_typed_object_zallocRUBY_TYPED_EMBEDDABLE 플래그를 사용하여 다음과 같은 이점을 기대했습니다.

  • 할당 횟수 감소: 두 번의 할당(ALLOC + xmalloc)을 한 번으로 줄여 시스템 호출 오버헤드 제거.
  • 메모리 지역성(Locality) 향상: 객체와 데이터가 인접하여 CPU 캐시 효율 증대.
  • 메모리 오버헤드 절감: 포인터(8바이트) 저장 공간 및 malloc의 메타데이터 관리 비용 제거.
  • GC 친화성: Ruby 가비지 컬렉터가 객체 이동(Compaction) 시 데이터를 함께 이동시켜 단편화 방지.

3. 벤치마크 결과: 예상 밖의 성능 저하

10,000개의 임베딩 벡터(768~4096 차원)를 대상으로 테스트한 결과는 이론적 기대와 정반대였습니다.

  • 메모리 사용량: 임베디드 방식이 기존 방식보다 약 4배 더 많은 RAM을 사용했습니다.
  • 처리 속도: 객체 생성 및 유사도 계산 속도 모두 임베디드 방식에서 유의미하게 저하되었습니다.

4. 실패 원인 분석: 왜 대형 객체에서는 역효과가 나는가?

이러한 역설적인 결과는 Ruby의 가비지 컬렉션(GC) 메커니즘과 데이터 크기의 상관관계에서 비롯되었습니다.

  • 힙 압박(Heap Pressure): xmalloc으로 할당된 데이터는 Ruby 힙 외부에 존재하지만, 임베디드 데이터는 Ruby 힙 내부에 위치합니다. 수 킬로바이트 크기의 객체가 힙에 대량 유입되면 GC가 훨씬 자주 실행되고, Ruby는 힙을 축소하는 데 소극적이게 됩니다.
  • GC 스캔 오버헤드: 가비지 컬렉터는 마킹 단계에서 임베디드된 데이터 영역까지 스캔해야 합니다. 벡터 데이터는 Ruby 객체 참조를 포함하지 않음에도 불구하고 스캔 대상이 되어 불필요한 연산이 발생합니다.
  • 캐시 오염(Cache Pollution): 객체 크기가 너무 크면(예: 3KB) CPU 캐시 라인을 여러 개 점유하게 되어, 오히려 임베디드 방식의 장점인 지역성이 퇴색되고 캐시 효율이 떨어집니다.

5. 핵심 교훈

  • 크기가 중요하다: 임베디드 할당은 작고 빈번하게 생성되는 객체에는 탁월하지만, 대형 데이터 구조에는 외부 할당이 유리합니다.
  • 측정의 중요성: 직관적인 최적화가 실제 환경에서는 독이 될 수 있으므로 반드시 벤치마크를 수행해야 합니다.
  • 상황에 맞는 도구 선택: 특정 기술(예: Time 객체 최적화)이 성공적이었다고 해서 이를 모든 케이스에 일반화하는 것은 위험합니다.

결론

실험 결과, 임베디드 할당은 Time.now와 같은 작고 수명이 짧은 객체에는 매우 효과적이지만, 임베딩 벡터처럼 크고 수명이 긴 데이터에는 적합하지 않음이 밝혀졌습니다. 이는 Ruby 가비지 컬렉터가 관리하는 힙 영역에 거대한 데이터가 포함되면서 발생하는 GC 오버헤드와 힙 압박 때문입니다. 결국 저자는 단순하고 메모리 효율적인 기존의 xmalloc 기반 개별 할당 방식으로 회귀하며, 최적화에 있어 직관보다는 실제 데이터 기반의 측정이 얼마나 중요한지를 강조합니다. 기술적 선택에 있어 문맥(Context)과 데이터 크기가 결정적인 변수임을 시사하며 글을 맺습니다.

댓글 0

댓글 작성

댓글 삭제 시 비밀번호가 필요합니다.

이미 계정이 있으신가요? 로그인 후 댓글을 작성하세요.

0/1000
정중하고 건설적인 댓글을 작성해 주세요.