C++ 할당 전략 비교: 커스텀 할당자, 메모리 풀, 아레나, std::pmr, jemalloc·tcmalloc

new와 delete는 대부분의 프로그램에서 충분히 빠릅니다. 하지만 같은 크기의 작은 객체를 초당 수백만 번 만들고 버리는 코드에서는 할당과 해제 자체가 프로파일러 상위에 올라오기도 하고, 장시간 실행되는 프로세스에서는 여러 크기의 할당이 뒤섞이며 해제된 공간이 잘게 흩어져 메모리 사용량이 계속 늘어나기도 합니다. 이 글은 이런 경우에 쓰는 할당 전략(커스텀 할당자, 고정 블록 풀, 객체 풀, 아레나, std::pmr)을 구현과 함께 비교하고, 각 기법에서 흔히 생기는 버그와 도입 여부를 판단하는 방법을 정리합니다.


new/delete가 실제로 하는 일

이 글은 new/delete, 스택과 힙, RAII를 안다는 전제에서 출발합니다. 기초가 필요하다면 스택과 힙(#6-1), 메모리 누수(#6-2), RAII(#6-4)를 먼저 보는 것이 좋습니다.

new T는 두 단계입니다. operator new(sizeof(T))가 메모리만 얻고(대개 malloc을 거침), 그 자리에 생성자를 호출합니다. delete p는 소멸자를 부른 뒤 operator delete로 메모리를 돌려줍니다. 비용이 큰 쪽은 대개 첫 단계입니다. 범용 할당기는 어떤 크기가 어떤 순서로, 어느 스레드에서 요청될지 모르기 때문에 요청마다 크기 등급을 찾고, 해제할 때 크기를 알기 위한 정보를 관리하고, 여러 스레드의 동시 요청을 처리해야 합니다. 최신 할당기는 스레드별 캐시(glibc의 tcache, jemalloc과 tcmalloc의 스레드 캐시)로 작은 할당의 동기화를 대부분 피하지만, 한 스레드에서 할당하고 다른 스레드에서 해제하는 패턴이나 캐시가 감당하지 못하는 크기에서는 여전히 공유 구조를 건드립니다.

풀과 아레나는 “크기가 고정되어 있다”, “한 스레드만 쓴다”, “한꺼번에 버린다” 같은, 내 프로그램만 아는 사실을 이용해 이 일반성을 덜어내는 기법입니다. 그 대가로 그 전제가 깨지면 메모리 손상이 납니다.

placement new와 정렬

new를 두 단계로 나눠 쓰는 도구가 placement new입니다. 이미 확보한 메모리에 생성자만 호출하고(new (p) T(args...)), 해제할 때는 delete 대신 소멸자를 직접 부릅니다. 풀과 아레나는 모두 이 조합 위에 만들어지며, 이때 가장 흔한 실수는 메모리 정렬입니다.

// 위험: unsigned char 배열은 1바이트 정렬만 보장
unsigned char raw[sizeof(Object) * 3];
Object* a = new (raw) Object(1);              // Object에 double이나 SIMD 멤버가 있으면 오정렬

// 버퍼를 T의 정렬에 맞춤
alignas(Object) unsigned char storage[sizeof(Object) * 3];
Object* b = new (storage) Object(1);
b->~Object();                                  // placement new로 만든 객체는 소멸자를 직접 호출

x86에서는 일반 load/store의 오정렬 접근이 대부분 조용히 동작해서 테스트를 통과하다가, 정렬을 요구하는 SIMD 명령(_mm256_load_ps 등)이나 일부 ARM 환경에서 크래시로 드러납니다. 직접 만드는 풀의 블록 크기를 정렬 단위의 배수로 올리는 이유가 이것입니다.


커스텀 할당자

STL 컨테이너는 두 번째 템플릿 인자로 할당자를 받습니다. C++11 이후 할당자의 최소 요구사항은 value_type, allocate, deallocate, 다른 타입으로 변환하는 생성자, 그리고 비교 연산자입니다. construct, destroy, rebind 같은 나머지는 std::allocator_traits가 기본 구현을 채워 줍니다.

#include <cstddef>
#include <new>

template <typename T>
class CountingAllocator {
public:
    using value_type = T;

    CountingAllocator() = default;
    template <typename U>
    CountingAllocator(const CountingAllocator<U>&) noexcept {}

    T* allocate(std::size_t n) {
        if (n > static_cast<std::size_t>(-1) / sizeof(T)) throw std::bad_array_new_length();
        ++allocations;
        return static_cast<T*>(::operator new(n * sizeof(T), std::align_val_t{alignof(T)}));
    }
    void deallocate(T* p, std::size_t n) noexcept {
        ::operator delete(p, n * sizeof(T), std::align_val_t{alignof(T)});
    }

    static inline std::size_t allocations = 0;
};

template <typename T, typename U>
bool operator==(const CountingAllocator<T>&, const CountingAllocator<U>&) noexcept { return true; }
template <typename T, typename U>
bool operator!=(const CountingAllocator<T>&, const CountingAllocator<U>&) noexcept { return false; }
#include <vector>
std::vector<int, CountingAllocator<int>> v;
for (int i = 0; i < 1000; ++i) v.push_back(i);
// CountingAllocator<int>::allocations로 재할당 횟수를 확인할 수 있음

C++17의 정렬 지정 operator new를 써서 alignof(T)가 큰 타입도 올바르게 할당합니다. 상태가 없는 할당자는 모든 인스턴스가 같다고 비교되므로(==가 true), 컨테이너끼리 메모리를 옮기거나 교환할 수 있습니다. 반대로 특정 풀을 가리키는 상태 있는 할당자라면 같은 풀을 가리킬 때만 같다고 해야 하고, 그 경우 컨테이너 대입이나 swap 시 할당자를 어떻게 전파할지(propagate_on_container_*)를 신경 써야 합니다. 또 할당자가 타입의 일부라서 std::vector<int, A>와 std::vector<int, B>는 서로 다른 타입이 되고, 이를 받는 함수도 템플릿이어야 합니다. 이 불편이 std::pmr이 등장한 이유입니다.


고정 블록 풀

같은 크기의 객체를 반복해서 만들고 버린다면, 큰 덩어리(청크)를 한 번에 할당해 같은 크기의 블록으로 나누고, 비어 있는 블록을 연결 리스트(free list)로 관리하는 풀이 가장 단순하고 빠릅니다. 빈 블록 안에 다음 빈 블록의 포인터를 저장하므로 별도의 관리 메모리가 필요 없습니다.

flowchart LR
  head[free head] --> B2[블록 2]
  B2 --> B5[블록 5]
  B5 --> B7[블록 7]
  B7 --> null[nullptr]
  U1[블록 1: 사용 중]
  U3[블록 3: 사용 중]
// fixed_block_pool.hpp
#pragma once
#include <algorithm>
#include <cstddef>
#include <new>
#include <vector>

class FixedBlockPool {
public:
    explicit FixedBlockPool(std::size_t block_size, std::size_t blocks_per_chunk = 64)
        : block_size_(round_up(std::max(block_size, sizeof(Node)), kAlign)),
          blocks_per_chunk_(blocks_per_chunk) {}

    ~FixedBlockPool() {
        for (void* chunk : chunks_) ::operator delete(chunk);   // 블록이 아니라 청크 단위로 해제
    }
    FixedBlockPool(const FixedBlockPool&) = delete;
    FixedBlockPool& operator=(const FixedBlockPool&) = delete;

    void* allocate() {
        if (!head_) expand();
        Node* n = head_;
        head_ = n->next;
        return n;
    }

    void deallocate(void* p) noexcept {
        if (!p) return;
        Node* n = static_cast<Node*>(p);
        n->next = head_;
        head_ = n;
    }

    std::size_t block_size() const { return block_size_; }

private:
    struct Node { Node* next; };
    static constexpr std::size_t kAlign = alignof(std::max_align_t);
    static std::size_t round_up(std::size_t n, std::size_t a) { return (n + a - 1) / a * a; }

    void expand() {
        char* chunk = static_cast<char*>(::operator new(block_size_ * blocks_per_chunk_));
        chunks_.push_back(chunk);
        for (std::size_t i = blocks_per_chunk_; i-- > 0;) {
            deallocate(chunk + i * block_size_);   // 앞쪽 블록이 먼저 나가도록 역순으로 연결
        }
    }

    std::size_t block_size_;
    std::size_t blocks_per_chunk_;
    Node* head_ = nullptr;
    std::vector<void*> chunks_;
};

블록은 청크 안의 일부일 뿐이라 개별적으로 operator delete에 넘기면 안 됩니다. 풀이 파괴될 때는 할당했던 청크를 따로 기록해 두었다가 청크 단위로 해제해야 합니다. 블록 크기를 alignof(std::max_align_t)의 배수로 올리는 것은, 청크 시작 주소가 그 정렬을 보장하므로 모든 블록도 같은 정렬을 갖게 하기 위해서입니다. 블록 크기가 12바이트처럼 정렬 단위의 배수가 아니면 두 번째 블록부터 오정렬됩니다. 이 풀은 정렬이 max_align_t보다 큰 타입(예: alignas(32))을 지원하지 않으며, 풀이 살아 있는 동안 청크를 운영체제에 돌려주지도 않습니다. 한때 많이 할당했다면 그 메모리는 풀이 계속 쥐고 있습니다.

template <typename T, typename... Args>
T* pool_new(FixedBlockPool& pool, Args&&... args) {
    void* p = pool.allocate();
    try {
        return new (p) T(std::forward<Args>(args)...);
    } catch (...) {
        pool.deallocate(p);   // 생성자가 던지면 블록을 돌려줌
        throw;
    }
}

template <typename T>
void pool_delete(FixedBlockPool& pool, T* obj) noexcept {
    if (!obj) return;
    obj->~T();
    pool.deallocate(obj);
}

타입 전용 객체 풀

특정 타입 전용이라면 블록 크기와 정렬을 타입에서 바로 정할 수 있고, 반환을 std::unique_ptr의 삭제자로 자동화할 수 있습니다.

// object_pool.hpp
#pragma once
#include <cstddef>
#include <memory>
#include <new>
#include <utility>
#include <vector>

template <typename T, std::size_t BlocksPerChunk = 64>
class ObjectPool {
    union Slot {
        Slot* next;
        alignas(T) unsigned char storage[sizeof(T)];
    };

public:
    struct Deleter {
        ObjectPool* pool;
        void operator()(T* p) const noexcept { pool->release(p); }
    };
    using Ptr = std::unique_ptr<T, Deleter>;

    ObjectPool() = default;
    ObjectPool(const ObjectPool&) = delete;
    ObjectPool& operator=(const ObjectPool&) = delete;
    ~ObjectPool() = default;   // 청크는 chunks_가 해제. 남은 객체의 소멸자는 호출되지 않으므로 먼저 모두 반환해야 함

    template <typename... Args>
    Ptr make(Args&&... args) {
        Slot* s = pop();
        try {
            return Ptr(new (s->storage) T(std::forward<Args>(args)...), Deleter{this});
        } catch (...) {
            push(s);
            throw;
        }
    }

private:
    void release(T* p) noexcept {
        p->~T();
        push(reinterpret_cast<Slot*>(p));   // storage는 Slot의 시작 주소와 같음
    }
    Slot* pop() {
        if (!free_) grow();
        Slot* s = free_;
        free_ = s->next;
        return s;
    }
    void push(Slot* s) noexcept {
        s->next = free_;
        free_ = s;
    }
    void grow() {
        auto chunk = std::make_unique<Slot[]>(BlocksPerChunk);
        for (std::size_t i = BlocksPerChunk; i-- > 0;) push(&chunk[i]);
        chunks_.push_back(std::move(chunk));
    }

    Slot* free_ = nullptr;
    std::vector<std::unique_ptr<Slot[]>> chunks_;
};
struct Bullet { float x, y, vx, vy; };

ObjectPool<Bullet> pool;
{
    auto b = pool.make(Bullet{100.0f, 200.0f, 5.0f, 0.0f});
    b->x += b->vx;
}   // 스코프를 벗어나면 자동으로 풀에 반환

Slot을 T의 크기와 정렬을 가진 저장 공간과 다음 포인터의 공용체로 정의했기 때문에, 블록 크기는 max(sizeof(T), sizeof(Slot*))이 되고 정렬도 자동으로 맞습니다. 반환 함수를 unique_ptr 삭제자로 넘기면 반환 누락과 이중 반환을 타입 시스템이 막아 줍니다. 단, 풀이 그 포인터들보다 오래 살아야 합니다.


아레나

아레나는 큰 버퍼를 확보해 두고 커서를 앞으로만 옮기며 메모리를 잘라 줍니다. 개별 해제는 없고, 전체를 한 번에 되돌립니다. 할당은 정렬 계산과 덧셈 한 번이고, 해제는 아예 없으므로 가장 빠른 할당 방식입니다. 대신 아레나에 만든 객체의 소멸자는 호출되지 않으므로, 소멸자가 해야 할 일이 없는 타입(또는 아레나에서만 메모리를 얻는 타입)에만 써야 합니다.

// memory_arena.hpp
#pragma once
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <vector>

class MemoryArena {
public:
    explicit MemoryArena(std::size_t chunk_size = 64 * 1024) : chunk_size_(chunk_size) {}
    MemoryArena(const MemoryArena&) = delete;
    MemoryArena& operator=(const MemoryArena&) = delete;

    void* allocate(std::size_t size, std::size_t align = alignof(std::max_align_t)) {
        if (current_ < chunks_.size()) {
            if (void* p = try_alloc(chunks_[current_], size, align)) return p;
            ++current_;   // 남은 청크가 있으면 다음 청크로
            while (current_ < chunks_.size()) {
                chunks_[current_].used = 0;
                if (void* p = try_alloc(chunks_[current_], size, align)) return p;
                ++current_;
            }
        }
        // 요청이 청크보다 크면 그 크기에 맞는 청크를 새로 만든다
        std::size_t cap = std::max(chunk_size_, size + align);
        chunks_.push_back(Chunk{std::make_unique<std::byte[]>(cap), cap, 0});
        current_ = chunks_.size() - 1;
        return try_alloc(chunks_.back(), size, align);
    }

    // 모든 할당을 무효화하고 처음 청크부터 다시 사용 (메모리는 반납하지 않음)
    void reset() noexcept {
        current_ = 0;
        if (!chunks_.empty()) chunks_[0].used = 0;
    }

private:
    struct Chunk {
        std::unique_ptr<std::byte[]> data;
        std::size_t capacity;
        std::size_t used;
    };

    static void* try_alloc(Chunk& c, std::size_t size, std::size_t align) {
        auto base = reinterpret_cast<std::uintptr_t>(c.data.get());
        std::uintptr_t start = (base + c.used + align - 1) & ~(std::uintptr_t(align) - 1);
        if (start + size > base + c.capacity) return nullptr;
        c.used = start + size - base;
        return reinterpret_cast<void*>(start);
    }

    std::size_t chunk_size_;
    std::vector<Chunk> chunks_;
    std::size_t current_ = 0;
};

정렬은 청크 안의 오프셋이 아니라 실제 주소를 기준으로 계산해야, 청크 시작 주소의 정렬보다 큰 정렬 요구도 맞출 수 있습니다(align은 2의 거듭제곱이어야 합니다). 청크보다 큰 요청은 그 크기에 맞는 청크를 따로 만들어 처리합니다. 이 처리 없이 고정 크기 청크를 하나 더 붙이기만 하면 큰 요청이 청크 끝을 넘어 씁니다. reset()은 첫 청크부터 다시 쓰도록 되돌리므로, 프레임이나 요청마다 reset()하면 처음 몇 번 이후로는 운영체제에 메모리를 요청하지 않습니다.

전체를 비우는 대신 중간 지점까지만 되돌리고 싶다면 아레나에 마커를 두면 됩니다. 현재 위치(청크 번호와 used)를 저장해 두었다가 그 값으로 되돌리면, 마커 이후에 할당한 것만 한꺼번에 버려집니다. 게임 엔진에서 스택 할당자라고 부르는 것이 이 방식으로, 레벨 로딩이나 파싱처럼 단계별로 임시 버퍼를 쌓았다가 단계가 끝나거나 실패하면 그 단계 시작 시점으로 되돌리는 데 씁니다. 되돌리기는 반드시 나중에 잡은 마커부터 역순(LIFO)이어야 합니다. 앞선 마커로 되돌린 뒤에도 그 이후에 받은 포인터를 계속 쓰면, 다음 할당이 같은 메모리를 다시 내주면서 데이터가 조용히 덮어써집니다.


std::pmr

C++17의 std::pmr은 할당 전략을 템플릿 인자가 아니라 실행 중에 넘기는 객체(std::pmr::memory_resource)로 표현합니다. std::pmr::vector<T>는 어떤 리소스를 쓰든 같은 타입이므로, 할당 전략이 다른 컨테이너도 같은 함수에 넘길 수 있습니다.

monotonic_buffer_resource: 표준 아레나

#include <array>
#include <cstddef>
#include <functional>
#include <map>
#include <memory_resource>
#include <string>
#include <string_view>
#include <vector>

void handleRequest(std::string_view raw) {
    std::array<std::byte, 16 * 1024> buffer;   // 처음 16KB는 스택에서
    std::pmr::monotonic_buffer_resource arena{buffer.data(), buffer.size()};   // 넘치면 기본 리소스(new/delete)

    std::pmr::vector<std::pmr::string> path_segments{&arena};
    std::pmr::map<std::pmr::string, std::pmr::string, std::less<>> headers{&arena};

    path_segments.emplace_back("api");          // 내부 문자열도 같은 arena 사용
    headers.emplace("Content-Type", "application/json");
    // ... raw를 파싱하고 처리 ...
}   // arena가 파괴되며 모든 메모리를 한 번에 반납

pmr 컨테이너는 원소를 만들 때 자신의 리소스를 원소에게도 넘겨 주므로(uses-allocator 생성), 벡터 안의 pmr::string과 맵의 키·값 문자열까지 모두 같은 아레나에서 메모리를 얻습니다. 원소 타입을 std::string으로 두면 문자열 내용은 일반 힙에 할당되므로 원소도 pmr 타입이어야 합니다. 이 리소스의 deallocate는 아무 일도 하지 않으므로, 벡터가 커지며 재할당될 때 버린 옛 버퍼도 아레나가 파괴될 때까지 남아 있습니다. 반복해서 커지는 컨테이너라면 reserve로 미리 크기를 잡는 것이 좋습니다.

pool_resource와 직접 만든 리소스

unsynchronized_pool_resource(한 스레드 전용)와 synchronized_pool_resource(스레드 안전)는 여러 크기 등급별 풀을 관리하며, 일정 크기를 넘는 요청은 상위 리소스로 넘깁니다. 고정 블록 풀을 pmr 리소스로 감쌀 수도 있습니다.

#include <list>
#include <memory_resource>
#include "fixed_block_pool.hpp"

class FixedPoolResource : public std::pmr::memory_resource {
public:
    explicit FixedPoolResource(std::size_t block_size,
                               std::pmr::memory_resource* upstream = std::pmr::get_default_resource())
        : pool_(block_size), upstream_(upstream) {}

private:
    bool fits(std::size_t bytes, std::size_t align) const {
        return bytes <= pool_.block_size() && align <= alignof(std::max_align_t);
    }
    void* do_allocate(std::size_t bytes, std::size_t align) override {
        return fits(bytes, align) ? pool_.allocate() : upstream_->allocate(bytes, align);
    }
    void do_deallocate(void* p, std::size_t bytes, std::size_t align) override {
        if (fits(bytes, align)) pool_.deallocate(p);
        else upstream_->deallocate(p, bytes, align);
    }
    bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
        return this == &other;
    }

    FixedBlockPool pool_;
    std::pmr::memory_resource* upstream_;
};

