std::pmr로 메모리 할당 최적화하기 | Polymorphic Memory Resource 활용법

들어가며: malloc/new가 병목일 때

프로파일링 가이드와 메모리 풀 패턴에서 성능과 메모리를 다뤘다면, 이번에는 할당 자체가 프로파일 상위를 차지하는 경우를 다룹니다. 작은 객체를 많이 할당하고 해제하면 힙 단편화(작은 빈 공간이 흩어져 큰 연속 메모리를 할당하기 어려워지는 현상)와 할당기 자체의 오버헤드가 누적됩니다.

메모리 풀은 큰 블록을 미리 한 번 할당하고 그 안에서 작은 조각을 나눠 주어 전역 할당 횟수를 줄이는 방식입니다. C++17의 std::pmr(polymorphic memory resources)는 std::pmr::memory_resource라는 추상 인터페이스를 기반으로, 컨테이너 타입을 바꾸지 않고도 어떤 풀에서 메모리를 가져올지 런타임에 정할 수 있게 해 줍니다.


std::pmr가 해결하려는 문제

할당자가 타입의 일부라는 한계

C++98부터 STL 컨테이너는 할당자를 템플릿 인자로 받았습니다. 할당 전략을 바꿀 수는 있었지만, 할당자가 타입의 일부가 되는 대가가 따랐습니다.

// 할당자가 타입의 일부
template<typename T, typename Allocator = std::allocator<T>>
class vector { /* ... */ };

std::vector<int, std::allocator<int>> v1;
std::vector<int, MyPoolAllocator<int>> v2;

// v1과 v2는 서로 다른 타입이라 같은 함수에 넘길 수 없음
void process(std::vector<int, std::allocator<int>>& vec);
// process(v2);  // 컴파일 에러

그래서 풀을 쓰려면 함수 시그니처를 템플릿으로 만들거나 할당자마다 오버로드해야 했습니다. 큰 코드베이스에서 이 비용이 커지자, Bloomberg의 BDE 라이브러리는 할당자를 가상 함수 인터페이스를 가진 런타임 객체로 다루는 방식을 오래전부터 써 왔습니다. 이 설계를 바탕으로 Pablo Halpern이 제안한 N3525(“Polymorphic Allocators”)가 Library Fundamentals TS를 거쳐 C++17(P0220)에 std::pmr로 들어왔습니다.

memory_resource와 polymorphic_allocator

핵심은 할당 전략을 템플릿 인자가 아닌 가상 함수 인터페이스로 표현한 것입니다.

// 표준의 memory_resource 인터페이스 (요약)
class memory_resource {
public:
    virtual ~memory_resource();

    void* allocate(size_t bytes, size_t alignment = alignof(max_align_t)) {
        return do_allocate(bytes, alignment);   // 가상 호출
    }
    void deallocate(void* p, size_t bytes, size_t alignment = alignof(max_align_t)) {
        do_deallocate(p, bytes, alignment);
    }
    bool is_equal(const memory_resource& other) const noexcept {
        return do_is_equal(other);
    }

private:
    virtual void* do_allocate(size_t bytes, size_t alignment) = 0;
    virtual void  do_deallocate(void* p, size_t bytes, size_t alignment) = 0;
    virtual bool  do_is_equal(const memory_resource& other) const noexcept = 0;
};

std::pmr::polymorphic_allocator<T>는 memory_resource* 하나만 들고 있는 할당자입니다. std::pmr::vector<T>는 std::vector<T, std::pmr::polymorphic_allocator<T>>의 별칭이므로, 어떤 풀을 쓰든 타입은 같고 생성할 때 넘긴 memory_resource*만 다릅니다. std::pmr 네임스페이스에는 vector, string, map, unordered_map, list, deque 등 표준 컨테이너마다 이런 별칭이 정의되어 있습니다.

가상 호출 비용은 얼마나 되나

모든 할당과 해제가 가상 호출 한 번을 거칩니다. vtable 포인터와 함수 포인터를 읽고 간접 호출하는 비용이라, 캐시에 있고 분기 예측이 맞으면 수 나노초 수준입니다. 반면 범용 malloc은 크기 등급을 찾고, 스레드 캐시가 비면 공유 아레나의 락을 잡고, 필요하면 mmap이나 brk 시스템 콜까지 갑니다. 대부분의 경우 가상 호출 비용은 이런 비용에 비해 작기 때문에 pmr는 유연성을 위해 그 비용을 받아들인 설계입니다.

다만 요즘 할당기(glibc의 tcache, jemalloc, mimalloc)는 스레드별 캐시로 작은 할당을 매우 빠르게 처리하므로, 격차는 플랫폼과 부하 패턴에 따라 크게 달라집니다. 할당당 일이 포인터를 한 번 미는 정도로 작고 호출 빈도가 극단적으로 높은 루프라면 가상 호출이 상대적으로 커 보일 수 있으므로, 이때는 do_allocate가 프로파일 상위에 보이는지 확인하고 해당 핫패스에는 비가상 아레나를 직접 쓰는 방법을 검토합니다.

