Published on

C++20 std - -pmr로 할당기 튜닝과 누수 추적

Authors

서버나 게임/시뮬레이션처럼 메모리 할당이 핫패스에 걸리는 C++ 코드에서 성능 문제를 파고들다 보면, 결국 new/delete 호출 빈도와 힙 단편화로 귀결되는 경우가 많습니다. C++17에 들어온 std::pmr(Polymorphic Memory Resource)는 컨테이너의 할당 정책을 런타임에 교체할 수 있게 해주고, C++20에서도 여전히 가장 실용적인 “할당기 튜닝 도구” 중 하나입니다.

이 글에서는 std::pmr를 이용해

  • 할당 비용을 낮추는 튜닝(풀/모노토닉 리소스)
  • 누수 및 “해제되지 않은 바이트”를 추적하는 계측 리소스
  • 실무에서 흔한 함정(리소스 수명, 업스트림, 스레드 안정성)

을 한 번에 정리합니다.

참고로 메모리 이슈는 C++만의 문제가 아닙니다. 로컬 LLM에서 OOM을 다루는 글처럼(원인은 다르지만 관찰/완화 전략은 비슷합니다) 메모리 사용량을 “측정 가능하게” 만드는 게 출발점입니다: Transformers 로컬 LLM OOM? 7가지 즉시 해결

std::pmr 핵심 개념: 컨테이너가 “리소스”에서 할당받는다

전통적인 C++ 할당기 모델은 템플릿 기반 Allocator를 타입에 박아 넣는 방식입니다. 반면 std::pmr는 다음 구조로 단순화합니다.

  • std::pmr::memory_resource: 가상 함수 기반의 “할당 서비스”
  • std::pmr::polymorphic_allocator(보통 컨테이너 내부에서 사용): memory_resource로 위임
  • std::pmr::vector, std::pmr::string 등: 기본 할당기가 polymorphic_allocator인 컨테이너 별칭

즉, 컨테이너 타입은 고정하고, 할당 정책만 런타임에 바꿀 수 있습니다.

기본 리소스와 스코프

std::pmr에는 전역 기본 리소스가 있습니다.

  • std::pmr::get_default_resource()
  • std::pmr::set_default_resource(...)

하지만 실무에서는 전역 기본 리소스를 바꾸기보다, 필요한 범위에서 명시적으로 리소스를 주입하는 편이 안전합니다.

성능 튜닝 1: monotonic_buffer_resource로 “한 번에” 할당하기

std::pmr::monotonic_buffer_resource는 이름 그대로 단조 증가 방식입니다.

  • 할당은 빠름(포인터 bump)
  • 개별 deallocate는 사실상 무시됨
  • 리소스가 파괴될 때 한 번에 반환

따라서 “요청 처리 동안 임시 객체를 많이 만들고, 요청이 끝나면 모두 버리는” 패턴에 특히 강합니다.

예제: 요청 단위 임시 컨테이너를 빠르게

아래 코드는 요청 처리 함수 내부에서 고정 크기 스택 버퍼를 먼저 쓰고, 부족하면 업스트림(기본은 new_delete_resource)으로 확장합니다.

#include <memory_resource>
#include <string>
#include <vector>
#include <iostream>

struct Request {
  std::string payload;
};

void handle_request(const Request& req) {
  // 스택에 작은 버퍼를 두고, 부족하면 힙으로 확장
  std::byte buffer[64 * 1024];
  std::pmr::monotonic_buffer_resource pool(
      buffer, sizeof(buffer), std::pmr::new_delete_resource());

  std::pmr::vector<std::pmr::string> tokens{&pool};
  tokens.reserve(1024);

  // 예시: 파싱 결과를 임시로 쌓는다
  for (int i = 0; i < 1000; ++i) {
    tokens.emplace_back(req.payload, &pool);
  }

  // tokens의 원소/버퍼 해제는 개별적으로 일어나지 않음
  // handle_request 종료 시 pool 소멸과 함께 한 번에 정리
}

int main() {
  handle_request(Request{"hello"});
}