// 노드 기반 컨테이너(list, map, set)는 노드 하나씩 같은 크기로 할당하므로 고정 블록 풀과 잘 맞음
FixedPoolResource res(64);
std::pmr::list<int> items{&res};

할당 요청의 크기와 정렬을 모두 확인해 풀이 감당할 수 없으면 상위 리소스로 넘깁니다. deallocate에 넘어오는 크기와 정렬은 allocate 때와 같다는 것이 memory_resource의 계약이라, 해제 시에도 같은 판단으로 어느 쪽에 돌려줄지 알 수 있습니다. std::pmr::vector처럼 큰 연속 버퍼를 할당하는 컨테이너는 고정 블록 풀의 이점을 거의 받지 못하므로, 이런 리소스는 노드 기반 컨테이너에 주로 씁니다.


자주 생기는 버그

풀이 객체보다 먼저 사라짐

void* create() {
    FixedBlockPool pool(256);
    return pool.allocate();   // 함수가 끝나면 pool이 청크를 해제
}

풀이나 아레나에서 받은 메모리는 그 풀이 살아 있는 동안만 유효합니다. 풀을 멤버로 가진 객체가 풀에서 할당한 객체를 다른 곳에 넘기는 구조에서 자주 생깁니다. 위의 ObjectPool::Ptr처럼 삭제자가 풀을 가리키게 하면, 적어도 풀보다 오래 사는 포인터를 만들기 어렵게 할 수 있습니다.