monotonic_buffer_resource가 빠른 이유

std::pmr가 제공하는 리소스 중 가장 큰 이득을 주는 경우가 많은 것은 monotonic_buffer_resource입니다. 개념적으로는 범프(bump) 할당기입니다.

// monotonic_buffer_resource의 개념 (의사 코드)
class monotonic_buffer_resource {
    char* current_;   // 현재 할당 위치
    char* end_;       // 현재 블록 끝

    void* do_allocate(size_t bytes, size_t align) {
        char* aligned = align_up(current_, align);
        if (aligned + bytes > end_) {
            // 업스트림에서 더 큰 새 블록을 받아 이어 붙임
            grow(bytes, align);
            aligned = align_up(current_, align);
        }
        current_ = aligned + bytes;   // 포인터만 앞으로 이동
        return aligned;
    }

    void do_deallocate(void*, size_t, size_t) {
        // 아무것도 하지 않음. 메모리는 release()나 소멸 시 한꺼번에 반환
    }
};

할당은 정렬 보정과 포인터 이동뿐이고, 해제는 아무 일도 하지 않으며, 할당된 객체가 메모리상에 연속으로 놓여 캐시 지역성도 좋습니다. 리소스 자체는 스레드 안전하지 않으므로 스레드별로 따로 두면 동기화도 필요 없습니다. 게임의 프레임별 임시 객체나 HTTP 요청을 처리하는 동안 만드는 문자열처럼, 수명이 같은 묶음을 한꺼번에 버리는 패턴에서 이 구조가 가장 큰 차이를 냅니다. 반대로 한 번 만들고 오래 쓰는 객체라면 차이를 느끼기 어렵습니다.


malloc이 병목일 때: HTTP 파서 Before/After

원인 후보

할당이 병목으로 보일 때 원인은 대개 네 가지 중 하나입니다. 작은 객체를 반복 할당·해제해 할당기 호출 자체가 많은 경우, 힙 단편화로 메모리 사용량이 계속 늘어나는 경우, 할당된 객체들이 메모리 여기저기에 흩어져 캐시 미스가 많은 경우, 그리고 멀티스레드 환경에서 할당기 내부 락을 두고 경합하는 경우입니다. 메모리 풀은 앞의 세 가지를 완화하고, 스레드별 풀은 마지막 경합 문제를 완화합니다.

시나리오특징권장 리소스
게임 프레임매 프레임 생성/해제, 수명이 짧음monotonic_buffer_resource + release()
HTTP 요청요청 단위로 할당, 요청 끝에 해제monotonic_buffer_resource
노드 기반 자료구조고정 크기 노드 반복 할당/해제unsynchronized_pool_resource (공유 시 synchronized_)
장기 객체수명이 길고 크기가 다양함기본 힙 또는 pool_resource
파싱/직렬화임시 버퍼 다수, 스코프 끝에 해제monotonic_buffer_resource

Before/After: HTTP 파서

기본 할당을 쓰면 요청마다 vector, map, string이 힙에서 반복 할당됩니다.

// 기본 할당 — 요소 하나, 노드 하나마다 malloc
void handleRequest(const std::string& raw) {
    std::vector<std::string> path_segments;
    std::map<std::string, std::string> headers;
    std::string body;
    parsePath(raw, path_segments);
    parseHeaders(raw, headers);
    parseBody(raw, body);
    process(path_segments, headers, body);
}

pmr를 적용하면 요청 스코프의 리소스에서 할당하고, 함수가 끝날 때 리소스와 함께 한꺼번에 해제됩니다.

// pmr — 요청 스코프 아레나에서 할당
void handleRequest(const std::string& raw) {
    std::pmr::monotonic_buffer_resource request_pool;
    std::pmr::vector<std::pmr::string> path_segments(&request_pool);
    std::pmr::map<std::pmr::string, std::pmr::string, std::less<>>
        headers(&request_pool);
    std::pmr::string body(&request_pool);
    parsePath(raw, path_segments);
    parseHeaders(raw, headers);
    parseBody(raw, body);
    process(path_segments, headers, body);
}  // 컨테이너가 먼저 소멸한 뒤 request_pool 소멸 → 모든 블록 반환

parsePath 같은 함수의 매개변수 타입도 std::pmr::vector<std::pmr::string>&로 바뀌어야 한다는 점은 도입 비용입니다. path_segments.emplace_back(...)으로 넣은 pmr::string은 다음 절에서 설명할 uses-allocator 생성 덕분에 자동으로 같은 request_pool을 씁니다.

sequenceDiagram
    participant Req as handleRequest
    participant Pool as monotonic_buffer_resource
    participant Vec as pmr::vector
    participant Map as pmr::map
    Req->>Pool: 생성 (스코프 진입)
    Req->>Vec: path_segments(&pool)
    Req->>Map: headers(&pool)
    Req->>Vec: push_back, parsePath...
    Req->>Map: insert, parseHeaders...
    Req->>Req: process()
    Req->>Pool: 소멸 (스코프 종료)
    Note over Pool: 모든 할당 일괄 해제

