C++ 캐시 최적화 실전 | 캐시 친화적 구조·프리페치·False Sharing·AoS vs SoA 가이드
이 글의 핵심
C++ CPU 캐시 최적화: 캐시 친화적 데이터 구조, 프리페치, False Sharing 방지, AoS vs SoA 설계. 프로파일러 병목 해결, 문제 시나리오, 완전한 예제, 흔한 에러, 성능 벤치마크, 프로덕션 패턴까지 실전 코드로 다룹니다.
들어가며: 같은 O(n)인데 10배 차이
”프로파일러에서 cache-misses가 병목이에요”
#15-2 캐시 친화적 코드와 #39-1 데이터 지향 설계에서 캐시 기초를 다뤘다면, 이 글은 실전 캐시 최적화를 집중적으로 다룹니다. 현대 CPU에서는 메모리 접근 지연(~100ns)이 연산(~1ns)보다 수십 배 크기 때문에, 캐시를 제대로 활용하지 않으면 병목이 됩니다.
비유: 캐시는 “책상 위에 자주 쓰는 책을 올려두는 것”입니다. 책상(캐시)이 작으므로, 필요한 책만 가까이 두고 순서대로 읽어야 효율적입니다. 책을 무작위로 꺼내면 책장(메모리)을 매번 왔다 갔다 해야 합니다.
이 글을 읽으면:
- 캐시 친화적 데이터 구조를 설계·구현할 수 있습니다.
- 프리페치로 랜덤 접근 시 지연을 숨길 수 있습니다.
- False Sharing을 방지해 멀티스레드 성능을 확보할 수 있습니다.
- AoS vs SoA를 상황에 맞게 선택할 수 있습니다.
- 프로덕션에서 검증된 패턴을 적용할 수 있습니다.
요구 환경: C++17 이상 (alignas, std::hardware_destructive_interference_size)
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
문제 시나리오
시나리오 1: 파티클 시스템이 프레임 드랍을 유발할 때
"10만 개 파티클의 위치만 매 프레임 갱신하는데 16ms를 넘어요."
"프로파일러에서 연산 자체는 O(n)인데, 메모리 접근이 병목이에요."
상황: 게임에서 Particle 구조체에 위치(x,y,z), 속도(vx,vy,vz), 색상(r,g,b), 수명 등이 한 덩어리로 있습니다. “위치만 갱신”해도 캐시 라인(64바이트)당 실제로 쓰는 데이터는 12바이트뿐이고, 나머지는 낭비됩니다.
해결 포인트: SoA(Structure of Arrays)로 바꿔 위치 배열만 순차 접근하면 캐시 효율이 5배 이상 올라갑니다.
시나리오 2: 스레드를 늘렸는데 오히려 느려질 때
"8스레드로 병렬 카운팅했는데 단일 스레드보다 2배도 안 빨라요."
"perf stat에서 cache-misses가 엄청 많아요."
상황: std::atomic<int> counters[8]처럼 8개 카운터가 같은 캐시 라인에 있습니다. 한 스레드가 counters[0]을 수정할 때마다 다른 스레드의 counters[1]이 있는 캐시 라인이 무효화됩니다. False Sharing입니다.
해결 포인트: alignas(64)로 각 카운터를 별도 캐시 라인에 배치하면 2~10배 빨라집니다.
시나리오 3: 링크드 리스트·트리 순회가 느릴 때
"BST를 순회하는데, 노드마다 캐시 미스가 나요."
"포인터를 따라가니까 다음 주소를 미리 알 수 있는데 활용이 안 돼요."
상황: 노드가 힙에 흩어져 있어 순차 접근이 불가능합니다. 하지만 다음 노드 주소를 미리 알 수 있으므로, __builtin_prefetch로 다음 블록을 미리 로드하면 지연을 숨길 수 있습니다.
해결 포인트: 프리페치로 다음 노드를 미리 캐시에 올리면 20~30% 성능 향상을 얻는 경우가 많습니다.
시나리오 4: 대용량 행렬 연산이 병목일 때
"4096×4096 행렬을 열 우선으로 순회하는데 너무 느려요."
"행 우선으로 바꾸기만 해도 10배 빨라진다고 하던데."
상황: C/C++ 2차원 배열은 행 우선(row-major) 저장입니다. 열 우선 순회는 매 접근마다 다른 행을 건너뛰어 캐시 미스가 폭발합니다.
해결 포인트: 행 우선 순회로 바꾸고, 가능하면 타일링(작은 블록 단위 처리)을 적용합니다.
시나리오 5: 특정 필드만 조회하는데 전체 구조체를 로드할 때
"100만 건의 '나이' 필드만 집계하는데, 전체 행을 메모리에 올려요."
"이름, 주소 등 안 쓰는 필드까지 캐시에 올라와 대역폭을 낭비해요."
상황: 행(row) 단위 저장은 AoS와 같습니다. 열(column) 단위 저장이나 SoA 형태로 “나이 배열만” 순회하면 필요한 바이트만 읽어 캐시 효율이 좋아집니다.
해결 포인트: SoA 또는 컬럼형 스토리지로 전환합니다.
시나리오 6: SIMD 벡터화가 안 될 때
"컴파일러가 자동 벡터화를 못 해준다고 나와요."
"x, y, z가 연속이 아니라 구조체마다 떨어져 있어서요."
상황: AoS에서는 entities[i].x, entities[i+1].x가 메모리에서 28바이트 이상씩 떨어져 있어, 4개 float를 한 번에 로드하는 SIMD 명령에 맞지 않습니다.
해결 포인트: SoA에서는 x[i], x[i+1], x[i+2], x[i+3]가 연속이므로 벡터화가 쉽습니다.
시나리오별 권장 기법
| 시나리오 | 특징 | 권장 기법 |
|---|---|---|
| 파티클·엔티티 대량 업데이트 | 특정 필드만 반복 처리 | SoA |
| 멀티스레드 per-thread 카운터 | 같은 배열에 스레드별 쓰기 | 캐시 라인 정렬 |
| 링크드 리스트·트리 순회 | 포인터 체이닝, 다음 주소 예측 가능 | 프리페치 |
| 행렬·이미지 처리 | 2차원 순회 | 행 우선 + 타일링 |
| 필드별 집계·조회 | 일부 필드만 사용 | SoA, 컬럼형 |
| SIMD 적용 필요 | 연속 float/int 배열 | SoA |
목차
- 캐시 기초와 메모리 계층
- 캐시 친화적 데이터 구조
- AoS vs SoA 완전 예제
- False Sharing 방지
- 프리페치 활용
- 완전한 캐시 최적화 예제
- 자주 발생하는 에러와 해결법
- 모범 사례와 체크리스트
- 성능 벤치마크
- 프로덕션 패턴
- 정리
1. 캐시 기초와 메모리 계층
메모리 계층
CPU Register < 1ns (가장 빠름)
↓
L1 Cache ~1ns (32-64KB per core)
↓
L2 Cache ~3ns (256KB-1MB per core)
↓
L3 Cache ~10ns (8-32MB shared)
↓
RAM ~100ns (수 GB)
↓
SSD ~100μs (수백 GB)
캐시 라인: CPU는 메모리를 64바이트 단위(캐시 라인)로 가져옵니다. 한 주소를 읽으면 그 주변 64바이트가 함께 로드됩니다.
캐시 히트 vs 미스
// g++ -std=c++17 -O2 -o cache_basic cache_basic.cpp
#include <vector>
#include <chrono>
#include <iostream>
int main() {
const size_t N = 1000000;
std::vector<int> data(N, 1);
// ✅ 캐시 히트: 연속 접근
auto start = std::chrono::high_resolution_clock::now();
long long sum = 0;
for (size_t i = 0; i < N; ++i) {
sum += data[i];
}
auto end = std::chrono::high_resolution_clock::now();
auto ms_seq = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
// ❌ 캐시 미스: 64칸씩 건너뛰기 (캐시 라인당 1개만 사용)
start = std::chrono::high_resolution_clock::now();
sum = 0;
for (size_t i = 0; i < N; i += 16) { // int 16개 = 64바이트
sum += data[i];
}
end = std::chrono::high_resolution_clock::now();
auto ms_strided = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Sequential: " << ms_seq << " ms, Strided: " << ms_strided << " ms\n";
return 0;
}
설명: 연속 접근은 캐시 라인당 16개 int를 활용하지만, 16칸씩 건너뛰면 캐시 라인당 1개만 쓰고 나머지는 낭비됩니다. 실제로는 strided 접근이 더 적은 원소를 읽지만, 캐시 미스가 많아 오히려 느릴 수 있습니다.
메모리 계층 시각화
flowchart TB
subgraph fast["빠른 접근 (~1ns)"]
R[Register]
L1[L1 Cache 32KB]
L2[L2 Cache 256KB]
end
subgraph slow["느린 접근 (~100ns)"]
L3[L3 Cache 8MB]
RAM[RAM]
end
R --> L1 --> L2 --> L3 --> RAM
2. 캐시 친화적 데이터 구조
원칙 1: 연속 메모리 사용
// ❌ 나쁜 예: 링크드 리스트 (노드마다 캐시 미스)
struct Node {
int value;
Node* next;
};
void sum_list(Node* head) {
for (Node* p = head; p; p = p->next) {
sum += p->value; // 매 노드마다 다른 메모리 블록
}
}
// ✅ 좋은 예: 벡터 (연속 메모리, 캐시 히트)
void sum_vector(const std::vector<int>& v) {
for (int x : v) {
sum += x; // 연속 접근, 캐시 효율 최고
}
}
원칙 2: 행 우선 순회
// g++ -std=c++17 -O2 -o matrix_order matrix_order.cpp
#include <vector>
#include <chrono>
#include <iostream>
const int N = 2048;
// ❌ 열 우선 순회 (느림): 매 접근마다 N*4바이트 건너뜀
void col_major(std::vector<std::vector<int>>& m) {
for (int c = 0; c < N; ++c) {
for (int r = 0; r < N; ++r) {
m[r][c] = r + c;
}
}
}
// ✅ 행 우선 순회 (빠름): 연속 접근
void row_major(std::vector<std::vector<int>>& m) {
for (int r = 0; r < N; ++r) {
for (int c = 0; c < N; ++c) {
m[r][c] = r + c;
}
}
}
int main() {
std::vector<std::vector<int>> m(N, std::vector<int>(N));
auto start = std::chrono::high_resolution_clock::now();
row_major(m);
auto end = std::chrono::high_resolution_clock::now();
auto ms_row = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
start = std::chrono::high_resolution_clock::now();
col_major(m);
end = std::chrono::high_resolution_clock::now();
auto ms_col = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Row-major: " << ms_row << " ms, Col-major: " << ms_col << " ms\n";
return 0;
}
원칙 3: 핫/콜드 데이터 분리
// ❌ 나쁜 예: 자주 쓰는 데이터와 안 쓰는 데이터 섞임
struct Entity {
int id; // 자주 사용
float x, y, z; // 자주 사용
float vx, vy, vz; // 자주 사용
std::string name; // 가끔 사용
std::string description; // 거의 안 사용
};
// name, description 접근 시 id, x, y, z까지 캐시에 올라옴 (낭비)
// ✅ 좋은 예: 핫 데이터와 콜드 데이터 분리
struct EntityHot {
int id;
float x, y, z;
float vx, vy, vz;
};
struct EntityCold {
std::string name;
std::string description;
};
std::vector<EntityHot> hot;
std::unordered_map<int, EntityCold> cold;
// 위치 업데이트 시 hot만 순차 접근 → 캐시 효율 최고
원칙 4: 구조체 패딩 최소화
// ❌ 나쁜 예: 패딩으로 크기 증가
struct Bad {
char a; // 1바이트 + 3바이트 패딩
int b; // 4바이트
char c; // 1바이트 + 3바이트 패딩
}; // 총 12바이트
// ✅ 좋은 예: 큰 타입 먼저 배치
struct Good {
int b; // 4바이트
char a; // 1바이트
char c; // 1바이트 + 2바이트 패딩
}; // 총 8바이트
3. AoS vs SoA 완전 예제
메모리 배치 비교
flowchart TB
subgraph AoS["AoS (Array of Structures)"]
direction LR
A1[[x0,y0,z0,vx0,vy0,vz0,r0,g0,b0]]
A2[[x1,y1,z1,vx1,vy1,vz1,r1,g1,b1]]
A1 --> A2
end
subgraph SoA["SoA (Structure of Arrays)"]
direction TB
X["x: [x0,x1,x2,...]"]
Y["y: [y0,y1,y2,...]"]
Z["z: [z0,z1,z2,...]"]
VX["vx: [vx0,vx1,vx2,...]"]
X --> Y --> Z --> VX
end
AoS 구현 (캐시 비효율)
// aos_particles.cpp
#include <vector>
#include <chrono>
#include <iostream>
struct ParticleAoS {
float x, y, z; // 위치 12바이트
float vx, vy, vz; // 속도 12바이트
float r, g, b; // 색상 12바이트
float life; // 수명 4바이트
// 총 40바이트, 캐시 라인(64B)에 1.6개
};
void updatePositionsAoS(std::vector<ParticleAoS>& particles, float dt) {
for (auto& p : particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
}
}
int main() {
const size_t N = 100000;
std::vector<ParticleAoS> particles(N);
for (size_t i = 0; i < N; ++i) {
particles[i].x = particles[i].y = particles[i].z = 0;
particles[i].vx = particles[i].vy = particles[i].vz = 1.0f;
}
auto start = std::chrono::high_resolution_clock::now();
for (int iter = 0; iter < 100; ++iter) {
updatePositionsAoS(particles, 0.016f);
}
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "AoS: " << ms << " ms\n";
return 0;
}
SoA 구현 (캐시 효율)
// soa_particles.cpp
#include <vector>
#include <chrono>
#include <iostream>
struct ParticleSystemSoA {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
std::vector<float> r, g, b;
std::vector<float> life;
void resize(size_t n) {
x.resize(n); y.resize(n); z.resize(n);
vx.resize(n); vy.resize(n); vz.resize(n);
r.resize(n); g.resize(n); b.resize(n);
life.resize(n);
}
size_t size() const { return x.size(); }
};
void updatePositionsSoA(ParticleSystemSoA& particles, float dt) {
const size_t n = particles.size();
float* x = particles.x.data();
float* y = particles.y.data();
float* z = particles.z.data();
const float* vx = particles.vx.data();
const float* vy = particles.vy.data();
const float* vz = particles.vz.data();
for (size_t i = 0; i < n; ++i) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
z[i] += vz[i] * dt;
}
}
int main() {
const size_t N = 100000;
ParticleSystemSoA particles;
particles.resize(N);
for (size_t i = 0; i < N; ++i) {
particles.x[i] = particles.y[i] = particles.z[i] = 0;
particles.vx[i] = particles.vy[i] = particles.vz[i] = 1.0f;
}
auto start = std::chrono::high_resolution_clock::now();
for (int iter = 0; iter < 100; ++iter) {
updatePositionsSoA(particles, 0.016f);
}
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "SoA: " << ms << " ms\n";
return 0;
}
AoS vs SoA 선택 가이드
| 상황 | 권장 | 이유 |
|---|---|---|
| 같은 필드만 대량 처리 (위치 업데이트) | SoA | 캐시 효율 최대, SIMD 용이 |
| 한 객체의 여러 필드를 함께 사용 | AoS | 코드 단순, 인덱스 하나로 접근 |
| SIMD 벡터화 적용 | SoA | 4/8개 float 연속 로드 |
| 개별 엔티티 조회·수정 | AoS | entities[i] 한 번에 접근 |
| 필드별 집계·통계 | SoA | 필요한 배열만 순회 |
4. False Sharing 방지
False Sharing 원리
flowchart LR
subgraph CacheLine[캐시 라인 64B]
C0[counter[0]]
C1[counter[1]]
C2[counter[2]]
end
T1[스레드1] -->|수정| C0
T2[스레드2] -->|수정| C1
C0 -.->|캐시 무효화| C1
한 스레드가 counter[0]을 수정하면, 같은 캐시 라인에 있는 counter[1]의 캐시가 무효화됩니다. 다른 스레드가 counter[1]을 읽으려면 메모리에서 다시 가져와야 합니다.
❌ 잘못된 예: 같은 캐시 라인 공유
// bad_false_sharing.cpp
#include <thread>
#include <vector>
#include <atomic>
#include <chrono>
#include <iostream>
void bad_parallel_counter() {
const int num_threads = 8;
std::atomic<int64_t> counters[8]; // 8*8 = 64바이트, 한 캐시 라인!
for (int i = 0; i < 8; ++i) counters[i].store(0);
auto start = std::chrono::high_resolution_clock::now();
std::vector<std::thread> threads;
for (int t = 0; t < num_threads; ++t) {
threads.emplace_back([&counters, t]() {
for (int i = 0; i < 10000000; ++i) {
counters[t].fetch_add(1, std::memory_order_relaxed);
}
});
}
for (auto& th : threads) th.join();
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Bad (false sharing): " << ms << " ms\n";
}
✅ 올바른 예: 캐시 라인 정렬
// good_cache_line_aligned.cpp
#include <thread>
#include <vector>
#include <atomic>
#include <chrono>
#include <iostream>
#include <new>
// C++17: 하드웨어 캐시 라인 크기
constexpr size_t CACHE_LINE_SIZE = std::hardware_destructive_interference_size;
struct alignas(CACHE_LINE_SIZE) AlignedCounter {
std::atomic<int64_t> value{0};
};
void good_parallel_counter() {
const int num_threads = 8;
std::vector<AlignedCounter> counters(num_threads);
auto start = std::chrono::high_resolution_clock::now();
std::vector<std::thread> threads;
for (int t = 0; t < num_threads; ++t) {
threads.emplace_back([&counters, t]() {
for (int i = 0; i < 10000000; ++i) {
counters[t].value.fetch_add(1, std::memory_order_relaxed);
}
});
}
for (auto& th : threads) th.join();
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Good (aligned): " << ms << " ms\n";
}
C++17 이전 환경에서의 정렬
// alignas(64)는 대부분의 x86-64에서 캐시 라인 크기
struct alignas(64) CacheLineAlignedCounter {
std::atomic<int64_t> value{0};
};
// 또는 수동 패딩
struct PaddedCounter {
std::atomic<int64_t> value{0};
char padding[64 - sizeof(std::atomic<int64_t>)];
};
5. 프리페치 활용
기본 사용법
// prefetch_example.cpp
#include <xmmintrin.h> // _mm_prefetch (SSE)
// 또는 GCC/Clang: __builtin_prefetch
void process_with_prefetch(const int* data, size_t n) {
constexpr int PREFETCH_DISTANCE = 8; // 8*4=32바이트 앞서 로드
for (size_t i = 0; i < n; ++i) {
// 다음 블록을 미리 캐시에 로드
if (i + PREFETCH_DISTANCE < n) {
__builtin_prefetch(&data[i + PREFETCH_DISTANCE], 0, 3);
// 0: 읽기, 3: temporal locality (L1에 유지)
}
// 현재 데이터 처리
int x = data[i];
// ...
}
}
링크드 리스트 순회 시 프리페치
struct Node {
int value;
Node* next;
};
int sum_list_prefetch(Node* head) {
int sum = 0;
for (Node* p = head; p; p = p->next) {
// 다음 노드를 미리 로드 (다음 반복에서 사용)
if (p->next) {
__builtin_prefetch(p->next, 0, 3);
}
sum += p->value;
}
return sum;
}
프리페치 주의사항
// ❌ 나쁜 예: 너무 멀리 프리페치 (캐시에서 밀려남)
for (size_t i = 0; i < n; ++i) {
if (i + 64 < n) {
__builtin_prefetch(&data[i + 64], 0, 3); // 너무 멂
}
process(data[i]);
}
// ✅ 좋은 예: 적당한 거리 (4~16 요소 앞)
for (size_t i = 0; i < n; ++i) {
if (i + 8 < n) {
__builtin_prefetch(&data[i + 8], 0, 3);
}
process(data[i]);
}
프리페치 locality 힌트:
0: 낮은 지역성 (L3에만)1: 중간 (L2)2: 높음 (L1에 가깝게)3: temporal (L1에 오래 유지)
6. 완전한 캐시 최적화 예제
예제 1: 게임 파티클 시스템 (SoA + SIMD 친화)
// particle_system_optimized.cpp
#include <vector>
#include <chrono>
#include <iostream>
struct ParticleSystem {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
void resize(size_t n) {
x.resize(n); y.resize(n); z.resize(n);
vx.resize(n); vy.resize(n); vz.resize(n);
}
size_t size() const { return x.size(); }
};
void update(ParticleSystem& ps, float dt) {
const size_t n = ps.size();
float* x = ps.x.data();
float* y = ps.y.data();
float* z = ps.z.data();
const float* vx = ps.vx.data();
const float* vy = ps.vy.data();
const float* vz = ps.vz.data();
// 컴파일러가 자동 벡터화하기 쉬운 패턴
for (size_t i = 0; i < n; ++i) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
z[i] += vz[i] * dt;
}
}
예제 2: 멀티스레드 워커 통계 (캐시 라인 정렬)
// worker_stats.cpp
#include <thread>
#include <vector>
#include <atomic>
#include <cstddef>
constexpr size_t CACHE_LINE = 64;
struct alignas(CACHE_LINE) WorkerStats {
std::atomic<uint64_t> requests_processed{0};
std::atomic<uint64_t> bytes_sent{0};
};
class ThreadPoolStats {
public:
explicit ThreadPoolStats(size_t num_workers)
: stats_(num_workers) {}
void record(size_t worker_id, uint64_t bytes) {
stats_[worker_id].requests_processed.fetch_add(1, std::memory_order_relaxed);
stats_[worker_id].bytes_sent.fetch_add(bytes, std::memory_order_relaxed);
}
uint64_t total_requests() const {
uint64_t sum = 0;
for (const auto& s : stats_) {
sum += s.requests_processed.load(std::memory_order_relaxed);
}
return sum;
}
private:
std::vector<WorkerStats> stats_;
};
예제 3: 행렬 타일링 (캐시에 맞는 블록 처리)
// matrix_tiling.cpp
#include <vector>
#include <algorithm>
constexpr int TILE = 32; // L1 캐시에 맞는 타일 크기
void matmul_tiled(const std::vector<float>& A,
const std::vector<float>& B,
std::vector<float>& C,
int N) {
for (int i0 = 0; i0 < N; i0 += TILE) {
for (int j0 = 0; j0 < N; j0 += TILE) {
for (int k0 = 0; k0 < N; k0 += TILE) {
int i_end = std::min(i0 + TILE, N);
int j_end = std::min(j0 + TILE, N);
int k_end = std::min(k0 + TILE, N);
for (int i = i0; i < i_end; ++i) {
for (int j = j0; j < j_end; ++j) {
float sum = C[i * N + j];
for (int k = k0; k < k_end; ++k) {
sum += A[i * N + k] * B[k * N + j];
}
C[i * N + j] = sum;
}
}
}
}
}
}
7. 자주 발생하는 에러와 해결법
에러 1: alignas로 패딩했는데도 False Sharing 발생
증상: alignas(64)를 썼는데 perf stat에서 cache-misses가 여전히 많음.
원인: std::vector<AlignedCounter>에서 AlignedCounter가 64바이트여도, 벡터가 연속 할당되면 첫 번째 요소가 64바이트 경계에 있지 않을 수 있습니다.
// ❌ 잘못된 예: 벡터 내부 요소 정렬 보장 안 됨
std::vector<AlignedCounter> counters(8);
// counters[0]의 주소가 64의 배수가 아닐 수 있음
해결법:
// ✅ 올바른 예: alignas가 있는 타입은 vector가 정렬된 메모리 할당 요청
// C++17: std::vector는 alignas를 가진 타입에 대해 정렬된 할당을 요청함
// 추가 보장: std::allocator가 over-aligned 타입 지원
std::vector<AlignedCounter> counters(8);
// 또는 수동으로 aligned_alloc 사용
AlignedCounter* counters = static_cast<AlignedCounter*>(
std::aligned_alloc(64, 8 * sizeof(AlignedCounter)));
실제로는 std::vector가 C++17 이상에서 over-aligned 타입을 지원하므로, alignas(64) 구조체가 요소로 있으면 정렬이 보장됩니다. 문제가 있다면 std::unique_ptr + aligned_alloc을 사용하세요.
에러 2: SoA로 바꿨는데 오히려 느려짐
증상: AoS에서 SoA로 전환했는데 성능이 떨어짐.
원인: (1) 한 객체의 여러 필드를 함께 사용하는 패턴인데 SoA로 바꿔서, 매번 다른 배열을 접근해 캐시가 오히려 더 자주 바뀜. (2) 인덱스 계산이 추가되어 오버헤드가 커짐.
// ❌ SoA가 불리한 경우: 한 객체의 모든 필드를 함께 사용
for (size_t i = 0; i < n; ++i) {
if (x[i] > 0 && y[i] > 0 && z[i] > 0) {
vx[i] *= 0.5f;
vy[i] *= 0.5f;
vz[i] *= 0.5f;
}
}
// 6개 배열을 오가며 접근 → 캐시 효율 나쁨
해결법: “같은 필드만 대량 순회”할 때는 SoA, “한 객체의 여러 필드를 함께 사용”할 때는 AoS 유지. 또는 Hybrid: 자주 함께 쓰는 필드만 AoS로 묶고 나머지는 SoA.
에러 3: 프리페치를 넣었는데 성능이 떨어짐
증상: __builtin_prefetch 추가 후 오히려 느려짐.
원인: (1) 프리페치 거리가 너무 멂 — 캐시에 로드된 데이터가 사용되기 전에 밀려남. (2) 순차 접근인데 프리페치 — 컴파일러/하드웨어가 이미 충분히 예측하고 있어서 불필요한 명령만 추가됨. (3) 캐시에 이미 있는 데이터를 프리페치 — 낭비.
// ❌ 순차 접근에는 프리페치 불필요 (CPU가 이미 예측함)
for (size_t i = 0; i < n; ++i) {
__builtin_prefetch(&data[i + 1], 0, 3); // 불필요
process(data[i]);
}
해결법: 랜덤 접근 또는 포인터 체이닝에서만 프리페치. 순차 접근은 제거. 거리는 4~16 요소 정도로 실험.
에러 4: 구조체 크기가 캐시 라인의 배수로 커짐
증상: sizeof(Entity)가 128바이트 이상으로 커져서, 10만 개일 때 12MB 이상 사용.
원인: 모든 필드에 alignas(64)를 써서 불필요한 패딩이 생김. 멀티스레드에서 공유되는 변수만 캐시 라인 정렬이 필요합니다.
// ❌ 과도한 정렬: 단일 스레드에서만 쓰는 구조체
struct Entity {
alignas(64) float x, y, z; // 불필요
alignas(64) float vx, vy, vz; // 불필요
};
해결법: 스레드별로 수정되는 변수만 alignas(64) 적용. 일반 구조체는 패딩 최소화만.
에러 5: 행 우선 순회인데도 느림
증상: 2차원 배열을 행 우선으로 순회하는데 여전히 느림.
원인: std::vector<std::vector<int>>는 행마다 별도 할당이라, 행이 메모리에 연속하지 않습니다. 열 우선이든 행 우선이든 캐시 미스가 많을 수 있습니다.
// ❌ 행이 연속이 아님
std::vector<std::vector<int>> m(N, std::vector<int>(N));
for (int r = 0; r < N; ++r) {
for (int c = 0; c < N; ++c) {
m[r][c] = 0; // m[r]과 m[r+1]이 다른 메모리 블록
}
}
해결법:
// ✅ 1차원 배열로 연속 저장
std::vector<int> m(N * N);
for (int r = 0; r < N; ++r) {
for (int c = 0; c < N; ++c) {
m[r * N + c] = 0;
}
}
8. 모범 사례와 체크리스트
캐시 최적화 체크리스트
- [ ] 프로파일러로 cache-misses 확인 후 최적화 (perf stat -e cache-misses)
- [ ] 연속 메모리 사용: vector 우선, list는 꼭 필요할 때만
- [ ] 2차원 데이터: 행 우선 순회, 1차원 배열로 연속 저장
- [ ] 멀티스레드 per-thread 데이터: alignas(64) 또는 std::hardware_destructive_interference_size
- [ ] 같은 필드만 대량 처리: SoA 검토
- [ ] 한 객체의 여러 필드 함께 사용: AoS 유지
- [ ] 랜덤 접근·포인터 체이닝: 프리페치 실험 (거리 4~16)
- [ ] 구조체: 큰 타입 먼저, 핫/콜드 분리
- [ ] 과도한 alignas 금지: 스레드 공유 변수만
성능 측정 명령
# g++ -std=c++17 -O2 -o bench cache_bench.cpp && perf stat -e cache-misses,cache-references ./bench
perf stat -e cache-misses,cache-references,L1-dcache-load-misses ./my_program
의사 결정 플로우
flowchart TD
A[성능 병목] --> B{프로파일러}
B -->|cache-misses 높음| C{메모리 접근 패턴}
C -->|순차 접근| D[연속 메모리? 행 우선?]
C -->|랜덤 접근| E[프리페치 활용]
C -->|멀티스레드 per-thread| F[캐시 라인 정렬]
C -->|특정 필드만 조회| G[SoA 검토]
D --> H[vector, 1차원 배열]
E --> I[__builtin_prefetch]
F --> J[alignas(64)]
G --> K[AoS vs SoA 선택]
9. 성능 벤치마크
AoS vs SoA 벤치마크
// benchmark_aos_soa.cpp
// g++ -std=c++17 -O2 -o bench benchmark_aos_soa.cpp
#include <vector>
#include <chrono>
#include <iostream>
struct ParticleAoS {
float x, y, z, vx, vy, vz, r, g, b;
};
struct ParticleSoA {
std::vector<float> x, y, z, vx, vy, vz, r, g, b;
void resize(size_t n) {
x.resize(n); y.resize(n); z.resize(n);
vx.resize(n); vy.resize(n); vz.resize(n);
r.resize(n); g.resize(n); b.resize(n);
}
};
int main() {
const size_t N = 100000;
const int ITERS = 100;
std::vector<ParticleAoS> aos(N);
ParticleSoA soa;
soa.resize(N);
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < ITERS; ++i) {
for (size_t j = 0; j < N; ++j) {
aos[j].x += aos[j].vx * 0.016f;
aos[j].y += aos[j].vy * 0.016f;
aos[j].z += aos[j].vz * 0.016f;
}
}
auto end = std::chrono::high_resolution_clock::now();
auto ms_aos = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < ITERS; ++i) {
for (size_t j = 0; j < N; ++j) {
soa.x[j] += soa.vx[j] * 0.016f;
soa.y[j] += soa.vy[j] * 0.016f;
soa.z[j] += soa.vz[j] * 0.016f;
}
}
end = std::chrono::high_resolution_clock::now();
auto ms_soa = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "AoS: " << ms_aos << " ms, SoA: " << ms_soa << " ms\n";
return 0;
}
예상 결과 (참고용)
| 구성 | 10만 파티클 × 100회 (ms) | 상대 |
|---|---|---|
| AoS | ~80 | 1x |
| SoA | ~15 | ~5.3x |
False Sharing 벤치마크
| 구성 | 8스레드 × 1000만 증가 (ms) | 상대 |
|---|---|---|
| 같은 캐시 라인 | ~200 | 1x |
| 캐시 라인 정렬 | ~25 | ~8x |
10. 프로덕션 패턴
패턴 1: 게임 ECS 컴포넌트 (SoA)
// ECS: Position, Velocity 컴포넌트를 SoA로 저장
struct PositionComponent {
std::vector<float> x, y, z;
};
struct VelocityComponent {
std::vector<float> vx, vy, vz;
};
void movement_system(PositionComponent& pos, const VelocityComponent& vel, float dt) {
for (size_t i = 0; i < pos.x.size(); ++i) {
pos.x[i] += vel.vx[i] * dt;
pos.y[i] += vel.vy[i] * dt;
pos.z[i] += vel.vz[i] * dt;
}
}
패턴 2: HTTP 서버 워커 통계 (캐시 라인 정렬)
struct alignas(64) WorkerMetrics {
std::atomic<uint64_t> requests{0};
std::atomic<uint64_t> bytes{0};
};
std::vector<WorkerMetrics> workers(num_threads);
// 각 워커 스레드가 자신의 인덱스만 수정
패턴 3: B-tree 순회 프리페치
struct BTreeNode {
int keys[32];
BTreeNode* children[33];
int count;
};
void search_prefetch(BTreeNode* root, int key) {
BTreeNode* node = root;
while (node) {
__builtin_prefetch(node->children[0], 0, 3);
// ... 키 비교
node = node->children[next];
}
}
패턴 4: 구현 체크리스트
- [ ] perf stat으로 cache-misses 확인
- [ ] AoS vs SoA: 접근 패턴에 맞게 선택
- [ ] 멀티스레드 per-thread 데이터: alignas(64)
- [ ] 2차원 데이터: 1차원 연속 + 행 우선
- [ ] 프리페치: 랜덤 접근·포인터 체이닝에서만, 거리 4~16
- [ ] 구조체: 핫/콜드 분리, 패딩 최소화
- [ ] 프로파일링 없이 최적화하지 않기
11. 정리
| 기법 | 용도 | 효과 |
|---|---|---|
| SoA | 같은 필드만 대량 처리 | 2~5배 |
| 캐시 라인 정렬 | 멀티스레드 per-thread | 2~10배 |
| 행 우선 순회 | 2차원 배열 | 5~10배 |
| 프리페치 | 랜덤 접근·포인터 체이닝 | 20~30% |
| 연속 메모리 | list → vector | 수 배 |
| 핫/콜드 분리 | 구조체 최적화 | 상황에 따라 |
핵심 원칙:
- 프로파일링 먼저: cache-misses 확인 후 최적화
- 같은 필드만 대량 처리 → SoA
- 멀티스레드 per-thread → alignas(64)
- 2차원 → 1차원 연속 + 행 우선
- 랜덤 접근 → 프리페치 실험 (거리 4~16)
- 과도한 패딩 금지: 스레드 공유 변수만
- AoS vs SoA: 접근 패턴에 맞게 선택
자주 묻는 질문 (FAQ)
Q. SoA vs AoS, 언제 어느 쪽을 써야 하나요?
A. 같은 필드만 대량 순회(위치 업데이트, 필드별 집계)하면 SoA. 한 객체의 여러 필드를 함께 사용(개별 엔티티 조회·수정)하면 AoS. Hybrid(자주 함께 쓰는 필드만 AoS)도 가능합니다.
Q. std::hardware_destructive_interference_size가 0이에요.
A. C++17 미지원 환경이거나 컴파일러가 아직 구현하지 않았을 수 있습니다. 이 경우 alignas(64)를 사용하세요. x86-64에서는 64바이트가 표준입니다.
Q. 프리페치를 넣었는데 효과가 없어요.
A. 순차 접근에서는 CPU가 이미 예측하므로 프리페치가 불필요합니다. 랜덤 접근·포인터 체이닝에서만 시도하고, 거리(4~16 요소)를 실험해 보세요.
Q. 선행으로 읽으면 좋은 글은?
A. 캐시 친화적 코드 #15-2, 데이터 지향 설계 #39-1, 캐시 정렬·패딩 #34-2를 먼저 읽으면 좋습니다.
Q. 더 깊이 공부하려면?
A. Intel Optimization Manual, AMD Software Optimization Manual, perf 도구 문서, What Every Programmer Should Know About Memory를 참고하세요.
한 줄 요약: 프로파일링으로 캐시 병목을 확인한 뒤, SoA·캐시 라인 정렬·행 우선·프리페치를 상황에 맞게 적용합니다.
이전 글: C++ 멀티스레딩 튜닝 #51-3
다음 글: C++ 컴파일 타임 최적화 #51-5
관련 글
- C++ 고급 프로파일링 완벽 가이드 | perf·gprof
- C++ SIMD 최적화 실전 | SSE·AVX2·NEON 인트린직으로 4배 빠르게 [#51-2]
- C++ 데이터 지향 설계 실전 | SoA·캐시 친화적 레이아웃·ECS·핫/콜드 분리 가이드
- C++ Cache Optimization |
- C++ 메모리 정렬 |