다른 풀에 반환, 이중 반환

풀 A에서 받은 블록을 풀 B에 돌려주거나 같은 블록을 두 번 돌려주면 free list가 망가집니다. 이중 반환된 블록은 free list에 두 번 들어가 서로 다른 두 객체에게 같은 메모리를 내주게 되고, 그 결과는 한참 뒤에 엉뚱한 곳에서 데이터 손상으로 나타납니다. 직접 만든 풀은 일반 malloc처럼 AddressSanitizer가 이런 오류를 잡아 주지 않으므로, 디버그 빌드에서는 풀을 거치지 않고 new/delete로 바로 할당하는 스위치를 두면 ASan으로 검사할 수 있습니다. ASan의 수동 오염 표시 API(ASAN_POISON_MEMORY_REGION)로 반환된 블록을 표시하는 방법도 있습니다.

확장하지 않는 풀의 소진

위의 FixedBlockPool은 비면 청크를 새로 붙이지만, 실시간 코드에서는 할당 시간의 상한을 지키려고 블록 수를 고정하고 확장하지 않는 풀을 쓰기도 합니다. 이런 풀은 비었을 때 nullptr을 돌려주거나 예외를 던지는데, 호출부가 이를 처리하지 않으면 부하가 몰리는 순간에만 크래시가 납니다. 최대 동시 사용량을 알 수 있다면 그보다 넉넉하게 잡고, 모르겠다면 풀에서 최대 동시 사용량을 기록해 두고 그 값으로 크기를 조정하거나 소진 시 상위 할당기로 넘기는 경로를 명시적으로 둡니다.