std::pmr 기본 사용

빌드 및 실행

# C++17 이상 필요
g++ -std=c++17 -O2 -o pmr_demo pmr_demo.cpp
./pmr_demo

libstdc++는 GCC 9부터 <memory_resource>를 완전히 지원하고, libc++는 비교적 최근 버전(LLVM 16 무렵)에야 std::pmr가 실험 플래그 없이 제공되었으므로, macOS의 Apple Clang에서는 지원 여부를 먼저 확인해야 합니다.

기본 사용 예

#include <cstddef>
#include <memory_resource>
#include <vector>

int main() {
    alignas(std::max_align_t) std::byte buffer[1024];
    std::pmr::monotonic_buffer_resource pool{buffer, sizeof(buffer)};
    std::pmr::vector<int> v(&pool);
    v.push_back(1);
    v.push_back(2);
    // v의 할당은 buffer 안에서 이루어짐
}

구조

flowchart TB
    subgraph Container[pmr 컨테이너]
        V["std pmr vector"]
        M["std pmr map"]
        S["std pmr string"]
    end
    subgraph Alloc[polymorphic_allocator]
        PA[memory_resource*]
    end
    subgraph Resources[memory_resource 구현체]
        MONO[monotonic_buffer_resource]
        POOL[synchronized_pool_resource]
        CUSTOM[커스텀 리소스]
    end
    subgraph Backend[업스트림]
        HEAP[new_delete_resource]
        BUF[스택/정적 버퍼]
    end
    V --> PA
    M --> PA
    S --> PA
    PA --> MONO
    PA --> POOL
    PA --> CUSTOM
    MONO --> BUF
    MONO --> HEAP
    POOL --> HEAP
    CUSTOM --> HEAP

monotonic_buffer_resource 예제

monotonic_buffer_resource는 주어진 버퍼(또는 업스트림 리소스)에서 순차적으로 메모리를 잘라 씁니다. deallocate는 아무 일도 하지 않으므로, 메모리는 release()를 호출하거나 리소스가 소멸할 때 한꺼번에 돌아갑니다. 생성자에 업스트림을 지정하지 않으면 std::pmr::get_default_resource()가 업스트림이 되는데, 기본 리소스를 바꾸지 않았다면 이는 new_delete_resource()입니다.

예제 1: 스택 버퍼 + monotonic

#include <array>
#include <cstddef>
#include <memory_resource>
#include <string>
#include <vector>

void processRequest() {
    // 64KB 스택 버퍼. 스레드 스택 크기(Windows 기본 1MB 등)를 고려해 정함
    alignas(std::max_align_t) std::array<std::byte, 65536> stack_buffer;
    std::pmr::monotonic_buffer_resource pool{
        stack_buffer.data(), stack_buffer.size(),
        std::pmr::new_delete_resource()   // 버퍼를 넘으면 힙 사용
    };
    std::pmr::vector<int> ids(&pool);
    std::pmr::vector<std::pmr::string> tokens(&pool);
    for (int i = 0; i < 1000; ++i) {
        ids.push_back(i);
        tokens.emplace_back("token");   // 요소 string도 pool에서 할당
    }
}  // tokens, ids, pool 순서로 소멸

예제 2: 프레임 풀 + release()

#include <array>
#include <cstddef>
#include <memory_resource>
#include <vector>

struct Entity { int id; float x, y; };
struct Component { int type; void* data; };

void gameLoop() {
    // 1MB는 스택에 두기엔 크므로 static으로 둠
    alignas(std::max_align_t) static std::array<std::byte, 1024 * 1024> frame_buffer;
    std::pmr::monotonic_buffer_resource frame_pool{
        frame_buffer.data(), frame_buffer.size(),
        std::pmr::new_delete_resource()
    };
    while (running) {
        {
            std::pmr::vector<Entity> entities(&frame_pool);
            std::pmr::vector<Component> components(&frame_pool);
            // ... 엔티티 생성, 프레임 로직 ...
        }  // 이 프레임의 컨테이너가 모두 소멸한 뒤에
        frame_pool.release();  // 버퍼를 처음부터 다시 쓰도록 리셋
    }
}

release()는 업스트림에서 받아 온 추가 블록을 반환하고 커서를 초기 버퍼의 시작으로 되돌립니다. 이 리소스를 쓰는 컨테이너가 아직 살아 있을 때 release()를 부르면, 그 컨테이너는 이미 반환된 메모리를 가리키게 되므로 순서가 중요합니다.

예제 3: 여러 pmr 컨테이너가 같은 풀 공유

#include <map>
#include <memory_resource>
#include <string>
#include <vector>

int main() {
    std::pmr::monotonic_buffer_resource pool;
    std::pmr::vector<int> nums(&pool);
    std::pmr::vector<std::pmr::string> names(&pool);
    std::pmr::map<std::pmr::string, int, std::less<>> scores(&pool);
    nums.push_back(42);
    names.emplace_back("Alice");
    scores["Bob"] = 100;
    // 노드와 문자열 버퍼 모두 pool에서 할당
}

