C++ 기초부터 마스터까지 — 22화
데이터 배치, 캐시와 pmr로 메모리 비용 설계하기
본 블로그 작성 글들은, 강의를 목표로 하고 제작되었습니다. 또한 작성된 글의 정보도 틀렸을 수 있으니, 주의해주세요.
성능은 연산 횟수만으로 결정되지 않는다. 객체가 메모리에 어떻게 배치되고 어떤 순서로 접근되며 얼마나 자주 할당되는지가 CPU 캐시와 전체 처리량을 바꾼다.
연속된 데이터는 캐시를 활용하기 쉽다
struct Particle
{
float X;
float Y;
float Z;
float VelocityX;
float VelocityY;
float VelocityZ;
bool Active;
};
std::vector<Particle> particles;
모든 필드를 함께 사용한다면 AoS(Array of Structures)는 단순하고 지역성이 좋다. 하지만 위치만 갱신하는 반복에서 사용하지 않는 필드까지 캐시 라인에 들어올 수 있다.
struct ParticlePositions
{
std::vector<float> X;
std::vector<float> Y;
std::vector<float> Z;
};
SoA(Structure of Arrays)는 같은 필드를 연속 배치해 SIMD와 대량 처리를 돕는다. 대신 원소 하나의 모든 필드를 자주 읽는 코드와 객체 단위 API는 복잡해질 수 있다.
할당 횟수와 수명을 함께 설계한다
#include <array>
#include <memory_resource>
std::array<std::byte, 4096> buffer;
std::pmr::monotonic_buffer_resource arena(buffer.data(), buffer.size());
std::pmr::vector<std::pmr::string> names(&arena);
names.emplace_back("Knight");
names.emplace_back("Mage");
std::pmr 컨테이너는 메모리를 어디서 얻을지 런타임에 주입할 수 있다. monotonic_buffer_resource는 개별 해제 없이 앞으로만 할당하고 resource를 초기화하거나 파괴할 때 한꺼번에 되돌리는 임시 프레임 데이터에 잘 맞는다.
resource는 이를 사용하는 모든 컨테이너와 원소보다 오래 살아야 한다. arena가 먼저 파괴되면 컨테이너가 잘못된 메모리를 참조한다.
reserve는 재할당 정책이다
std::vector<Entity> entities;
entities.reserve(expectedCount);
예상 크기를 안다면 reserve로 여러 번의 재할당과 이동을 줄일 수 있다. 하지만 실제 최대치보다 지나치게 크게 예약하면 사용하지 않는 메모리를 오래 점유한다.
컨테이너 재할당은 내부 주소와 반복자를 무효화할 수 있다. 외부 시스템이 원소 주소를 오래 보관한다면 index, 안정적인 handle, 세대 번호 같은 간접 참조를 검토해야 한다.
측정 없이 layout을 바꾸지 않는다
AoS와 SoA, pool과 일반 할당, 작은 객체 최적화 중 무엇이 나은지는 workload에 달려 있다. 대표 데이터로 wall time, CPU time, 할당 횟수, peak memory, cache miss를 측정하고 변경 전후의 정확성도 함께 확인해야 한다.
좋은 메모리 설계는 가장 영리한 allocator를 쓰는 것이 아니다. 데이터 수명과 접근 패턴을 분명히 하고 가장 단순한 소유 구조로 목표 성능을 만족하는 것이다.