블록보다 큰 객체

FixedBlockPool pool(256)에서 512바이트 구조체를 할당하면 인접 블록을 덮어씁니다. 풀을 타입 전용으로 만들거나(위 ObjectPool), 크기를 받는 할당 함수에서 블록보다 큰 요청을 상위 할당기로 넘기고 해제 때도 같은 크기로 판단하게 합니다.

스레드 사이에서 공유

위의 풀과 아레나는 모두 동기화를 하지 않습니다. 여러 스레드가 하나의 풀을 락 없이 쓰면 free list가 손상됩니다. thread_local로 스레드마다 풀을 두면 동기화 없이 쓸 수 있지만, 한 스레드에서 할당한 블록을 다른 스레드가 해제하면 그 블록이 다른 스레드의 풀에 들어가거나(해제한 스레드의 풀 사용 시) 데이터 레이스가 됩니다. 생산자 스레드가 메시지를 만들고 소비자 스레드가 처리 후 버리는 구조라면 스레드 로컬 풀은 맞지 않고, 스레드 간 반환을 처리하는 할당기(jemalloc, mimalloc 등)나 synchronized_pool_resource가 낫습니다.

아레나 객체의 소멸자

아레나는 메모리를 한꺼번에 버릴 뿐 그 안의 객체들의 소멸자를 부르지 않습니다. 아레나에 std::string(일반 할당자 사용)이나 파일 핸들을 가진 객체를 만들면, 그 객체가 따로 잡은 힙 메모리나 핸들은 누수됩니다. 아레나에는 소멸자가 하는 일이 없는 타입이나, 위처럼 메모리를 모두 같은 아레나에서 얻는 pmr 타입을 둡니다.