pool_resource 예제

synchronized_pool_resource와 unsynchronized_pool_resource는 크기 등급별로 고정 크기 블록 풀을 관리하고, 해제된 블록을 자유 목록에 넣어 재사용합니다. synchronized_는 여러 스레드가 동시에 써도 안전하고, unsynchronized_는 단일 스레드 전용인 대신 동기화 비용이 없습니다.

예제 1: pool_options로 블록 크기 조정

#include <memory_resource>

int main() {
    std::pmr::pool_options opts;
    opts.max_blocks_per_chunk = 32;          // 업스트림에서 한 번에 가져올 블록 수 상한
    opts.largest_required_pool_block = 256;  // 풀로 관리할 최대 블록 크기
    std::pmr::synchronized_pool_resource pool{opts};
    // 256바이트보다 큰 요청은 풀을 거치지 않고 업스트림에서 직접 할당
}

두 값은 힌트이므로 구현이 다른 값으로 조정할 수 있습니다. largest_required_pool_block보다 큰 요청은 업스트림으로 바로 넘어가므로, 이 값을 너무 작게 잡으면 풀의 이점이 사라집니다.

예제 2: 노드 기반 자료구조에 pool 적용

#include <list>
#include <memory_resource>

struct TreeNode {
    int value;
    TreeNode* left = nullptr;
    TreeNode* right = nullptr;
};

void buildList(std::pmr::memory_resource* mr) {
    std::pmr::list<TreeNode> nodes(mr);
    for (int i = 0; i < 1000; ++i) {
        nodes.push_back(TreeNode{i});
    }
}  // list 소멸 시 노드 블록이 풀의 자유 목록으로 돌아감

int main() {
    std::pmr::unsynchronized_pool_resource pool;   // 단일 스레드에서만 사용
    buildList(&pool);
    buildList(&pool);   // 두 번째 호출은 첫 호출이 반환한 블록을 재사용
}

std::list의 노드는 모두 같은 크기이므로 pool_resource와 잘 맞습니다. 반대로 std::vector처럼 크기가 계속 바뀌며 큰 버퍼를 다시 잡는 컨테이너는 큰 요청이 풀 범위를 넘어 업스트림으로 가는 경우가 많아 이득이 작습니다.


커스텀 memory_resource 구현

예제 1: 로깅 memory_resource

할당과 해제를 로그로 남기는 디버깅용 리소스입니다.

#include <cstddef>
#include <iostream>
#include <memory_resource>

class logging_memory_resource : public std::pmr::memory_resource {
public:
    explicit logging_memory_resource(std::pmr::memory_resource* upstream
        = std::pmr::get_default_resource())
        : upstream_(upstream) {}
private:
    void* do_allocate(std::size_t bytes, std::size_t alignment) override {
        void* p = upstream_->allocate(bytes, alignment);
        std::cout << "[alloc] " << bytes << " bytes, align " << alignment
                  << " -> " << p << "\n";
        return p;
    }
    void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
        std::cout << "[dealloc] " << bytes << " bytes @ " << p << "\n";
        upstream_->deallocate(p, bytes, alignment);
    }
    bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
        return this == &other;
    }
    std::pmr::memory_resource* upstream_;
};

예제 2: 통계 수집 memory_resource

#include <atomic>
#include <cstddef>
#include <memory_resource>

class stats_memory_resource : public std::pmr::memory_resource {
public:
    explicit stats_memory_resource(std::pmr::memory_resource* upstream
        = std::pmr::get_default_resource())
        : upstream_(upstream) {}
    std::size_t allocation_count() const noexcept { return alloc_count_.load(); }
    std::size_t current_bytes() const noexcept { return current_.load(); }
    std::size_t peak_bytes() const noexcept { return peak_.load(); }
private:
    void* do_allocate(std::size_t bytes, std::size_t alignment) override {
        void* p = upstream_->allocate(bytes, alignment);
        alloc_count_.fetch_add(1, std::memory_order_relaxed);
        std::size_t now = current_.fetch_add(bytes, std::memory_order_relaxed) + bytes;
        std::size_t peak = peak_.load(std::memory_order_relaxed);
        while (now > peak &&
               !peak_.compare_exchange_weak(peak, now, std::memory_order_relaxed)) {
            // 실패하면 peak가 최신 값으로 갱신되어 다시 비교
        }
        return p;
    }
    void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
        current_.fetch_sub(bytes, std::memory_order_relaxed);
        upstream_->deallocate(p, bytes, alignment);
    }
    bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
        return this == &other;
    }
    std::pmr::memory_resource* upstream_;
    std::atomic<std::size_t> alloc_count_{0};
    std::atomic<std::size_t> current_{0};
    std::atomic<std::size_t> peak_{0};
};

이 래퍼를 monotonic의 업스트림으로 넣으면, 초기 버퍼를 넘어 힙에서 추가 블록을 몇 번 받았는지 셀 수 있어 버퍼 크기를 정하는 근거가 됩니다.

예제 3: 스레드별 리소스

#include <memory_resource>
#include <vector>

