본문 바로가기

Library/C/C++

메모리 풀 형식의 할당기 제작 (Prototype)

한달 전부터 고심을 거듭하던 메모리 풀 형식의 할당기 만드는 것을 대충 끝냈다. 생각보다 골치 아픈 일이었다. 단순히 커다란 힙을 할당 받고, 그 크기에 따라 적절한 할당 개체를 불러 할당 / 해제 함수를 부르면 될거라고 생각했는데, 성능에 민감한 부분이 많았다. 즉, 스택 상에서 하부 구조끼리 단순히 복사가 가능한 부분과, 반드시 힙에 생성해야 하는 부분을 파악해야 했다. 또, std::vector가 최대의 성능을 내는 것이 컨테이너의 끝에서 원소의 추가 /  삭제가 일어날 때라는 것도 고려해야 했다.

기본적인 컨셉은 Loki의 SmallObj에서 가져왔는데, SmallObj의 가장 하부 구조인 Chunk를 거의 그대로 차용했다. 그리고 가변 사이즈의 메모리 풀을 지원하기 위해 Loki::SmallObj와 동일하게 4계층 구조를 가지고 있다. 다만, Loki의 FixedAllocator와 가장 다른 점은, Slab Allocator의 아이디어를 활용했다는 점이다. 즉, Loki의 FixedAllocator의 단일 Chunk 풀과는 다른 구조를 가진다.

Loki::SmallObj의 FixedAllocator의 성능상의 병목 지점 중 하나는 할당과 해제가 일어날 때 하나의 Chunk 풀에서 할당이 일어나야 하는 Chunk, 해제 할 블럭이 포함된 Chunk를 찾는 부분이다. 해제 할 부분을 찾기 위한 함수인 VicinityFind()는 선형적인 해제에 효율적인 방법이지만, 사실 해제할 블럭을 찾고 해제하는데 소모되는 전체적인 성능은 탐색 방법에 크게 좌우되지 않는다. 이것은 탐색 방법 자체가 일반적으로 성능과 상관없다는 말이 아니라, FixedAllocator 안에서의 Chunk의 구조, 그리고 대량의 일괄적인 메모리 해제에서 VicinityFind()의 성능은 크게 영향을 미치지 못한다는 뜻이다. 왜냐하면, 하나의 Chunk에서 최대 255개의 블럭을 할당할 수 있기 때문에, 실제로 벡터에 포함되는 Chunk의 갯수는 그다지 많지 않다. 기껏해야 두 자리수 정도인데, 이런 경우에는 선형 탐색이나 이진 탐색이나, 탐색 방법의 변화로 극적인 성능 향상을 꾀하기는 힘들다.

즉, 이 구조의 메모리 해제 연산에서 가장 많은 시간을 소모하는 것은 연속적으로 빈번하게 일어나는 기본 delete 연산이지, 실제로 VicinityFind()에서 해제할 블럭에 대한 포인터를 가진 Chunk를 찾는 시간이 아니다. VicinityFind()는 선형 탐색임에도 불구하고 괜찮은 성능을 보여주기 때문에, 기본 delete에 대한 수행 시점은 더욱 중요한 문제가 된다.

다시 말해, FixedAllocator는 메모리 경계에서의 Chunk 생성과 해제에 대한 문제를 해결하기 위해 최소한 하나의 빈 Chunk를 유지하려고 하고, 빈 Chunk에 대한 포인터를 가지고 있다. 이것은 일반적으로 효과적인 메모리 사용량과 속도에 대한 타협점이지만, 대량의 선형적인 메모리 할당과 해제에 있어서는 좀 더 개선이 필요하다. 만약, 메모리 사용량이 한계 상황에 이를 정도로 압박스러운 상황이 아니라면, 좀 더 대담한 방법을 사용해도 될 것이다.

즉, 완전히 Empty 상태인 Chunk들에 대해서 둘 이상이 되면 즉시 해제하는 것이 아니라, 비어있는 Chunk들을 모아두는 벡터를 가지고, 이 수가 일정 수 이상이 되면 한번에 delete 해버리는 전략을 취할 수 있을 것이다. 그리고, 해제 시점에 대한 카운터를 사용자에게 선택 사항으로 넘겨주면, 해제 시점을 사용자의 입맛에 따라 결정할 수 있게 된다.

메모리 풀 형식의 할당기를 단독으로 만드는 것은 매우 어려운 문제이다. 즉, 메모리 할당기는 사전에 이런저런 인프라를 필요로 한다. 예를 들어, 이 메모리 할당기를 활용하고자 하는 클래스마다 이 메모리 할당 개체를 상속 받는다면, 메모리 풀이란 것이 의미없게 된다. 따라서, 이 할당 개체를 상속 받은 개체 각각에서 따로 움직이지 않도록 프로그램 전체에서 유일한 할당 개체로 접근할 필요성이 있으며, 그렇기 때문에 미리 Singleton 인프라가 필요하다. 또, Singleton 인프라는 멀티 스레드 환경에 안전해야 하기 때문에, 스레드 동기화 메커니즘도 준비해야 한다.

완전히 실전에 투입하기에는 모자란 부분이 많지만, 그럭저럭 동작하는 수준에서 버그는 없는 것 같다. 간단한 테스트 프로그램으로 틱 카운트를 사용하여 기본 할당자와 성능을 비교해봤다.

Loki::SmallObj와 마찬가지로 간단히 상속을 받아서 사용하며, 세부적인 옵션을 지정할 수 있는 템플릿 인자를 제공한다. Loki::SmallObj처럼 상속을 통해 특화된 할당 능력을 사용하고자 한다면, 상속 받는 개체의 크기 또한 중요한 문제가 된다. 0.1.7 버전의 Loki::SmallObj는 4바이트 크기를 가지는데, 이것은 실제로 Loki::SmallObj가 해당 할당기를 직접적으로 상속 받는 구조가 아니라, Loki::SmallObj가 실제 할당 능력을 제공하는 할당기로의 인터페이스만 제공하기 때문이다. 즉, 기껏해야 가상 함수 하나 정도(가상 소멸자)를 포함하고 있다는 뜻이다.

Loki::SmallObj처럼 상속을 통해 인터페이스를 제공하는 경우, 소멸자를 가상 함수로 선언하는 것은 매우 중요하다. 만약 소멸자가 가상 함수로 선언되지 않았다면, 그리고 메모리 할당기를 이용하고자 이를 상속 받은 개체의 소멸자가 가상 함수가 아니라면 메모리 누수, 최악의 경우 메모리 해제 도중 그냥 죽어버리는 치명적인 결과를 초래할 수 있다. Loki::SmallObj는 가변 메모리풀이며, 할당하려는 메모리 크기로 할당 개체를 찾는다. 따라서, 동적으로 개체의 정확한 크기를 알아내지 못한다면 이에 기반한 디자인 전략은 메모리 해제 동작에서 직접적인 파국을 맞이하게 될 것이다.




첫번째 테스트는 할당, 해제가 연속적으로 일어나는 것을 반복적으로 테스트한 것이고, 두번째 항목은 연속적인 할당, 연속적인 해제를 테스트한 것이다. 그리고 마지막 항목은 테스트 2번의 항목이 연달아 일어나도록 한 것이다.

 Loki::SmallObj와 비교한다면, 기본적인 할당, 해제 외의 검증, 편의에 관한 함수는 아직 구현되지 않았다. 조금씩 다듬어 나가면 훨씬 예쁜 모습이 될 것 같다.