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_zalloc과 RUBY_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 객체 최적화)이 성공적이었다고 해서 이를 모든 케이스에 일반화하는 것은 위험합니다.