std::pmr::unsynchronized_pool_resource& threadPool() {
    // 스레드마다 하나, 스레드 종료 시 자동 소멸
    thread_local std::pmr::unsynchronized_pool_resource pool;
    return pool;
}

void worker(int id) {
    std::pmr::vector<int> local_data(&threadPool());
    for (int i = 0; i < 1000; ++i) {
        local_data.push_back(i * id);
    }
}

포인터를 thread_local로 두고 new로 만드는 방식은 정리 함수를 부르지 않으면 누수가 생기므로, 객체 자체를 thread_local로 두는 편이 간단합니다. 여기서 monotonic 대신 pool을 쓴 이유는, 스레드 수명 내내 살아 있는 monotonic은 아무것도 반환하지 않아 작업이 쌓일수록 메모리가 계속 늘어나기 때문입니다. monotonic을 스레드별로 쓰려면 작업 단위로 release()해야 합니다.

예제 4: 고정 크기 블록 풀

#include <cstddef>
#include <memory_resource>
#include <new>
#include <vector>

class fixed_block_pool : public std::pmr::memory_resource {
public:
    fixed_block_pool(std::size_t block_size, std::size_t block_count,
                     std::pmr::memory_resource* upstream = std::pmr::get_default_resource())
        : block_size_(round_up(block_size, alignof(std::max_align_t))),
          upstream_(upstream),
          storage_(static_cast<std::byte*>(
              upstream->allocate(block_size_ * block_count, alignof(std::max_align_t)))),
          capacity_(block_size_ * block_count) {
        free_list_.reserve(block_count);
        for (std::size_t i = 0; i < block_count; ++i)
            free_list_.push_back(storage_ + i * block_size_);
    }
    ~fixed_block_pool() override {
        upstream_->deallocate(storage_, capacity_, alignof(std::max_align_t));
    }
    fixed_block_pool(const fixed_block_pool&) = delete;
    fixed_block_pool& operator=(const fixed_block_pool&) = delete;

private:
    static std::size_t round_up(std::size_t n, std::size_t a) { return (n + a - 1) / a * a; }

    void* do_allocate(std::size_t bytes, std::size_t alignment) override {
        // 계약상 nullptr를 반환하면 안 됨: 처리할 수 없으면 예외를 던짐
        if (bytes > block_size_ || alignment > alignof(std::max_align_t) || free_list_.empty())
            throw std::bad_alloc{};
        void* p = free_list_.back();
        free_list_.pop_back();
        return p;
    }
    void do_deallocate(void* p, std::size_t, std::size_t) override {
        free_list_.push_back(static_cast<std::byte*>(p));
    }
    bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
        return this == &other;
    }

    std::size_t block_size_;
    std::pmr::memory_resource* upstream_;
    std::byte* storage_;
    std::size_t capacity_;
    std::vector<std::byte*> free_list_;
};

직접 리소스를 만들 때 지켜야 할 계약이 두 가지 있습니다. 첫째, do_allocate는 요청한 정렬을 만족하는 메모리를 반환하거나 예외를 던져야 하며, nullptr를 반환하면 컨테이너는 그 포인터에 그대로 객체를 만들어 정의되지 않은 동작이 됩니다. 둘째, 블록 크기를 정렬 단위의 배수로 맞추지 않으면 두 번째 블록부터 정렬이 깨집니다. 이 예제는 단일 스레드 전용이며, 여러 스레드에서 쓰려면 자유 목록을 락으로 보호해야 합니다.


pmr 컨테이너 실전 활용

지원되는 pmr 컨테이너

별칭실제 타입
std::pmr::stringbasic_string<char, char_traits<char>, polymorphic_allocator<char>>
std::pmr::vector<T>vector<T, polymorphic_allocator<T>>
std::pmr::map<K, V, C>map<K, V, C, polymorphic_allocator<pair<const K, V>>>
std::pmr::set<T, C>set<T, C, polymorphic_allocator<T>>
std::pmr::unordered_map<K, V, H, E>unordered_map<K, V, H, E, polymorphic_allocator<pair<const K, V>>>
std::pmr::list<T>list<T, polymorphic_allocator<T>>

중첩 컨테이너가 같은 풀을 쓰는 원리

#include <map>
#include <memory_resource>
#include <string>
#include <vector>

void parseConfig() {
    std::pmr::monotonic_buffer_resource pool;
    std::pmr::map<std::pmr::string, std::pmr::vector<std::pmr::string>, std::less<>>
        config(&pool);
    config["sections"].push_back("a");
    config["sections"].push_back("b");
    config["keys"].push_back("x");
    // map 노드, 키 문자열, 값 vector의 버퍼, 그 안의 문자열 모두 pool에서 할당
}

config["sections"]로 새 노드를 만들 때 polymorphic_allocator는 uses-allocator 생성 규칙을 따릅니다. 만들려는 요소 타입(pmr::string, pmr::vector<pmr::string>)이 같은 할당자를 받을 수 있으면, 요소 생성자에 자신의 memory_resource를 넘겨 줍니다. 그래서 최상위 컨테이너에 리소스를 한 번만 넘기면 안쪽까지 같은 풀을 씁니다. 고전 할당자에서 같은 효과를 내려면 std::scoped_allocator_adaptor가 필요했지만, polymorphic_allocator는 이 동작을 기본으로 합니다.