튜닝 포인트

  • 버퍼 크기: 너무 작으면 업스트림으로 새 청크를 자주 받아 이점이 줄고, 너무 크면 스택 사용량이 커집니다.
  • 업스트림 선택: 디버그/계측 리소스를 업스트림에 끼워 넣으면 추적이 쉬워집니다(뒤에서 설명).
  • 수명: pool보다 오래 사는 pmr 컨테이너/문자열을 반환하면 즉시 UAF(Use-After-Free)로 이어집니다.

성능 튜닝 2: unsynchronized_pool_resource로 작은 할당을 풀링

monotonic_buffer_resource가 “수명 단위 일괄 정리”라면, 풀 리소스는 “작은 블록을 재사용”합니다.

  • std::pmr::unsynchronized_pool_resource: 빠르지만 스레드 안전 아님
  • std::pmr::synchronized_pool_resource: 내부 락으로 스레드 안전

작은 객체가 빈번히 생성/파괴되는 패턴(예: 토큰, AST 노드, 작은 문자열 버퍼)에 유리합니다.

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

struct Worker {
  std::pmr::unsynchronized_pool_resource pool;
  std::pmr::vector<std::pmr::string> cache;

  Worker() : cache(&pool) {
    cache.reserve(4096);
  }

  void run() {
    for (int i = 0; i < 100000; ++i) {
      cache.emplace_back("abc", &pool);
      if (cache.size() > 8000) cache.clear();
    }
  }
};

풀 리소스 사용 시 주의점

  • 풀은 “반환된 블록”을 잡고 있으므로 RSS가 기대보다 덜 줄 수 있습니다.
  • 스레드마다 unsynchronized_pool_resource를 두는 패턴이 흔합니다(락 회피).
  • 프로세스 종료 전까지 메모리가 남아 보인다면, 누수라기보다 풀 캐시일 수 있습니다. 이때는 계측으로 “미해제 바이트”와 “캐시 유지 바이트”를 구분해야 합니다.

누수 추적 1: 계측 memory_resource로 할당/해제 집계하기

std::pmr::memory_resource는 가상 함수 do_allocate, do_deallocate, do_is_equal만 구현하면 됩니다. 이를 이용해 “업스트림으로 위임하면서” 통계를 쌓는 프록시 리소스를 만들 수 있습니다.

아래는 총 할당 바이트, 총 해제 바이트, 현재 사용량을 추적하는 간단한 예입니다.

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

class TrackingResource final : public std::pmr::memory_resource {
public:
  explicit TrackingResource(std::pmr::memory_resource* upstream)
      : upstream_(upstream) {}

  std::size_t bytes_in_use() const {
    return in_use_.load(std::memory_order_relaxed);
  }

  std::size_t total_allocated() const {
    return allocated_.load(std::memory_order_relaxed);
  }

  std::size_t total_deallocated() const {
    return deallocated_.load(std::memory_order_relaxed);
  }

private:
  void* do_allocate(std::size_t bytes, std::size_t alignment) override {
    void* p = upstream_->allocate(bytes, alignment);
    allocated_.fetch_add(bytes, std::memory_order_relaxed);
    in_use_.fetch_add(bytes, std::memory_order_relaxed);
    return p;
  }

  void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
    upstream_->deallocate(p, bytes, alignment);
    deallocated_.fetch_add(bytes, std::memory_order_relaxed);
    in_use_.fetch_sub(bytes, std::memory_order_relaxed);
  }

  bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
    return this == &other;
  }

private:
  std::pmr::memory_resource* upstream_;
  std::atomic<std::size_t> allocated_{0};
  std::atomic<std::size_t> deallocated_{0};
  std::atomic<std::size_t> in_use_{0};
};

int main() {
  TrackingResource tr(std::pmr::new_delete_resource());

  {
    std::pmr::vector<int> v{&tr};
    for (int i = 0; i < 100000; ++i) v.push_back(i);
  }

  std::cout << "allocated=" << tr.total_allocated()
            << " deallocated=" << tr.total_deallocated()
            << " in_use=" << tr.bytes_in_use() << "\n";
}

이 방식의 장점은 명확합니다.

  • 컨테이너 코드 변경이 최소화(리소스만 주입)
  • 특정 서브시스템(요청 핸들러, 파서, 캐시 등) 단위로 측정 가능
  • 풀/모노토닉 같은 리소스와 조합 가능

중요한 한계: bytes가 “요청한 크기”라는 점

