C++ 캐시 최적화 | 메모리 접근 패턴 바꿔서 성능 10배 향상시키기
이 글의 핵심
C++ 캐시 최적화에 대한 실전 가이드입니다. 메모리 접근 패턴 바꿔서 성능 10배 향상시키기 등을 예제와 함께 상세히 설명합니다.
들어가며: 같은 연산인데 10배 차이
”배열 순회 방향만 바꿨는데 10배 빨라졌어요”
2차원 배열을 순회하는 코드를 작성했습니다. 순회 방향에 따라 성능이 10배 차이났습니다.
CPU는 메모리를 캐시 라인(cache line—CPU 캐시가 한 번에 가져오는 메모리 블록 단위, 보통 64바이트) 단위로 가져오기 때문에, 접근 순서가 “연속된 주소”를 따라가면 캐시 히트(필요한 데이터가 캐시에 있어 빠르게 접근)가 많아지고, 건너뛰며 접근하면 캐시 미스(캐시에 없어 메인 메모리에서 가져와야 함)가 늘어납니다. 행 우선 순회, 구조체 정렬, 연관된 데이터를 한 덩어리로 두는 식의 데이터 지역성(data locality—자주 쓰는 데이터를 가까이·연속으로 두어 캐시 효율을 높이는 것)을 신경 쓰면, 같은 연산이라도 훨씬 빨라질 수 있습니다.
느린 코드:
int matrix[1000][1000];
// ❌ 열 우선 순회 (느림)
for (int col = 0; col < 1000; ++col) {
for (int row = 0; row < 1000; ++row) {
sum += matrix[row][col]; // 캐시 미스 많음
}
}
// 시간: 약 50ms
빠른 코드:
// 복사해 붙여넣은 뒤: g++ -std=c++17 -O2 -o cache_fast cache_fast.cpp && ./cache_fast
#include <iostream>
#include <chrono>
int main() {
int matrix[1000][1000] = {};
long long sum = 0;
auto start = std::chrono::high_resolution_clock::now();
// ✅ 행 우선 순회 (빠름)
for (int row = 0; row < 1000; ++row) {
for (int col = 0; col < 1000; ++col) {
sum += matrix[row][col]; // 캐시 히트
}
}
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "sum=" << sum << " time=" << ms << "ms\n";
return 0;
}
실행 결과: sum=0 time=Nms 형태로 출력됩니다 (환경에 따라 N 값은 다름).
원인: CPU 캐시는 연속된 메모리를 미리 가져옴
이 글을 읽으면:
- CPU 캐시의 동작 원리를 이해할 수 있습니다.
- 캐시 친화적인 코드를 작성할 수 있습니다.
- 데이터 지역성을 활용할 수 있습니다.
- 실전에서 메모리 접근을 최적화할 수 있습니다.
목차
- CPU 캐시 기초
- 데이터 지역성
- 캐시 미스 줄이기
- 구조체 레이아웃 최적화
- AoS vs SoA 완전 예제
- False Sharing (가짜 공유)
- 프리페치 활용
- 실전 최적화 패턴
- 자주 발생하는 에러와 해결법
- 성능 벤치마크
- 프로덕션 패턴
1. CPU 캐시 기초
메모리 계층
CPU Register < 1ns (가장 빠름)
↓
L1 Cache ~1ns (32-64KB)
↓
L2 Cache ~3ns (256KB-1MB)
↓
L3 Cache ~10ns (8-32MB)
↓
RAM ~100ns (수 GB)
↓
SSD ~100us (수백 GB)
↓
HDD ~10ms (수 TB, 가장 느림)
캐시 라인: 보통 64바이트 단위로 메모리를 가져옴
메모리 계층 시각화
flowchart TB
subgraph fast["빠른 접근"]
R[Register]
L1[L1 Cache 32KB]
L2[L2 Cache 256KB]
end
subgraph slow["느린 접근"]
L3[L3 Cache 8MB]
RAM[RAM 100ns]
end
R --> L1 --> L2 --> L3 --> RAM
캐시 히트 vs 미스
int arr[1000];
// 캐시 히트: 연속 접근
for (int i = 0; i < 1000; ++i) {
sum += arr[i]; // 빠름
}
// 캐시 미스: 불규칙 접근
for (int i = 0; i < 1000; i += 64) {
sum += arr[i]; // 느림 (캐시 라인 낭비)
}
코드 상세 설명:
캐시 히트 (연속 접근):
arr[0],arr[1],arr[2], … 순서대로 접근합니다.- 캐시 라인은 보통 64바이트(int 16개)를 한 번에 가져옵니다.
arr[0]을 읽을 때arr[0]~arr[15]가 캐시에 함께 로드됩니다.- 다음 15번의 접근은 캐시 히트 (매우 빠름, ~1ns).
- 결과: 1000번 접근 중 약 62번만 메모리에서 가져옴 (1000/16).
캐시 미스 (불규칙 접근):
arr[0],arr[64],arr[128], … 64칸씩 건너뜁니다.- 매번 다른 캐시 라인을 접근하므로 캐시 미스 발생.
- 16번 접근 중 16번 모두 메모리에서 가져와야 함.
- 캐시에 로드된 나머지 15개 요소는 사용하지 않고 버려집니다.
- 결과: 약 16배 느림 (100ns vs 1ns per access).
실제 성능 차이:
- 연속 접근: ~1000ns (1μs)
- 불규칙 접근: ~16000ns (16μs)
- 데이터가 클수록 차이가 더 커집니다.
2. 데이터 지역성
시간적 지역성 (Temporal Locality)
// ✅ 좋은 예: 같은 데이터 반복 접근
int sum = 0;
for (int i = 0; i < 100; ++i) {
sum += data[0]; // data[0]이 캐시에 유지됨
}
// ❌ 나쁜 예: 매번 다른 데이터
for (int i = 0; i < 100; ++i) {
sum += data[rand() % 10000]; // 캐시 미스 많음
}
공간적 지역성 (Spatial Locality)
struct Point {
int x, y, z;
};
std::vector<Point> points(1000);
// ✅ 좋은 예: 연속 접근
for (const auto& p : points) {
sum += p.x + p.y + p.z; // 캐시 친화적
}
// ❌ 나쁜 예: 포인터 체이싱
struct Node {
int value;
Node* next;
};
Node* head = /* ... */;
for (Node* p = head; p != nullptr; p = p->next) {
sum += p->value; // 캐시 미스 많음
}
3. 캐시 미스 줄이기
패턴 1: 연속 메모리 사용
// ❌ 나쁜 예: 링크드 리스트 (캐시 미스)
std::list<int> data;
for (int val : data) {
sum += val; // 노드마다 캐시 미스
}
// ✅ 좋은 예: 벡터 (캐시 친화적)
std::vector<int> data;
for (int val : data) {
sum += val; // 연속 메모리, 캐시 히트
}
패턴 2: 행 우선 순회
const int N = 1000;
int matrix[N][N];
// ❌ 열 우선 (느림)
for (int col = 0; col < N; ++col) {
for (int row = 0; row < N; ++row) {
matrix[row][col] = 0; // 캐시 미스
}
}
// ✅ 행 우선 (빠름)
for (int row = 0; row < N; ++row) {
for (int col = 0; col < N; ++col) {
matrix[row][col] = 0; // 캐시 히트
}
}
코드 상세 설명:
C++ 2차원 배열의 메모리 배치:
int matrix[N][N]은 행 우선(row-major) 으로 메모리에 저장됩니다.- 메모리 순서:
matrix[0][0],matrix[0][1], …,matrix[0][N-1],matrix[1][0], … - 같은 행의 요소들이 메모리상에서 연속적으로 배치됩니다.
열 우선 순회 (느림):
접근 순서: matrix[0][0] → matrix[1][0] → matrix[2][0] → ...
메모리 순서: [0][0] [0][1] [0][2] ... [1][0] [1][1] ...
matrix[0][0]을 읽으면matrix[0][0]~[0][15]가 캐시에 로드됩니다.- 하지만 다음에
matrix[1][0]을 접근하므로 1000 * 4바이트 = 4KB 떨어진 위치를 읽습니다. - 캐시에 로드된
matrix[0][1]~[0][15]는 사용하지 않고 버려집니다. - 결과: 거의 모든 접근이 캐시 미스 (매우 느림).
행 우선 순회 (빠름):
접근 순서: matrix[0][0] → matrix[0][1] → matrix[0][2] → ...
메모리 순서: [0][0] [0][1] [0][2] ... (일치!)
matrix[0][0]을 읽으면matrix[0][0]~[0][15]가 캐시에 로드됩니다.- 다음 15번의 접근(
matrix[0][1]~[0][15])은 모두 캐시 히트. - 결과: 16번 중 1번만 캐시 미스 (매우 빠름).
성능 차이 (1000x1000 행렬):
- 열 우선: ~100ms (1,000,000번 캐시 미스)
- 행 우선: ~10ms (62,500번 캐시 미스)
- 약 10배 차이!
실무 팁: 행렬 곱셈, 이미지 처리 등에서 순회 순서를 잘못 정하면 성능이 크게 떨어집니다.
4. 구조체 레이아웃 최적화
패딩 최소화
// ❌ 나쁜 예: 패딩 많음 (16바이트)
struct Bad {
char c1; // 1바이트
// 3바이트 패딩
int i; // 4바이트
char c2; // 1바이트
// 3바이트 패딩
};
// ✅ 좋은 예: 패딩 최소화 (12바이트)
struct Good {
int i; // 4바이트
char c1; // 1바이트
char c2; // 1바이트
// 2바이트 패딩
};
핫/콜드 데이터 분리
// ❌ 나쁜 예: 자주 쓰는 데이터와 안 쓰는 데이터 섞임
struct Entity {
int id; // 자주 사용
float x, y, z; // 자주 사용
std::string name; // 가끔 사용
std::string description; // 거의 안 사용
};
// ✅ 좋은 예: 핫 데이터만 분리
struct EntityHot {
int id;
float x, y, z;
};
struct EntityCold {
std::string name;
std::string description;
};
std::vector<EntityHot> hotData;
std::map<int, EntityCold> coldData;
5. AoS vs SoA 완전 예제
메모리 배치 비교
flowchart LR
subgraph AoS["AoS: 구조체 배열"]
direction TB
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: 배열의 구조체"]
direction TB
S1[""x: (x0,x1,x2,..."]"]
S2[""vx: (vx0,vx1,vx2,..."]"]
S3[""r: (r0,r1,r2,..."]"]
S1 --> S2 --> S3
end
AoS (Array of Structures) — 구조체 배열
#include <vector>
#include <chrono>
#include <iostream>
struct ParticleAoS {
float x, y, z; // 위치 (12바이트)
float vx, vy, vz; // 속도 (12바이트)
float r, g, b; // 색상 (12바이트)
// 총 36바이트
};
void updatePositionsAoS(std::vector<ParticleAoS>& particles) {
for (auto& p : particles) {
p.x += p.vx;
p.y += p.vy;
p.z += p.vz;
}
}
int main() {
std::vector<ParticleAoS> particles(100000);
// 초기화...
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 100; ++i) {
updatePositionsAoS(particles);
}
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;
}
AoS 메모리 배치:
메모리: [x0,y0,z0,vx0,vy0,vz0,r0,g0,b0][x1,y1,z1,vx1,vy1,vz1,r1,g1,b1]...
↑ 36바이트 (9 floats) ↑ 36바이트
캐시 라인(64B)에 약 1.7개 파티클 → 위치만 써도 색상까지 로드됨 (낭비)
SoA (Structure of Arrays) — 배열의 구조체
struct ParticleSystemSoA {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
std::vector<float> 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);
}
size_t size() const { return x.size(); }
};
void updatePositionsSoA(ParticleSystemSoA& particles) {
const size_t n = particles.size();
for (size_t i = 0; i < n; ++i) {
particles.x[i] += particles.vx[i];
particles.y[i] += particles.vy[i];
particles.z[i] += particles.vz[i];
}
}
SoA 메모리 배치:
x 배열: [x0,x1,x2,x3,x4,x5,x6,x7,x8,x9,x10,x11,x12,x13,x14,x15,...]
vx 배열: [vx0,vx1,vx2,vx3,vx4,vx5,vx6,vx7,vx8,vx9,vx10,vx11,...]
r 배열: [r0,r1,r2,r3,r4,r5,r6,r7,r8,r9,r10,r11,r12,r13,r14,r15,...]
캐시 라인(64B)에 16개 float → x[i] 읽을 때 x[i]~x[i+15] 모두 활용
AoS vs SoA 선택 가이드
| 상황 | 권장 | 이유 |
|---|---|---|
| 같은 필드만 대량 처리 (위치 업데이트) | SoA | 캐시 효율 최대 |
| 한 객체의 여러 필드를 함께 사용 | AoS | 코드 단순 |
| SIMD 벡터화 적용 | SoA | 4/8개씩 병렬 처리 용이 |
| 개별 엔티티 조회·수정 | AoS | 인덱스 하나로 접근 |
6. False Sharing (가짜 공유)
False Sharing 발생 원리
flowchart LR
subgraph bad["❌ False Sharing"]
CL["캐시 라인 64B"]
C0[""counter(0"]"]
C1[""counter(1"]"]
C2[""counter(2"]"]
CL --> C0 --> C1 --> C2
T1["스레드1 수정"] -.->|무효화| C0
T2["스레드2 수정"] -.->|무효화| C1
end
한 스레드가 counter[0]을 수정하면 같은 캐시 라인에 있는 counter[1], counter[2]의 캐시가 무효화되어 다른 스레드가 매번 메모리에서 다시 로드해야 합니다.
문제: 같은 캐시 라인을 여러 스레드가 수정
#include <thread>
#include <vector>
#include <atomic>
#include <chrono>
#include <iostream>
// ❌ 나쁜 예: false sharing 발생
void badParallelCounter() {
const int numThreads = 4;
std::vector<int> counters(numThreads, 0); // 4개 int = 16바이트, 같은 캐시 라인!
std::vector<std::thread> threads;
for (int t = 0; t < numThreads; ++t) {
threads.emplace_back([&counters, t]() {
for (int i = 0; i < 10000000; ++i) {
counters[t]++; // 다른 스레드의 캐시 라인 무효화 유발
}
});
}
for (auto& th : threads) th.join();
}
원인: counters[0], counters[1], … 가 64바이트 캐시 라인 안에 같이 들어가면, 한 스레드가 counters[0]을 수정할 때마다 해당 캐시 라인이 무효화되고, counters[1]을 쓰는 다른 스레드는 매번 메모리에서 다시 가져와야 합니다.
해결: 캐시 라인 정렬
#include <cstddef>
// ✅ 좋은 예: 캐시 라인 경계에 정렬
struct alignas(64) CacheLineAlignedCounter {
int value;
char padding[64 - sizeof(int)]; // 같은 캐시 라인 공유 방지
};
void goodParallelCounter() {
const int numThreads = 4;
std::vector<CacheLineAlignedCounter> counters(numThreads);
std::vector<std::thread> threads;
for (int t = 0; t < numThreads; ++t) {
threads.emplace_back([&counters, t]() {
for (int i = 0; i < 10000000; ++i) {
counters[t].value++;
}
});
}
for (auto& th : threads) th.join();
}
C++17 alignas 활용
// 각 카운터가 별도 캐시 라인에 배치
struct alignas(64) ThreadLocalCounter {
std::atomic<int64_t> count{0};
};
void benchmarkCounters() {
const int numThreads = 4;
std::vector<ThreadLocalCounter> counters(numThreads);
auto start = std::chrono::high_resolution_clock::now();
std::vector<std::thread> threads;
for (int t = 0; t < numThreads; ++t) {
threads.emplace_back([&counters, t]() {
for (int i = 0; i < 10000000; ++i) {
counters[t].count.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 << "Aligned: " << ms << " ms\n";
}
성능 차이: false sharing 제거 시 2~10배 빨라지는 경우가 많습니다.
7. 프리페치 활용
기본 사용법
#include <xmmintrin.h> // _mm_prefetch (또는 GCC/Clang: __builtin_prefetch)
void processWithPrefetch(const std::vector<int>& data) {
const size_t n = data.size();
const int PREFETCH_DISTANCE = 8; // 몇 요소 앞을 미리 로드할지
for (size_t i = 0; i < n; ++i) {
// 다음에 쓸 블록 미리 캐시에 로드
if (i + PREFETCH_DISTANCE < n) {
__builtin_prefetch(&data[i + PREFETCH_DISTANCE], 0, 3);
// 인자: (주소, 0=읽기, 3=모든 캐시 레벨)
}
process(data[i]);
}
}
링크드 리스트 순회에 프리페치
struct Node {
int value;
Node* next;
};
int sumListWithPrefetch(Node* head) {
int sum = 0;
Node* curr = head;
while (curr != nullptr) {
// 다음 노드 미리 로드 (포인터 따라가기 전에)
if (curr->next != nullptr) {
__builtin_prefetch(curr->next, 0, 3);
}
sum += curr->value;
curr = curr->next;
}
return sum;
}
인덱스 배열 따라가기
// indices[i]가 data의 인덱스 → data[indices[i]] 접근
void gatherWithPrefetch(const std::vector<float>& data,
const std::vector<size_t>& indices) {
const size_t n = indices.size();
float sum = 0;
for (size_t i = 0; i < n; ++i) {
if (i + 4 < n) {
__builtin_prefetch(&data[indices[i + 4]], 0, 3);
}
sum += data[indices[i]];
}
}
주의: 프리페치 거리를 너무 크게 하면 캐시에서 밀려나고, 너무 작으면 효과가 없습니다. 4~16 정도로 실험해 보는 것이 좋습니다.
8. 실전 최적화 패턴
패턴 1: 블록 단위 처리
const int BLOCK_SIZE = 64; // 캐시 라인 크기
void processBlocked(int* data, int N) {
for (int i = 0; i < N; i += BLOCK_SIZE) {
int end = std::min(i + BLOCK_SIZE, N);
for (int j = i; j < end; ++j) {
process(data[j]);
}
}
}
패턴 2: 루프 융합
std::vector<int> data(10000);
// ❌ 나쁜 예: 여러 번 순회
for (int& val : data) {
val *= 2;
}
for (int& val : data) {
val += 10;
}
// ✅ 좋은 예: 한 번만 순회
for (int& val : data) {
val *= 2;
val += 10;
}
패턴 3: 정렬로 지역성 개선
struct Entity {
int type;
// 데이터...
};
std::vector<Entity> entities;
// 타입별로 정렬
std::sort(entities.begin(), entities.end(),
{
return a.type < b.type;
});
// 같은 타입끼리 연속 처리 (캐시 친화적)
for (const auto& e : entities) {
processType(e.type, e);
}
패턴 4: 데이터 재배치 (SoA)
// ❌ 나쁜 예: AoS (Array of Structures)
struct Particle {
float x, y, z; // 위치
float vx, vy, vz; // 속도
float r, g, b; // 색상
};
std::vector<Particle> particles(10000);
// 위치만 업데이트 (색상도 캐시에 로드됨, 낭비)
for (auto& p : particles) {
p.x += p.vx;
p.y += p.vy;
p.z += p.vz;
}
// ✅ 좋은 예: SoA (Structure of Arrays)
struct ParticleSystem {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
std::vector<float> r, g, b;
};
ParticleSystem particles;
particles.x.resize(10000);
// ...
// 위치만 업데이트 (위치 데이터만 캐시에 로드)
for (size_t i = 0; i < particles.x.size(); ++i) {
particles.x[i] += particles.vx[i];
particles.y[i] += particles.vy[i];
particles.z[i] += particles.vz[i];
}
SoA 상세 설명:
- AoS의 문제: 캐시 라인(64바이트)에 약 7개 float가 들어갑니다.
x0을 읽으면x0,y0,z0,vx0,vy0,vz0,r0가 캐시에 로드됩니다. 하지만x만 사용하므로 나머지는 낭비됩니다. 캐시 효율: 약 14% (1/7) - SoA의 장점: 캐시 라인(64바이트)에 16개 float가 들어갑니다.
x0을 읽으면x0~x15가 캐시에 로드됩니다. 다음 15번의 접근은 모두 캐시 히트! 캐시 효율: 100% - 성능 향상: 10,000개 파티클 업데이트 시 AoS ~50μs, SoA ~10μs → 약 5배 빠름!
9. 자주 발생하는 에러와 해결법
에러 1: 열 우선 순회로 인한 극심한 저하
증상: 2D 배열 처리 시 예상보다 10배 이상 느림.
원인: C/C++ 배열은 행 우선 저장인데 열 우선으로 순회함.
해결:
// ❌ 잘못된 순서
for (int col = 0; col < COLS; ++col)
for (int row = 0; row < ROWS; ++row)
process(matrix[row][col]);
// ✅ 올바른 순서 (행 우선)
for (int row = 0; row < ROWS; ++row)
for (int col = 0; col < COLS; ++col)
process(matrix[row][col]);
에러 2: False sharing으로 멀티스레드가 느려짐
증상: 스레드 수를 늘렸는데 오히려 느려짐.
원인: 서로 다른 스레드가 같은 캐시 라인에 있는 변수를 수정함.
해결:
// ❌ 같은 캐시 라인 공유
std::atomic<int> counters[8];
// ✅ 캐시 라인 정렬
struct alignas(64) AlignedCounter {
std::atomic<int> value{0};
};
std::vector<AlignedCounter> counters(8);
에러 3: 프리페치 과다로 성능 저하
증상: __builtin_prefetch를 넣었는데 오히려 느려짐.
원인: 너무 먼 미래 데이터를 프리페치해 유효 데이터가 캐시에서 밀려남.
해결:
// ❌ 거리 너무 큼 (캐시 오염)
__builtin_prefetch(&data[i + 64], 0, 3);
// ✅ 적절한 거리 (4~16)
__builtin_prefetch(&data[i + 8], 0, 3);
에러 4: SoA와 AoS 혼용 시 인덱스 불일치
증상: SoA로 전환 후 일부 파티클이 잘못된 데이터를 참조함.
원인: resize 시 일부 배열만 크기 변경하거나, 인덱스 계산 오류.
해결:
// ✅ SoA 크기 일관성 유지
struct ParticleSystem {
std::vector<float> x, y, z, 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);
}
};
에러 5: list 대신 vector를 써야 할 곳
증상: std::list 순회가 std::vector보다 훨씬 느림.
원인: list는 노드가 흩어져 있어 캐시 미스가 많음.
해결:
// ❌ 순회만 할 때 list
std::list<int> items;
for (auto v : items) sum += v;
// ✅ 순회 위주면 vector
std::vector<int> items;
for (auto v : items) sum += v;
10. 성능 벤치마크
벤치마크 1: 행 우선 vs 열 우선
#include <iostream>
#include <chrono>
const int N = 4096;
int matrix[N][N];
void benchmarkRowMajor() {
long long sum = 0;
auto start = std::chrono::high_resolution_clock::now();
for (int row = 0; row < N; ++row) {
for (int col = 0; col < N; ++col) {
sum += matrix[row][col];
}
}
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Row-major: " << ms << " ms (sum=" << sum << ")\n";
}
void benchmarkColMajor() {
long long sum = 0;
auto start = std::chrono::high_resolution_clock::now();
for (int col = 0; col < N; ++col) {
for (int row = 0; row < N; ++row) {
sum += matrix[row][col];
}
}
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Col-major: " << ms << " ms (sum=" << sum << ")\n";
}
int main() {
benchmarkRowMajor(); // 예: 15ms
benchmarkColMajor(); // 예: 150ms (약 10배 차이)
}
벤치마크 2: AoS vs SoA
환경: 100,000 파티클, 위치만 100회 업데이트
AoS: 52ms
SoA: 11ms
비율: SoA가 약 4.7배 빠름
벤치마크 3: False sharing 제거
환경: 4스레드, 각 1000만 회 카운터 증가
False sharing: 180ms
alignas(64) 적용: 45ms
비율: 약 4배 빠름
벤치마크 4: vector vs list 순회
// 100만 요소 순회 합계
std::vector<int> vec(1000000);
std::list<int> lst(1000000);
// vector: ~2ms
// list: ~25ms (약 12배 느림)
11. 프로덕션 패턴
패턴 1: ECS (Entity-Component-System) 스타일 SoA
// 게임 엔진에서 흔한 패턴
struct TransformComponent {
std::vector<float> x, y, z;
std::vector<float> rotX, rotY, rotZ;
};
struct RenderComponent {
std::vector<uint32_t> textureId;
std::vector<float> r, g, b, a;
};
void updateTransforms(TransformComponent& tf, float dt) {
for (size_t i = 0; i < tf.x.size(); ++i) {
tf.x[i] += 0.1f * dt; // 위치만 연속 접근
tf.y[i] += 0.1f * dt;
tf.z[i] += 0.1f * dt;
}
}
패턴 2: 캐시 라인 크기 상수화
namespace cache {
constexpr size_t LINE_SIZE = 64;
constexpr size_t L1_SIZE = 32 * 1024;
constexpr size_t L2_SIZE = 256 * 1024;
}
template<typename T>
struct alignas(cache::LINE_SIZE) CacheLineAligned {
T value;
};
패턴 3: 프로파일링 후 최적화
// 1. 프로파일러로 캐시 미스 확인 (perf, VTune)
// 2. 캐시 미스 많은 루프 식별
// 3. 순회 순서, SoA 전환, 프리페치 적용
// 4. 벤치마크로 검증
패턴 4: 데이터 지향 설계 체크리스트
- [ ] 연속 메모리 사용 (vector > list)
- [ ] 행 우선 순회 (2D 배열)
- [ ] SoA 고려 (같은 필드 대량 처리 시)
- [ ] 핫/콜드 분리 (자주 쓰는 필드만 묶기)
- [ ] 멀티스레드 시 캐시 라인 정렬 (false sharing 방지)
- [ ] 프리페치 실험 (포인터 체이닝, 인덱스 배열)
성능 비교 요약
| 최적화 | 효과 | 적용 난이도 |
|---|---|---|
| 행 우선 순회 | 5~10배 | 쉬움 |
| SoA 전환 | 3~5배 | 중간 |
| False sharing 제거 | 2~10배 | 쉬움 |
| vector 대신 list 제거 | 5~20배 | 쉬움 |
| 프리페치 | 1.2~1.5배 | 중간 |
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 프로파일링 | “어디가 느린지 모르겠어요” perf·gprof로 병목 찾기
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- C++ Move Semantics | std::move로 불필요한 복사 제거하고 성능 최적화
이 글에서 다루는 키워드 (관련 검색어)
C++ 캐시 친화, cache friendly, 메모리 접근 패턴, 성능 최적화, 데이터 지향 설계, AoS SoA, false sharing, 프리페치 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 원칙 | 설명 |
|---|---|
| 연속 메모리 | vector > list |
| 행 우선 순회 | 2D 배열은 행 우선 |
| SoA > AoS | 자주 쓰는 필드만 분리 |
| 패딩 최소화 | 큰 필드 먼저 |
| 루프 융합 | 여러 순회 → 한 번 |
| 정렬 | 같은 타입 연속 처리 |
| False sharing 방지 | alignas(64)로 스레드별 데이터 분리 |
| 프리페치 | 포인터/인덱스 따라갈 때 다음 블록 미리 로드 |
핵심 원칙:
- 연속 메모리 선호
- 순차 접근 최대화
- 핫 데이터 분리
- 캐시 라인 고려
- 측정으로 검증
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. CPU 캐시의 동작 원리, 캐시 미스를 줄이는 방법, 데이터 지역성, 그리고 실전에서 메모리 접근을 최적화하는 패턴을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: 연속 메모리·로컬리티를 살리면 캐시 히트가 늘어 성능이 좋아집니다. 다음으로 컴파일 타임 최적화(#15-3)를 읽어보면 좋습니다.
이전 글: [C++ 실전 가이드 #15-1] 프로파일링과 병목 지점 찾기: 성능 측정의 기초
다음 글: [C++ 실전 가이드 #15-3] 컴파일 타임 최적화: constexpr과 템플릿 메타프로그래밍
관련 글
- C++ 프로파일링 |
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- C++ 캐시 효율적인 코드: 데이터 지향 설계 가이드
- C++ 캐시 최적화 실전 | 캐시 친화적 구조·프리페치·False Sharing·AoS vs SoA 가이드
- C++ STL 알고리즘 기초 | sort·find·count·transform·accumulate 가이드