이 규칙은 요소 타입이 pmr 할당자를 쓸 때만 적용됩니다.

// 값이 std::string이면 값 문자열은 전역 힙에서 할당 (정의되지 않은 동작은 아니지만 풀의 의미가 없어짐)
std::pmr::map<std::pmr::string, std::string, std::less<>> m1(&pool);
// 모두 pmr 타입이면 노드와 문자열 모두 pool 사용
std::pmr::map<std::pmr::string, std::pmr::string, std::less<>> m2(&pool);

중첩 구조를 리팩터링한 뒤에는 앞의 logging_memory_resource를 기본 리소스로 잠시 설정(std::pmr::set_default_resource)해 두고 실행해 보면, 의도치 않게 기본 리소스로 새는 할당을 쉽게 찾을 수 있습니다.


리소스별 내부 동작

monotonic: 범프 할당과 업스트림 확장

monotonic_buffer_resource는 초기 버퍼 안에서 현재 오프셋만 앞으로 움직입니다. 요청 크기와 정렬에 맞춰 주소를 올린 뒤 그만큼 잘라 주므로, 정렬이 큰 객체와 작은 객체가 섞이면 그 사이에 패딩이 생깁니다. 버퍼가 고갈되면 업스트림에 새 블록을 요청하는데, 표준은 다음 블록을 직전보다 기하급수적으로 키우도록 규정하고 있어 업스트림 호출 횟수는 로그 수준으로 유지됩니다.

deallocate가 아무 일도 하지 않으므로, 크기가 자라는 vector를 monotonic 위에 두면 재할당 때마다 버려진 이전 버퍼가 그대로 남습니다. 1, 2, 4, …, n개 용량으로 자라면 버려진 버퍼의 합이 최종 버퍼와 비슷한 크기가 되어 메모리를 약 두 배로 쓰게 되므로, 최종 크기를 알면 reserve()를 먼저 호출하는 것이 좋습니다.

pool: 크기 등급과 청크

synchronized_pool_resource와 unsynchronized_pool_resource는 요청 크기를 여러 크기 등급으로 나눠 등급별 자유 목록을 둡니다. 자유 목록이 비면 업스트림에서 여러 블록을 묶은 청크를 한 번에 받아 잘게 나누며, max_blocks_per_chunk가 이 묶음의 상한입니다. 너무 크게 잡으면 쓰지도 않을 블록 때문에 최대 메모리 사용량이 늘고, 너무 작게 잡으면 업스트림 호출이 잦아집니다. 해제된 블록은 업스트림에 바로 돌려주지 않고 자유 목록에 남겨 두므로, 리소스가 살아 있는 동안 메모리 사용량은 최고점 근처에서 내려오지 않습니다(release()를 호출하거나 소멸해야 반환됩니다).

synchronized_pool_resource는 구현에 따라 스레드별 캐시를 두기도 하지만, 여러 스레드가 같은 풀을 강하게 공유하면 결국 동기화 비용이 다시 보입니다. 그런 경우는 스레드별 unsynchronized_pool_resource로 나누는 편이 낫습니다.


자주 만나는 에러

에러 1: 리소스보다 컨테이너가 오래 살 때

// 잘못된 코드
std::pmr::vector<int>* createVector() {
    std::pmr::monotonic_buffer_resource pool;
    return new std::pmr::vector<int>(&pool);  // pool은 함수 종료 시 소멸
}
// 반환된 vector는 이미 소멸한 pool을 가리킴 → 정의되지 않은 동작

컨테이너는 memory_resource를 포인터로만 들고 있을 뿐 수명을 관리하지 않습니다. 가장 안전한 방법은 리소스와 컨테이너를 같은 스코프에 두되, 리소스를 먼저 선언해 나중에 소멸하게 하는 것입니다. 클래스 멤버로 둘 때도 리소스 멤버를 컨테이너 멤버보다 먼저 선언해야 합니다(멤버는 선언 역순으로 소멸).

void process() {
    std::pmr::monotonic_buffer_resource pool;   // 먼저 선언 → 나중에 소멸
    std::pmr::vector<int> vec(&pool);
    // ... 사용 ...
}  // vec 소멸 후 pool 소멸

에러 2: monotonic에서 메모리가 반환되리라 기대

std::pmr::monotonic_buffer_resource pool;
std::pmr::vector<int> v(&pool);
for (int round = 0; round < 1000; ++round) {
    std::pmr::vector<int> tmp(&pool);
    tmp.resize(10000);   // 매 반복 약 40KB 할당
}  // tmp가 소멸해도 pool은 메모리를 회수하지 않음 → 반복마다 누적