do_deallocate로 넘어오는 bytes는 리소스가 실제로 잡은 크기가 아니라 “요청한 크기”입니다. 업스트림이 내부적으로 라운딩/헤더를 붙이면 실제 RSS와 차이가 날 수 있습니다. 그래도 누수 탐지(해제 누락)에는 충분히 유용합니다.

누수 추적 2: 태그/스택을 붙여 “어디서” 할당했는지 찾기

실무에서 누수는 “몇 바이트 남았다”보다 “어떤 경로에서 남았다”가 중요합니다. memory_resource에 태그를 붙이거나, 디버그 빌드에서 호출 스택을 샘플링해 해시로 집계할 수 있습니다.

아래는 간단히 태그 문자열을 붙여 로그에 남기는 예시입니다(스택 수집은 플랫폼 의존이라 생략).

#include <memory_resource>
#include <string_view>
#include <iostream>

class TaggedTrackingResource final : public std::pmr::memory_resource {
public:
  TaggedTrackingResource(std::string_view tag, std::pmr::memory_resource* upstream)
      : tag_(tag), upstream_(upstream) {}

private:
  void* do_allocate(std::size_t bytes, std::size_t alignment) override {
    void* p = upstream_->allocate(bytes, alignment);
    std::cerr << "[alloc] tag=" << tag_ << " bytes=" << bytes << "\n";
    return p;
  }

  void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
    std::cerr << "[free ] tag=" << tag_ << " bytes=" << bytes << "\n";
    upstream_->deallocate(p, bytes, alignment);
  }

  bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
    return this == &other;
  }

  std::string_view tag_;
  std::pmr::memory_resource* upstream_;
};

운영 환경에서는 이렇게 “모든 할당을 로깅”하면 비용이 너무 큽니다. 대신 다음을 권합니다.

  • 샘플링(예: N번 중 1번만 기록)
  • 바이트 임계치 이상만 기록
  • 요청 ID나 트랜잭션 ID를 태그로 주입

트랜잭션/요청 단위로 추적하는 관점은 DB 트랜잭션 원자성을 다루는 글과도 결이 같습니다. 경계(스코프)를 명확히 잡아야 추적이 됩니다: Python contextmanager로 DB 트랜잭션 원자성 보장

실전 조합: “모노토닉 + 계측 업스트림”으로 요청 단위 누수 감지

가장 실용적인 패턴 중 하나는 아래 조합입니다.

  • 요청마다 monotonic_buffer_resource를 만든다
  • 그 업스트림을 TrackingResource로 감싼다
  • 요청 종료 시점에 in_use를 확인한다

단, 모노토닉은 개별 해제를 하지 않으니 “요청 중간”의 in_use는 계속 증가합니다. 대신 요청 스코프가 끝난 뒤 업스트림에 남은 바이트가 있는지를 보는 식으로 “요청 밖으로 새는 메모리”를 잡습니다.

#include <memory_resource>
#include <iostream>
#include <vector>

class TrackingResource final : public std::pmr::memory_resource {
public:
  explicit TrackingResource(std::pmr::memory_resource* upstream)
      : upstream_(upstream) {}
  std::size_t in_use() const { return in_use_; }

private:
  void* do_allocate(std::size_t bytes, std::size_t alignment) override {
    void* p = upstream_->allocate(bytes, alignment);
    in_use_ += bytes;
    return p;
  }
  void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
    upstream_->deallocate(p, bytes, alignment);
    in_use_ -= bytes;
  }
  bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
    return this == &other;
  }

  std::pmr::memory_resource* upstream_;
  std::size_t in_use_{0};
};

void process_one() {
  TrackingResource upstream_tr(std::pmr::new_delete_resource());

  std::byte buf[32 * 1024];
  std::pmr::monotonic_buffer_resource req_mr(buf, sizeof(buf), &upstream_tr);

  std::pmr::vector<int> v(&req_mr);
  for (int i = 0; i < 200000; ++i) v.push_back(i);

  // req_mr 소멸 시 업스트림으로부터 받은 청크가 반환되어야 함
}

int main() {
  // 데모: 여러 요청을 돌린 뒤, 요청 밖으로 새는 메모리가 있는지 확인하는 구조를 만들 수 있음
  process_one();
}

