[C++] 오늘의 삽질기_메모리 풀
안녕하세요. 저에요.
오늘은 여유가 생겨서 나중에 메모리 풀을 구현해보고 싶다는 로망을 실현해 보기로 했어요.
차후 자체 엔진에 도입할 수도 있기 때문이지요.
결론부터 말하면 제가 꿈에 그리던 메모리 풀은 사실상 쓰레기였어요. ㄱ-
그렇기에 적어보는 글이다요.
삽질에 대한 내용을 적는 일기 개념이기 때문에 메모리 풀이 뭔지는 설명 안 하겠다요.
본론으로 바로 들어가자면,
가장 유명하고도 성능 좋은 메모리 풀은 하나의 객체에 대한 타입만을 메모리 풀링해주는 방식이에요.
왜냐하면, 하나의 객체에 대한 메모리만을 풀링 해주면 객체의 사이즈가 고정이기 때문에 메모리 단편화 현상을 아예 없앨 수 있기 때문이에요. 단편화 현상이 없으면 조각화 현상에 대해서 코스트를 투자하지 않아도 되기 때문에 가장 빠르다고 알려진 메모리 풀 패턴이에요.
제가 짜본 코드로 같이 봐볼까요?
/// 메모리 풀에서 관리되는 모든 객체는 이 클래스를 상속해야 함
class Instance
{
public:
Instance() = default;
virtual ~Instance() = default;
size_t mMemoryIndex = (size_t)-1;
};
/// 고정 크기 메모리 풀
template<typename T>
class MemoryPool
{
friend class Instance;
public:
MemoryPool(size_t elementSize, size_t capacity) : mElementSize(elementSize), mCapacity(capacity)
{
mMemory = static_cast<char*>(malloc(elementSize * capacity));
assert(mMemory);
// 초기 free index 테이블 구성
for (size_t i = 0; i < capacity; ++i)
{
mFreeIndices.push(i);
}
}
~MemoryPool()
{
free(mMemory);
}
template<typename... Args>
T* Alloc(Args&&... args)
{
if (sizeof(T) > mElementSize) {
printf("Error: Object size exceeds pool element size.\n");
return nullptr;
}
if (mFreeIndices.empty()) {
printf("Error: MemoryPool is full.\n");
return nullptr;
}
size_t index = mFreeIndices.top();
mFreeIndices.pop();
void* mem = mMemory + (index * mElementSize);
T* obj = new (mem) T(std::forward<Args>(args)...);
obj->mMemoryIndex = index;
return obj;
}
void Free(T* instance)
{
if (!instance) return;
size_t index = instance->mMemoryIndex;
assert(index < mCapacity);
instance->~T();
mFreeIndices.push(index);
}
private:
size_t mElementSize;
size_t mCapacity;
char* mMemory;
std::stack<size_t> mFreeIndices;
};
사실 이 코드도 특이한 방식이긴 해요.
Instance객체를 상속해야 하는 방식으로 작성되었는데, 이는 추후 자체 엔진에 Instance Manager를 만들어서 풀링 하려고 했기 때문이에요.
하지만 저는 이게 너무 싫었어요. (홍대병)
이 방식은 풀링할 객체 타입이 많아질수록 메모리 풀을 해당 객체 수만큼 만들어줘야 했기 때문인데요?
예를 들어, Transform객체와 Object객체...등 메모리 풀링을 해야 할 객체가 만들어질 때마다 Transform 메모리 풀, Object 메모리 풀... 전부 만들어줘야 하기 때문이에요.
그래서 모든 타입을 풀링할 수 있도록 설계해 봤습니다.
이름하야 Custom Dynamic Memory Pool ! ! !
하지만 모든 타입을 풀링 하려면 필연적으로 조각화 현상이 생기는데요.
때문에 객체를 삭제할 때마다 해당 객체의 뒤에 위치한 메모리를 memmove로 당겨오는 방식(vector의 erase처럼)을 채택했습니다.
코드로 볼까요?
// 내부 관리용 객체: 메모리 위치, 사이즈, 실제 객체 포인터 등을 담는다
class Instance
{
public:
void* mPtr = nullptr;
int mID = -1;
size_t mOffset = 0;
size_t mSize = 0;
};
class MemoryPool
{
public:
MemoryPool(size_t capacity)
: mCapacity(capacity), mUsedMemory(0)
{
mMemory = static_cast<char*>(malloc(capacity));
assert(mMemory && "Memory allocation failed");
}
~MemoryPool()
{
free(mMemory);
}
// 객체 할당
template <typename T, typename... Args>
int Alloc(Args&&... args)
{
size_t size = sizeof(T);
size_t start = mUsedMemory;
size_t end = mUsedMemory + size;
if (end > mCapacity) {
std::cerr << "MemoryPool Full!\n";
return -1;
}
void* mem = mMemory + start;
T* obj = new (mem) T(std::forward<Args>(args)...);
Instance meta;
meta.mID = ++UID;
meta.mOffset = start;
meta.mSize = size;
meta.mPtr = obj;
mInstanceMap[meta.mID] = meta;
mInstanceList.push_back(&mInstanceMap[meta.mID]);
mUsedMemory += size;
return meta.mID;
}
// 객체 접근
template <typename T>
T* Get(int handle) {
auto it = mInstanceMap.find(handle);
if (it == mInstanceMap.end()) return nullptr;
return reinterpret_cast<T*>(mMemory + it->second.mOffset);
}
// 객체 해제 + 조각화 제거
void Free(int handle) {
auto it = mInstanceMap.find(handle);
if (it == mInstanceMap.end()) return;
Instance& instance = it->second;
size_t offset = instance.mOffset;
size_t size = instance.mSize;
size_t tail = offset + size;
size_t remain = mUsedMemory - tail;
// 메모리 이동
memmove(mMemory + offset, mMemory + tail, remain);
// 모든 Instance의 offset 수정 및 포인터 갱신
for (auto& meta : mInstanceList) {
if (meta->mOffset > offset) {
meta->mOffset -= size;
meta->mPtr = mMemory + meta->mOffset;
}
}
// 리스트에서 제거
mInstanceList.erase(
std::remove_if(mInstanceList.begin(), mInstanceList.end(), [&](const Instance* i) {
return i->mID == instance.mID;
}),
mInstanceList.end()
);
mInstanceMap.erase(it);
mUsedMemory -= size;
}
private:
size_t mCapacity;
size_t mUsedMemory;
char* mMemory;
std::vector<Instance*> mInstanceList;
std::unordered_map<int, Instance> mInstanceMap;
};
급하게 짜느라 조잡하긴 하다...
짜고 보니까 문제가 생각보다 많았습니다.
1. memmove가 기대 이하의 퍼포먼스를 보여줬다.
일단 memmove를 제대로 공부하지 않고 멋대로 쓴 게 패착이었습니다.
당연히 저수준 메모리 이동은 비용이 적을 줄 알았는데, 생각보다 느렸습니다. ㅋㅋ
근데 아직도 왜 이렇게 느린지 모르겠는데, 이건 더 공부해 봐야겠네요.
아무튼 매우 느리답니다.
참고로 memcpy로 교체도 해봤는데 속도는 똑같았습니다.
2. 메모리만 옮겨서 끝이 아니라 메타 정보도 바꿔줘야 했다.
일단 모든 타입에 메모리를 풀링 하려다 보니, 각 타입마다 사이즈가 달라 메모리를 풀링 하기 어려웠습니다.
때문에 해당 메모리의 Offset과 Size 등의 메타정보가 있어야 메모리를 해제해 줄 수 있었는데요.
객체를 삭제하면 뒤에 있는 메모리를 옮겨오는 것까진 했는데, 그럼 뒤에 있는 객체의 메타 정보도 하나하나 수정해줘야 했습니다.
그래서 이미 이때부터 아...망했구나를 직감했습니다.
3. 메모리 이동 시 버추얼 테이블이 깨진다.
이건 좀 충격이었습니다...
속도 다 버리고 구현이라도 해보자는 느낌으로 구현 다 해놨는데. 자꾸 Free 할 때 터지는 거예요. 디버깅으로 보니까 버추얼 테이블이 NULL이더라구요.
추후 자체 엔진에 도입하기 위해 Instance라는 객체를 상속받는 구조로 설계했기 때문에 버추얼 테이블이 있어야 상속 트리를 타고 소멸자가 호출되는 구조인데, 버추얼 테이블이 NULL라 터지는 것이었습니다.
결과적으로, 메모리를 옮기는 건 저수준 작업이라 버추얼 테이블은 안 옮겨진다고 하네요.... 때문에 상속 기반 객체는 메모리 풀링에 사용할 수 없었습니다.
제가 2번에 언급했던 메타 정보를 하나하나 바꿔줘야 했던 것처럼, 버추얼 테이블도 옮겨주는 작업이 필요한데? 사실상 불가능하다고 합니다.
그래서 상속도 안 하고 사용하게 만들었습니다... 못쓰더라도 끝은 봐야 하니까...
End. 속도 비교
대망의 속도 비교가 빠질 수 없겠죠? 사실 이미 망한 거 알고 있지만 테스트해봤습니다.
(Custom Dynamic Memory Pool vs New/Delete)
... 그만 알아보도록 하자.
그래서 제 인생은 망했습니다.
그냥 new/delete나 씁시다. 와~~
애매하게 만들 거면 만들지 말기로...
번외 1. 고정 타입 메모리 풀의 성능은 어떠할까?
아까 고정 타입 메모리 풀 코드도 올렸는데요?
이건 성능이 어떻게 나올까요?
(Fixed Type Memory Pool vs New/Delete)
와 ㅋㅋㅋㅋ
역시 유명한 패턴엔 이유가 있습니다. 그냥 이거나 써야겠다. 와~~
번외 2. 왜 저는 님 코드 복사해서 해봤는데 차이가 별로 안 나요?
디버그 말고 릴리즈로 실행해 보세요.
비주얼스튜디오 디버그는 생각보다 사람 손을 탈수록 많이 느리답니다?
비단 메모리 풀만이 아니라 이런저런 코드도 디버그에서 매우 느린 경우가 많아요.