monotonic_buffer_resource의 deallocate는 아무것도 하지 않으므로, 장시간 살아 있는 monotonic에서 할당과 해제를 반복하면 메모리가 계속 늘어납니다. monotonic은 수명이 짧은 스코프(요청, 프레임, 작업)에 두고 그 단위로 소멸시키거나 release()하는 용도로만 써야 하며, 개별 해제가 필요한 패턴은 pool_resource를 씁니다.

에러 3: 다른 리소스를 쓰는 컨테이너 사이의 복사·이동·swap

polymorphic_allocator는 복사 대입, 이동 대입, swap 때 할당자를 따라 옮기지 않도록 정의되어 있습니다(propagate_on_container_*가 모두 false). 그래서 서로 다른 리소스를 쓰는 컨테이너끼리의 동작이 일반 할당자와 다릅니다.

std::pmr::monotonic_buffer_resource pool1, pool2;
std::pmr::vector<int> a(&pool1);
a.push_back(42);

std::pmr::vector<int> b(&pool2);
b = a;              // 안전: 요소를 b의 리소스(pool2)에 복사
b = std::move(a);   // 안전하지만 O(n): 리소스가 달라 버퍼를 넘겨받지 못하고 요소를 하나씩 이동

std::pmr::vector<int> c = a;          // 주의: c는 pool1이 아니라 기본 리소스를 씀
std::pmr::vector<int> d(a, &pool1);   // 같은 풀이 필요하면 리소스를 명시

// std::swap(a, b);  // 리소스가 다르면 정의되지 않은 동작

복사 생성은 select_on_container_copy_construction()이 기본 생성된 할당자를 돌려주도록 정의되어 있어, 원본이 어느 풀을 쓰든 복사본은 기본 리소스로 갑니다. 함수가 pmr 컨테이너를 값으로 반환하거나 값으로 받는 경우에도 이 규칙 때문에 풀에서 벗어난 할당이 생길 수 있습니다. 반대로 리소스가 다른 두 컨테이너의 swap은 표준상 정의되지 않은 동작이므로, 같은 리소스를 쓰는 컨테이너끼리만 swap해야 합니다.

에러 4: 스택 버퍼 크기 부족

alignas(std::max_align_t) std::byte buffer[256];
std::pmr::monotonic_buffer_resource pool{buffer, sizeof(buffer)};
std::pmr::vector<int> v(&pool);
for (int i = 0; i < 1000; ++i) v.push_back(i);  // 256바이트를 넘으면 업스트림(힙) 사용

조용히 힙으로 넘어가는 것이 싫다면 업스트림을 std::pmr::null_memory_resource()로 지정해 초과 시 std::bad_alloc이 나게 하고, 통계 래퍼로 실제 사용량을 측정해 버퍼 크기를 정합니다.

에러 5: 커스텀 리소스에서 정렬 무시

// 잘못된 구현: 정렬 요구를 버림
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
    return upstream_->allocate(bytes, 1);
}

요청된 정렬보다 덜 정렬된 메모리에 alignas(32) 타입이나 SIMD 타입을 만들면, 일부 플랫폼에서는 SIGBUS로 죽고 x86에서도 정렬을 요구하는 SIMD 명령이 예외를 냅니다. 항상 받은 alignment를 그대로 넘기거나 직접 맞춰야 합니다.

에러 6: 스레드 안전성 오해

// 위험: 여러 스레드가 같은 unsynchronized_pool / monotonic 공유
std::pmr::unsynchronized_pool_resource pool;
std::thread t1([&] { std::pmr::vector<int> v(&pool); /* ... */ });
std::thread t2([&] { std::pmr::vector<int> v(&pool); /* ... */ });

unsynchronized_pool_resource와 monotonic_buffer_resource는 동기화가 없습니다. 여러 스레드가 공유해야 한다면 synchronized_pool_resource를 쓰고, 그렇지 않다면 스레드마다 별도 리소스를 두는 편이 빠릅니다.


측정하기

#include <chrono>
#include <iostream>
#include <list>
#include <memory_resource>

template <typename List>
long long fill_and_destroy(List make_list(), int n, int rounds) {
    auto t0 = std::chrono::steady_clock::now();
    for (int r = 0; r < rounds; ++r) {
        auto lst = make_list();
        for (int i = 0; i < n; ++i) lst.push_back(i);   // 노드마다 할당 1회
    }                                                    // 소멸 시 노드마다 해제 1회
    auto t1 = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
}

int main() {
    constexpr int N = 100000, R = 20;

    auto t_default = fill_and_destroy<std::list<int>>(
        [] { return std::list<int>{}; }, N, R);

    std::pmr::unsynchronized_pool_resource pool;
    static std::pmr::memory_resource* pool_ptr = &pool;
    auto t_pool = fill_and_destroy<std::pmr::list<int>>(
        [] { return std::pmr::list<int>{pool_ptr}; }, N, R);

    long long t_mono = 0;
    {
        auto t0 = std::chrono::steady_clock::now();
        for (int r = 0; r < R; ++r) {
            std::pmr::monotonic_buffer_resource mono;   // 라운드마다 새 아레나
            std::pmr::list<int> lst(&mono);
            for (int i = 0; i < N; ++i) lst.push_back(i);
        }
        auto t1 = std::chrono::steady_clock::now();
        t_mono = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
    }

    std::cout << "default:   " << t_default << " us\n"
              << "pool:      " << t_pool << " us\n"
              << "monotonic: " << t_mono << " us\n";
}