도입 전에 측정하기

할당 전략을 바꾸기 전에 먼저 할당이 실제 병목인지 확인해야 합니다. Linux라면 perf record -g로 호출 그래프를 기록해 malloc, free, operator new가 차지하는 비율을 보고, 할당 횟수와 크기 분포는 heaptrack이나 jemalloc의 프로파일링 기능으로 볼 수 있습니다.

할당이 병목이라면 가장 먼저 해 볼 수 있는 것은 할당기 교체입니다. jemalloc, tcmalloc, mimalloc은 스레드별 캐시와 크기 등급별 관리로 범용 상황에서 기본 할당기보다 나은 경우가 많고, 코드를 고치지 않고 링크하거나 LD_PRELOAD로 바꿔 끼울 수 있습니다. 단편화로 메모리가 계속 늘어나는 문제도 할당기 교체만으로 나아지는 경우가 있습니다. 어느 쪽이 나은지는 워크로드에 따라 다르므로 실제 프로그램으로 비교해야 합니다.

마이크로벤치마크로 풀을 비교할 때는 결과를 해석할 때 주의할 점이 있습니다. 같은 크기의 할당과 해제만 반복하는 벤치마크에서는 free list 풀이 범용 할당기보다 몇 배 빠르게 나오는 것이 보통이지만, 그 격차는 기준 할당기에 크게 좌우됩니다. Windows의 기본 CRT 힙과 비교한 결과와 glibc tcache와 비교한 결과는 다르고, jemalloc이나 mimalloc과 비교하면 더 줄어듭니다. 표준 pmr 풀은 여러 크기 등급과 상위 리소스 위임을 처리하는 범용 구현이라 단일 크기 free list보다 할 일이 많습니다. 무엇보다 실제 프로그램에서 할당이 전체 시간의 일부일 뿐이라면, 할당을 몇 배 빠르게 해도 전체 개선은 그 비율만큼에 그칩니다.

전략맞는 경우주의할 점
할당기 교체 (jemalloc 등)코드 변경 없이 전반적인 개선을 시도할 때워크로드별로 결과가 다름
커스텀 할당자STL 컨테이너에 특정 메모리 소스를 연결할 때할당자가 타입의 일부가 됨
고정 블록 풀 / 객체 풀같은 크기 객체의 빈번한 생성·삭제수명, 이중 반환, 스레드 공유
아레나수명이 같은 묶음을 한꺼번에 버릴 때소멸자 미호출, 개별 해제 불가
std::pmr컨테이너 타입을 바꾸지 않고 전략을 바꿀 때원소도 pmr 타입이어야 함, 가상 호출 비용

참고 자료


같이 보면 좋은 글