- Published on
C++20 std - -pmr로 할당기 튜닝과 누수 추적
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 게임/시뮬레이션처럼 메모리 할당이 핫패스에 걸리는 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로 서브시스템 단위 메모리 사용을 계측하고 누수를 좁히는 관측
정리하면, 다음 순서가 실전에서 실패 확률이 낮습니다.
- 누가/어디서/얼마나 할당하는지
TrackingResource로 먼저 수치화 - 수명이 짧은 임시 데이터는
monotonic_buffer_resource로 묶기 - 작은 객체 churn이 심하면 스레드 로컬
unsynchronized_pool_resource고려 - 리소스 수명 규약을 API 레벨에서 강제(주입/스코프)하고, 누수는 요청 경계에서 탐지
이렇게 하면 “막연한 최적화”가 아니라, 재현 가능한 형태로 성능과 안정성을 같이 끌어올릴 수 있습니다.