벤치마크는 할당이 실제로 많이 일어나는 형태여야 의미가 있습니다. std::vector에 reserve를 미리 해 두고 push_back만 반복하면 할당은 한 번뿐이라 할당 전략의 차이가 거의 드러나지 않으므로, 여기서는 원소마다 노드를 할당하는 std::list로 측정합니다. 결과는 기준이 되는 할당기에 크게 좌우됩니다. Windows의 기본 CRT 힙, glibc의 tcache, jemalloc이나 mimalloc은 작은 할당 속도가 서로 많이 달라서, 같은 코드라도 플랫폼마다 pmr의 이득이 다르게 나옵니다. 또 실제 프로그램에서 할당이 전체 시간의 일부일 뿐이라면, 할당을 몇 배 빠르게 해도 전체 개선은 그 비율만큼에 그칩니다. 측정 조건과 해석은 할당 전략 비교(#55-5)에서 더 자세히 다룹니다.


프로덕션 패턴

요청 스코프와 명시적 리소스 전달

void handleRequest(const Request& req) {
    std::pmr::monotonic_buffer_resource request_pool;
    std::pmr::vector<std::pmr::string> path_segments(&request_pool);
    std::pmr::map<std::pmr::string, std::pmr::string, std::less<>>
        headers(&request_pool);
    parsePath(req.uri, path_segments);
    parseHeaders(req.raw_headers, headers);
    Response response = buildResponse(path_segments, headers, &request_pool);
    sendResponse(response);
}  // 요청에서 할당한 모든 메모리 일괄 반환

std::pmr::set_default_resource로 전역 기본 리소스를 바꾸는 방식은 편해 보이지만, 프로세스 전체에 영향을 주는 전역 상태라 멀티스레드나 플러그인이 섞인 프로그램에서 어느 할당이 어디로 가는지 추적하기 어렵게 만듭니다. 파서나 직렬화기처럼 할당이 많은 API가 memory_resource*를 인자로 받게 하면, 어느 호출이 어느 아레나를 쓰는지 코드에서 바로 보입니다.

워커별 풀

class Worker {
    std::pmr::monotonic_buffer_resource worker_pool_{std::pmr::new_delete_resource()};
public:
    void process(const Task& t) {
        {
            std::pmr::vector<Item> scratch(&worker_pool_);
            // ... t 처리 중 scratch 등에서 할당 ...
        }  // 이 작업의 컨테이너가 모두 소멸한 뒤
        worker_pool_.release();   // 다음 작업을 위해 리셋
    }
};

release()는 이 리소스를 쓰는 컨테이너가 하나도 남지 않은 시점에 불러야 합니다. 멤버 컨테이너를 두고 release() 후에 clear()만 하면, 컨테이너는 이미 반환된 버퍼를 계속 들고 있어 다음 push_back이 해제된 메모리에 씁니다. 작업 처리 중 예외가 나도 release()가 호출되도록 하려면 RAII 가드로 감싸는 것이 좋습니다.

계층적 리소스

std::pmr::synchronized_pool_resource global_pool;
std::pmr::monotonic_buffer_resource thread_arena{&global_pool};
std::pmr::monotonic_buffer_resource frame_arena{&thread_arena};
std::pmr::vector<int> frame_data(&frame_arena);
// frame_arena 부족 → thread_arena에서 블록을 받음 → 부족하면 global_pool

관측과 예산

stats_memory_resource 같은 얇은 래퍼를 업스트림에 끼우면 다음과 같은 체인이 됩니다.

[monotonic 또는 pool] → [통계 래퍼] → new_delete_resource()

monotonic이 초기 버퍼를 넘어 업스트림에서 블록을 받아 올 때마다 래퍼의 카운터가 올라가므로, 요청당 힙 사용 횟수와 크기를 볼 수 있습니다. pmr는 요청당 메모리 상한을 자동으로 강제하지 않으므로, 악의적이거나 비정상적으로 큰 입력이 들어오는 서비스라면 이 래퍼에서 누적 바이트가 임계치를 넘을 때 예외를 던져 요청을 조기에 실패시키는 방식으로 예산을 걸 수 있습니다.

검증 단계에서는 AddressSanitizer 빌드로 부하 테스트를 돌리면 리소스보다 오래 사는 컨테이너나 release() 이후 사용 같은 문제가 비교적 빨리 드러납니다. 다만 monotonic 내부에서 잘라 준 메모리는 ASan이 개별 객체 단위로 추적하지 못하므로, 아레나 내부의 오버런은 잡히지 않을 수 있다는 점도 알아 두어야 합니다.

참고 자료


같이 보면 좋은 글

이전 글: 고성능 C++ #39-1: 캐시·데이터 지향 설계

다음 글: 고성능 C++ #39-3: SIMD와 병렬화