이 패턴은 “요청 스코프 밖으로 컨테이너/문자열이 유출되는 버그”를 찾는 데 특히 좋습니다. 예를 들어 요청 처리 중 만든 std::pmr::string을 전역 캐시에 넣어버리면, 그 문자열이 참조하는 메모리는 요청 리소스와 함께 사라져 UAF가 발생합니다. 이를 막으려면 캐시는 별도의 장수명 리소스를 쓰거나, 저장 시점에 std::string으로 복사해 소유권을 명확히 해야 합니다.

흔한 함정과 체크리스트

1) 리소스 수명: pmr 객체는 리소스보다 오래 살면 안 된다

  • std::pmr::vector/std::pmr::string은 내부 버퍼를 리소스에서 받습니다.
  • 리소스가 먼저 파괴되면, 남아 있는 컨테이너는 즉시 위험해집니다.

대응:

  • 리소스를 “가장 바깥 스코프”에 두고, 그 안에서 pmr 객체를 생성
  • 반환 타입에 pmr 컨테이너를 쓰는 API는 리소스 전달 규약을 명확히 문서화

2) 기본 리소스 전역 변경은 신중하게

std::pmr::set_default_resource로 전역을 바꾸면 편하지만, 라이브러리/서드파티가 같은 프로세스에서 돌아가는 환경에서는 예측이 깨질 수 있습니다.

대응:

  • 가능하면 생성자에서 memory_resource*를 받는 방식으로 명시 주입
  • 테스트/벤치에서만 전역 기본 리소스를 바꿔 영향 범위를 제한

3) 스레드 안정성

  • unsynchronized_pool_resource는 스레드 안전하지 않습니다.
  • synchronized_pool_resource는 안전하지만 락 비용이 있습니다.

대응:

  • 스레드 로컬 풀(예: 워커 스레드당 하나)
  • 공유가 필요하면 synchronized를 쓰되, 경쟁이 심하면 샤딩(풀 여러 개) 고려

4) “누수처럼 보이는” 풀 캐시

풀 리소스는 재사용을 위해 메모리를 잡고 있을 수 있습니다. 프로파일러에서 해제가 안 된 것처럼 보여도, 실제로는 캐시일 수 있습니다.

대응:

  • 계측으로 “현재 사용량(in-use)”과 “리소스가 보유한 캐시”를 구분
  • 종료 시점에 release(리소스가 제공하는 경우) 또는 리소스 수명 단축으로 반환 유도

벤치마크/관찰 팁: 수치화하지 않으면 튜닝이 아니다

std::pmr 튜닝은 체감이 아니라 수치로 해야 합니다.

  • 할당 횟수: perf, ETW, Instruments, heap profiling
  • 지연 시간: p50/p95/p99
  • RSS/가상 메모리: 요청 처리 중 피크
  • 단편화: 장시간 구동 후 성능 저하 여부

특히 p99가 중요합니다. 할당이 락 경쟁이나 시스템 콜로 튀면 꼬리 지연이 커집니다. 이 관점은 병렬 처리에서 “특정 패턴이 성능을 망친다”는 이야기와도 통합니다: Java Stream 병렬 처리 성능 망치는 5가지 패턴

결론: std::pmr는 “성능”과 “관측 가능성”을 동시에 올린다

C++20에서 std::pmr를 도입하는 가장 큰 이점은 두 가지입니다.

  • 핫패스에서 할당 비용을 줄이는 구조적 튜닝(모노토닉/풀)
  • 커스텀 memory_resource로 서브시스템 단위 메모리 사용을 계측하고 누수를 좁히는 관측

정리하면, 다음 순서가 실전에서 실패 확률이 낮습니다.

  1. 누가/어디서/얼마나 할당하는지 TrackingResource로 먼저 수치화
  2. 수명이 짧은 임시 데이터는 monotonic_buffer_resource로 묶기
  3. 작은 객체 churn이 심하면 스레드 로컬 unsynchronized_pool_resource 고려
  4. 리소스 수명 규약을 API 레벨에서 강제(주입/스코프)하고, 누수는 요청 경계에서 탐지

이렇게 하면 “막연한 최적화”가 아니라, 재현 가능한 형태로 성능과 안정성을 같이 끌어올릴 수 있습니다.