C++ 캐시 효율적인 코드: 데이터 지향 설계 가이드
이 글의 핵심
15번 성능 최적화를 하드웨어 레벨로 확장. 데이터 지향 설계와 캐시 라인 정렬·패딩으로 압도적 성능을 내는 방법을 다룹니다. AoS vs SoA, 거짓 공유 해결, 실전 벤치마크까지 C++ 실전 가이드 시리즈에서 예제와 함께 다룹니다.
들어가며: 캐시가 성능을 좌우한다
”같은 O(n)인데 왜 이 구현만 느릴까?”
15번에서 프로파일링과 캐시 친화적 코드를 다뤘다면, 39번은 하드웨어 최적화까지 파고듭니다. 현대 CPU에서는 메모리 접근 지연이 연산 비용보다 훨씬 크기 때문에, 캐시 라인 단위로 데이터를 배치하고 접근하는 데이터 지향 설계(Data-Oriented Design, DoD)(데이터 배치와 접근 패턴을 먼저 설계해 캐시·SIMD에 맞추는 방식)가 실전에서 큰 차이를 냅니다. AoS(Array of Structures—구조체의 배열. 한 요소에 여러 필드가 묶임) 대신 SoA(Structure of Arrays—필드별로 배열을 두고 같은 인덱스로 접근)로 바꾸면, 순회 시 캐시 미스가 줄고 SIMD와도 잘 맞습니다. 캐시 라인 정렬과 패딩으로 거짓 공유(false sharing—서로 다른 스레드가 같은 캐시 라인을 수정해 캐시가 무효화되는 현상)를 피하면 멀티스레드 성능도 안정됩니다.
이 글에서 다루는 것:
- 데이터 지향 설계(DoD): AoS vs SoA, 엔티티 단위보다 데이터 단위로 생각하기
- 캐시 라인: 64바이트 단위, 정렬·패딩·alignas
- 거짓 공유 방지: 스레드별 데이터를 캐시 라인 경계에 맞추기
- 문제 시나리오: 실제 겪는 성능 병목 상황
- 완전한 캐시 최적화 예제: AoS→SoA 변환, 벤치마크 코드
- 일반적인 에러: 과도한 패딩, SoA 오용, 정렬 실수
- 성능 벤치마크: AoS vs SoA, 패딩 전/후 비교
- 프로덕션 패턴: 게임 엔진, 시뮬레이션, 멀티스레드 워커
개념을 잡는 비유
C++에서 자주 쓰는 비유로, 템플릿은 붕어빵 틀, 스마트 포인터는 자동 청소 로봇, RAII는 자동문처럼 스코프를 나가며 자원을 정리한다고 기억해 두면 다른 글과도 연결하기 쉽습니다.
목차
- 문제 시나리오: 왜 캐시 최적화가 필요한가
- 데이터 지향 설계 (DoD)
- 캐시 라인과 정렬
- 거짓 공유와 패딩
- 완전한 캐시 최적화 예제
- 자주 발생하는 에러와 해결법
- 성능 벤치마크
- 프로덕션 패턴
- 정리
1. 문제 시나리오: 왜 캐시 최적화가 필요한가
시나리오 1: “10만 개 엔티티 업데이트가 너무 느려요”
"게임에서 10만 개 파티클의 위치만 갱신하는데 16ms를 넘어요."
"프로파일러를 돌려보니 연산 자체는 O(n)인데, 메모리 접근이 병목이에요."
원인: AoS(Array of Structures)로 설계하면 Entity 전체(위치 12바이트 + 속도 12바이트 + id 4바이트 + 기타)가 한 덩어리로 있어, “x, y, z만 갱신”해도 vy, id 등 불필요한 데이터까지 캐시에 로드됩니다. 캐시 라인(64바이트)당 실제로 쓰는 데이터 비율이 낮아 캐시 낭비가 큽니다.
시나리오 2: “스레드를 늘렸는데 오히려 느려요”
"4스레드로 병렬 카운팅했는데 단일 스레드보다 2배도 안 빨라요."
"perf stat으로 보니 cache-misses가 엄청 많아요."
원인: 스레드별 카운터가 같은 캐시 라인에 있으면, 한 스레드가 수정할 때마다 다른 스레드의 캐시가 무효화됩니다. 거짓 공유(false sharing) 때문입니다.
시나리오 3: “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]가 연속이므로 벡터화가 쉽습니다.
시나리오 4: “대량 데이터 처리 파이프라인이 병목이에요”
"로그 100만 건을 파싱해서 필드별로 집계하는데 메모리 대역폭이 한계예요."
"필드마다 순회를 여러 번 돌아서 캐시가 계속 밀려나요."
원인: 한 레코드에서 여러 필드를 읽다 보면 캐시 라인이 자주 바뀌고, 같은 데이터를 여러 번 메모리에서 가져오게 됩니다. SoA로 변환해 “필드별로 한 번씩 순회”하면 캐시 재사용률이 올라갑니다.
시나리오 5: “특정 필드만 조회하는데 전체 구조체를 로드해요”
"DB에서 100만 건의 '나이' 필드만 집계하는데, 전체 행을 메모리에 올려요."
"이름, 주소 등 안 쓰는 필드까지 캐시에 올라와 대역폭을 낭비해요."
원인: 행(row) 단위 저장은 AoS와 같습니다. 열(column) 단위 저장이나 SoA 형태로 “나이 배열만” 순회하면 필요한 바이트만 읽어 캐시 효율이 좋아집니다. 컬럼형 스토리지(Columnar Storage)가 이 원리입니다.
시나리오 6: “엔티티 타입별로 다른 처리인데 한 배열에 섞여 있어요”
"게임에서 플레이어, 적, 아이템이 한 Entity 배열에 있어요."
"플레이어만 업데이트할 때도 적·아이템 데이터가 캐시에 올라와요."
원인: 타입별로 분리된 배열(SoA + 타입별 분리)을 쓰면, “플레이어 위치 갱신” 시 플레이어 데이터만 순차 접근해 캐시가 낭비되지 않습니다.
2. 데이터 지향 설계 (DoD)
AoS vs SoA 개념
flowchart TB
subgraph AoS["AoS (Array of Structures)"]
direction TB
E1[Entity0: x,y,z,vx,vy,vz,id]
E2[Entity1: x,y,z,vx,vy,vz,id]
E3[Entity2: x,y,z,vx,vy,vz,id]
E1 --> E2 --> E3
end
subgraph SoA["SoA (Structure of Arrays)"]
direction TB
X[x0,x1,x2,...]
Y[y0,y1,y2,...]
Z[z0,z1,z2,...]
VX[vx0,vx1,vx2,...]
end
AoS -->|"위치만 순회 시"| Waste["캐시에 vx,vy,vz,id까지 로드 → 낭비"]
SoA -->|"위치만 순회 시"| Hit["x,y,z만 연속 로드 → 캐시·SIMD 유리"]
- AoS(Array of Structures): 한 구조체에 위치·속도·색 등이 다 들어 있고, 그 구조체 배열을 순회합니다. 한 번의 연산에 필요한 필드만 쓰더라도 캐시에 다른 필드까지 같이 올라와 캐시 낭비가 생깁니다.
- SoA(Structure of Arrays): 위치 배열, 속도 배열, 색 배열을 따로 두고, 같은 인덱스로 각 필드에 접근합니다. “위치만 순회”할 때는 위치 배열만 캐시에 올라와 캐시 효율과 SIMD 벡터화에 유리합니다.
AoS는 entities[i] 한 번 접근에 Entity 전체(위치·속도·id)가 한 캐시 라인에 들어와, “x만 갱신”해도 vy, id 등이 같이 로드됩니다. SoA는 x[i], y[i], z[i] 처럼 같은 인덱스로 접근하되, “위치만 갱신”하는 루프에서는 x, y, z 벡터만 순차로 읽어 캐시 미스를 줄이고, 컴파일러가 SIMD로 벡터화하기도 쉽습니다.
AoS 예제 (캐시 비효율)
// AoS: 캐시에 불필요한 필드까지 로드
struct Entity {
float x, y, z; // 위치 12바이트
float vx, vy, vz; // 속도 12바이트
int id; // 식별자 4바이트
// 패딩 등으로 총 32바이트 이상
};
std::vector<Entity> entities;
// 위치만 갱신하는 루프: vx, vy, vz, id까지 캐시에 올라옴
void updatePositionsAoS(std::vector<Entity>& entities, float dt) {
for (auto& e : entities) {
e.x += e.vx * dt;
e.y += e.vy * dt;
e.z += e.vz * dt;
}
}
SoA 예제 (캐시 효율)
// SoA: 필요한 데이터만 연속으로
struct World {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
std::vector<int> id;
};
// 위치만 갱신하는 루프: x,y,z,vx,vy,vz만 순차 접근 → 캐시·SIMD 유리
void updatePositionsSoA(World& world, float dt) {
const size_t n = world.x.size();
for (size_t i = 0; i < n; ++i) {
world.x[i] += world.vx[i] * dt;
world.y[i] += world.vy[i] * dt;
world.z[i] += world.vz[i] * dt;
}
}
코드 상세 설명:
-
AoS:
entities[i]접근 시Entity전체가 로드됩니다. 64바이트 캐시 라인에 Entity 2개가 들어가고, 실제로 쓰는 건 위치·속도 6개 float뿐입니다. id와 패딩은 낭비입니다. -
SoA:
x[i],vx[i]등은 각각 연속 배열이므로, 64바이트에 float 16개가 들어갑니다. 위치 갱신 루프에서는 x, y, z, vx, vy, vz 6개 배열만 순차 접근하므로 캐시 재사용률이 높습니다. -
게임·시뮬레이션에서 “같은 연산을 많은 엔티티에 적용”할 때 SoA로 바꾸면 15번에서 다룬 캐시 친화적 코드가 극대화됩니다.
AoS vs SoA 선택 가이드
| 조건 | 권장 |
|---|---|
| 엔티티 수 100개 미만 | AoS (단순성 우선) |
| 엔티티 수 수천~수만 개 이상 | SoA 검토 |
| ”특정 필드만” 순회하는 루프가 많음 | SoA |
| 전체 엔티티를 한 번에 다 쓰는 경우 | AoS도 무방 |
| SIMD 자동 벡터화가 중요 | SoA |
| 랜덤 인덱스 접근이 주 | AoS 또는 인덱스 정렬 후 SoA |
| 여러 시스템이 다른 필드 조합 사용 | SoA + 컴포넌트별 배열 |
메모리 레이아웃 비교 (개념도)
AoS (Entity 32바이트, 64바이트 캐시 라인):
┌─────────────────────────────────────────────────────────────────┐
│ Entity0(x,y,z,vx,vy,vz,id...) │ Entity1(x,y,z,vx,vy,vz,id...) │ ← 한 라인에 2개
└─────────────────────────────────────────────────────────────────┘
위치만 읽을 때: vx,vy,vz,id 등 불필요 데이터까지 로드
SoA:
x[]: [x0][x1][x2][x3][x4]...[x15] ← 64바이트에 16개 float
y[]: [y0][y1][y2]...
z[]: [z0][z1][z2]...
위치만 읽을 때: x,y,z 배열만 순차 로드 → 캐시 효율 극대화
SoA 인덱스 일관성
// SoA에서 같은 인덱스 = 같은 엔티티
// world.x[42], world.y[42], world.z[42], world.id[42] → 엔티티 42번
// 삭제 시 주의: 인덱스가 밀리면 swap-with-last 패턴 사용
void removeEntity(World& world, size_t index) {
size_t last = world.x.size() - 1;
if (index != last) {
world.x[index] = world.x[last];
world.y[index] = world.y[last];
world.z[index] = world.z[last];
world.vx[index] = world.vx[last];
world.vy[index] = world.vy[last];
world.vz[index] = world.vz[last];
world.id[index] = world.id[last];
}
world.x.pop_back();
world.y.pop_back();
world.z.pop_back();
world.vx.pop_back();
world.vy.pop_back();
world.vz.pop_back();
world.id.pop_back();
}
3. 캐시 라인과 정렬
64바이트 단위로 생각하기
- 대부분의 x86/ARM CPU에서 캐시 라인은 64바이트입니다. 한 주소를 읽으면 그 주소가 속한 전체 64바이트가 캐시에 올라옵니다.
- alignas(64) 로 구조체나 배열을 캐시 라인 경계에 맞추면, 한 라인에 다른 변수가 섞여 들어오는 것을 줄일 수 있습니다. 핫 데이터를 라인 단위로 묶어 두면 효율이 좋습니다.
- 패딩: 구조체 크기를 64의 배수로 맞추거나, 자주 같이 쓰는 멤버를 한 라인 안에 모으는 식으로 레이아웃을 조정할 수 있습니다. (34번 캐시 정렬·패딩 참고.)
캐시 라인 로딩 다이어그램
flowchart LR
subgraph Memory["메인 메모리"]
M1[0-63]
M2[64-127]
M3[128-191]
end
subgraph Cache["L1 캐시"]
C1["캐시 라인 1\n64바이트"]
C2["캐시 라인 2\n64바이트"]
end
M1 -->|"한 주소 접근 시"| C1
M2 --> C2
C1 -->|"연속 접근 시 히트"| Fast["~1ns"]
Memory -->|"캐시 미스 시"| Slow["~100ns"]
alignas(64)로 정렬
alignas(64) 로 이 구조체가 64바이트 경계에 정렬되므로, value 하나가 한 캐시 라인을 차지하고 다른 스레드의 변수와 같은 라인을 쓰지 않아 거짓 공유를 피할 수 있습니다. atomic 카운터를 여러 스레드가 쓸 때 이런 정렬이 있으면 성능이 안정됩니다.
struct alignas(64) CacheFriendlyCounter {
std::atomic<int> value;
// 나머지 바이트는 패딩 → 다른 캐시 라인과 분리
};
플랫폼별 캐시 라인 크기
// 컴파일 타임에 플랫폼별 캐시 라인 크기
#if defined(__x86_64__) || defined(_M_X64)
constexpr size_t CACHE_LINE_SIZE = 64;
#elif defined(__aarch64__) || defined(_M_ARM64)
constexpr size_t CACHE_LINE_SIZE = 64;
#else
constexpr size_t CACHE_LINE_SIZE = 64; // 대부분 64
#endif
// C++17 std::hardware_destructive_interference_size (선택)
#include <new>
constexpr size_t CACHE_LINE = std::hardware_destructive_interference_size;
4. 거짓 공유와 패딩
서로 다른 변수가 같은 캐시 라인을 공유할 때
- 거짓 공유(false sharing): 서로 다른 스레드가 서로 다른 변수를 수정하는데, 두 변수가 같은 캐시 라인에 있으면 한 스레드가 쓸 때 다른 스레드의 캐시 라인이 무효화되어 성능이 떨어집니다.
- 해결: 스레드별 데이터(카운터, 로컬 버퍼 등)를 캐시 라인 크기만큼 떨어뜨리거나 alignas(64) + 패딩으로 한 라인에 하나씩만 들어가게 합니다.
False Sharing 다이어그램
flowchart TB
subgraph Bad["❌ 거짓 공유 발생"]
subgraph Line["캐시 라인 (64바이트)"]
A[스레드 A: counter_a]
B[스레드 B: counter_b]
end
A -.->|"수정 시 캐시 무효화"| B
end
subgraph Good["✅ 캐시 라인 분리"]
subgraph Line1["캐시 라인 1"]
A2[스레드 A: counter_a]
end
subgraph Line2["캐시 라인 2"]
B2[스레드 B: counter_b]
end
end
PerThreadData 가 64바이트로 맞춰져 있어서 thread_data[i] 와 thread_data[j] 가 서로 다른 캐시 라인에 들어갑니다. 스레드 0은 local_count 만, 스레드 1은 다음 64바이트의 local_count 만 수정하므로 같은 라인을 두 스레드가 동시에 써서 생기던 거짓 공유가 사라집니다.
struct alignas(64) PerThreadData {
int local_count;
char padding[64 - sizeof(int)]; // 한 캐시 라인에 이 구조체만
};
std::array<PerThreadData, 16> thread_data;
alignas 기반 패딩 (권장)
// alignas 사용이 더 안전 (플랫폼별 sizeof 차이 대응)
template <typename T>
struct CacheLinePadded {
alignas(64) T value;
};
CacheLinePadded<std::atomic<int>> thread_counters[16];
5. 완전한 캐시 최적화 예제
예제 1: AoS → SoA 변환 (파티클 시스템)
#include <vector>
#include <chrono>
#include <iostream>
// Before: AoS
struct ParticleAoS {
float x, y, z;
float vx, vy, vz;
float r, g, b, a;
float life;
};
using ParticlesAoS = std::vector<ParticleAoS>;
void updateAoS(ParticlesAoS& particles, float dt) {
for (auto& p : particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
p.life -= dt;
}
}
// After: SoA
struct ParticlesSoA {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
std::vector<float> r, g, b, a;
std::vector<float> life;
};
void updateSoA(ParticlesSoA& particles, float dt) {
const size_t n = particles.x.size();
for (size_t i = 0; i < n; ++i) {
particles.x[i] += particles.vx[i] * dt;
particles.y[i] += particles.vy[i] * dt;
particles.z[i] += particles.vz[i] * dt;
particles.life[i] -= dt;
}
}
int main() {
const size_t N = 100000;
ParticlesAoS aos(N);
ParticlesSoA soa;
soa.x.resize(N); soa.y.resize(N); soa.z.resize(N);
soa.vx.resize(N); soa.vy.resize(N); soa.vz.resize(N);
soa.r.resize(N); soa.g.resize(N); soa.b.resize(N); soa.a.resize(N);
soa.life.resize(N);
// 초기화
for (size_t i = 0; i < N; ++i) {
aos[i].x = aos[i].y = aos[i].z = 0.f;
aos[i].vx = aos[i].vy = aos[i].vz = 1.f;
soa.x[i] = soa.y[i] = soa.z[i] = 0.f;
soa.vx[i] = soa.vy[i] = soa.vz[i] = 1.f;
}
const int iterations = 1000;
auto startAoS = std::chrono::high_resolution_clock::now();
for (int i = 0; i < iterations; ++i) updateAoS(aos, 0.016f);
auto endAoS = std::chrono::high_resolution_clock::now();
auto startSoA = std::chrono::high_resolution_clock::now();
for (int i = 0; i < iterations; ++i) updateSoA(soa, 0.016f);
auto endSoA = std::chrono::high_resolution_clock::now();
auto msAoS = std::chrono::duration_cast<std::chrono::milliseconds>(endAoS - startAoS).count();
auto msSoA = std::chrono::duration_cast<std::chrono::milliseconds>(endSoA - startSoA).count();
std::cout << "AoS: " << msAoS << "ms, SoA: " << msSoA << "ms\n";
return 0;
}
예제 2: 거짓 공유 제거 (병렬 카운터)
#include <atomic>
#include <thread>
#include <vector>
#include <chrono>
#include <iostream>
constexpr size_t CACHE_LINE = 64;
// ❌ 나쁜 예: 같은 캐시 라인
void bench_bad() {
std::atomic<int> counters[4];
for (auto& c : counters) c.store(0);
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&, i]() {
for (int j = 0; j < 10000000; ++j) {
counters[i].fetch_add(1, std::memory_order_relaxed);
}
});
}
for (auto& t : threads) t.join();
}
// ✅ 좋은 예: 캐시 라인 분리
void bench_good() {
struct Padded { alignas(CACHE_LINE) std::atomic<int> value; };
std::vector<Padded> counters(4);
for (auto& c : counters) c.value.store(0);
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&, i]() {
for (int j = 0; j < 10000000; ++j) {
counters[i].value.fetch_add(1, std::memory_order_relaxed);
}
});
}
for (auto& t : threads) t.join();
}
int main() {
auto t1 = std::chrono::high_resolution_clock::now();
bench_bad();
auto t2 = std::chrono::high_resolution_clock::now();
bench_good();
auto t3 = std::chrono::high_resolution_clock::now();
auto ms_bad = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();
auto ms_good = std::chrono::duration_cast<std::chrono::milliseconds>(t3 - t2).count();
std::cout << "Bad (False Sharing): " << ms_bad << "ms\n";
std::cout << "Good (Padded): " << ms_good << "ms\n";
return 0;
}
예제 3: SoA + SIMD 친화적 루프 (개념)
// SoA에서는 컴파일러가 자동 벡터화하기 쉬움
void updatePositionsSIMDFriendly(float* x, float* y, float* z,
const float* vx, const float* vy, const float* vz,
size_t n, float dt) {
// -O3 -march=native 로 컴파일 시 AVX/SSE 자동 벡터화
for (size_t i = 0; i < n; ++i) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
z[i] += vz[i] * dt;
}
}
예제 4: 수동 프리페치 (고급)
#include <xmmintrin.h> // _mm_prefetch
// 다음 블록을 미리 캐시에 올려서 순차 접근 시 지연 숨김
void updateWithPrefetch(float* x, const float* vx, size_t n, float dt) {
constexpr int PREFETCH_DISTANCE = 8; // 8 * 4바이트 = 32바이트 앞
for (size_t i = 0; i < n; ++i) {
if (i + PREFETCH_DISTANCE < n) {
_mm_prefetch(reinterpret_cast<const char*>(&x[i + PREFETCH_DISTANCE]),
_MM_HINT_T0);
}
x[i] += vx[i] * dt;
}
}
주의: 프리페치는 프로파일링으로 확인한 후 적용. 너무 앞을 프리페치하면 캐시에서 유용한 데이터가 밀려나고, 잘못된 주소를 프리페치하면 오히려 느려질 수 있습니다.
예제 5: 캐시 친화적 행렬 전치 (행 우선 순회)
// 2차원 배열을 행 우선으로 순회하면 캐시 히트
void sumMatrixRowMajor(const std::vector<std::vector<float>>& matrix, float& sum) {
sum = 0;
for (size_t row = 0; row < matrix.size(); ++row) {
for (size_t col = 0; col < matrix[row].size(); ++col) {
sum += matrix[row][col]; // 연속 접근
}
}
}
// 열 우선 순회는 캐시 미스 다발
void sumMatrixColMajor(const std::vector<std::vector<float>>& matrix, float& sum) {
sum = 0;
for (size_t col = 0; col < matrix[0].size(); ++col) {
for (size_t row = 0; row < matrix.size(); ++row) {
sum += matrix[row][col]; // 캐시 미스 많음
}
}
}
6. 자주 발생하는 에러와 해결법
에러 1: SoA에서 인덱스 불일치
증상: 엔티티 삭제·추가 후 x[i]와 y[i]가 서로 다른 엔티티를 가리킴.
// ❌ 나쁜 예: 한 배열만 pop_back
void removeBad(World& world, size_t index) {
world.x.erase(world.x.begin() + index); // x만 삭제
// y, z, vx, vy, vz, id는 그대로 → 인덱스 어긋남!
}
해결: 모든 배열에서 동일한 인덱스를 처리하거나, swap-with-last 패턴 사용.
// ✅ 올바른 예: swap-with-last
void removeGood(World& world, size_t index) {
size_t last = world.x.size() - 1;
if (index != last) {
world.x[index] = std::move(world.x[last]);
world.y[index] = std::move(world.y[last]);
// ... 모든 필드
}
world.x.pop_back();
world.y.pop_back();
// ... 모든 필드
}
에러 2: 과도한 패딩 (메모리 폭증)
증상: 스레드 100개 × 64바이트 패딩 = 6.4KB만 카운터 하나에 사용.
// ❌ 나쁜 예: 읽기 전용 변수까지 패딩
struct OverPadded {
alignas(64) int read_only_config; // 수정 안 하는데 64바이트
alignas(64) std::atomic<int> counter;
};
해결: 핫 경로(자주 수정되는 변수)에만 패딩 적용. 읽기 전용은 같은 캐시 라인에 있어도 거짓 공유가 없음.
// ✅ 올바른 예: 수정되는 변수만 분리
struct Reasonable {
int read_only_config; // 같은 라인에 있어도 OK
alignas(64) std::atomic<int> counter; // 수정 빈도 높음 → 분리
};
에러 3: AoS와 SoA 혼용 시 캐시 오염
증상: SoA로 바꿨는데 성능 개선이 미미함.
// ❌ 나쁜 예: SoA인데 랜덤 인덱스로 접근
for (size_t i = 0; i < indices.size(); ++i) {
size_t idx = indices[i]; // 랜덤 인덱스
world.x[idx] += world.vx[idx] * dt; // 캐시 미스 다발
}
해결: SoA의 장점은 순차 접근에 있음. 인덱스 배열을 따라가는 대신, 활성 엔티티를 연속 배열로 재배치하거나, 배치(batch) 단위로 처리.
에러 4: alignas와 #pragma pack 혼용
// ❌ 나쁜 예: pack이 alignas를 무시할 수 있음
#pragma pack(push, 1)
struct Mixed {
alignas(64) int x;
};
#pragma pack(pop)
해결: #pragma pack과 alignas를 같은 구조체에서 쓰지 않음.
에러 5: SoA 초기화 누락
증상: 일부 배열만 resize하고 나머지는 비어 있음 → 인덱스 접근 시 크래시.
// ❌ 나쁜 예
World world;
world.x.resize(1000);
// y, z, vx, vy, vz, id는 resize 안 함 → world.y[i] 크래시
해결: SoA 헬퍼에서 한 번에 resize하거나, 생성자에서 크기 일관성 보장.
// ✅ 올바른 예
struct World {
std::vector<float> x, y, z, vx, vy, vz;
std::vector<int> id;
void resize(size_t n) {
x.resize(n); y.resize(n); z.resize(n);
vx.resize(n); vy.resize(n); vz.resize(n);
id.resize(n);
}
};
에러 6: SoA를 과도하게 적용 (작은 데이터)
증상: 엔티티가 100개 미만인데 SoA로 바꿨더니 코드만 복잡해지고 성능 차이 없음.
// ❌ 과한 최적화: 50개 엔티티에 SoA
struct TinyWorld {
std::vector<float> x, y, z, vx, vy, vz;
std::vector<int> id;
// 50개 × 7배열 = 관리 오버헤드
};
해결: 데이터 양이 적으면(수백 개 이하) AoS가 더 단순하고, 캐시에 전부 들어가므로 SoA 이점이 작습니다. 수천~수만 개 이상에서 SoA를 검토합니다.
에러 7: 랜덤 접근이 주인데 SoA 적용
증상: 인덱스가 indices[i]처럼 랜덤할 때 SoA로 바꿔도 캐시 개선이 거의 없음.
// 랜덤 인덱스 순회: 캐시 예측 불가
for (size_t i = 0; i < indices.size(); ++i) {
size_t idx = indices[i]; // 3, 1002, 7, 99999, ...
world.x[idx] += world.vx[idx] * dt; // 매번 다른 캐시 라인
}
해결: 인덱스를 정렬해 순차 접근으로 바꾸거나, 활성 엔티티만 연속 배열로 재배치하는 패킹(packing) 기법을 사용합니다.
에러 8: 구조체 크기와 캐시 라인 관계 무시
증상: Entity가 65바이트인데, 두 개가 한 캐시 라인에 걸쳐 들어가 캐시 라인 분할(split) 발생.
// Entity 65바이트: 64바이트 경계를 넘어감
struct Entity {
char data[65];
};
// entities[0]과 entities[1]이 같은 캐시 라인을 공유
해결: 자주 같이 접근하는 데이터는 64바이트 이하로 묶거나, alignas(64)로 경계를 맞춥니다.
7. 성능 벤치마크
AoS vs SoA (10만 파티클, 위치 갱신)
| 구성 | 1000회 반복 시간 (대략) | 상대 속도 |
|---|---|---|
| AoS (Entity 36바이트) | 100% | 1.0x |
| SoA (연속 배열) | 50~70% | 1.4~2.0x |
실제 수치는 CPU, 메모리 대역폭, 컴파일러 최적화에 따라 다릅니다.
거짓 공유 패딩 전/후 (4스레드, 1천만 회 카운터 증가)
| 구성 | 시간 (대략) | 상대 속도 |
|---|---|---|
| 패딩 없음 (False Sharing) | 100% | 1.0x |
| alignas(64) 패딩 적용 | 20~35% | 3~5x |
perf로 캐시 미스 확인
# AoS vs SoA 비교
perf stat -e cache-misses,cache-references,L1-dcache-load-misses ./bench_aos
perf stat -e cache-misses,cache-references,L1-dcache-load-misses ./bench_soa
# cache-misses / cache-references 비율이 낮을수록 캐시 효율 좋음
벤치마크 시 주의사항
- -O2 또는 -O3로 컴파일
- -march=native로 SIMD 활용
- 워밍업 루프로 캐시·분기 예측 안정화
- 여러 번 실행해 평균·표준편차 확인
캐시 계층별 크기와 의미
L1 데이터 캐시: 32~64KB (코어당)
→ 수 KB 단위 데이터를 반복 접근할 때 L1에 유지되면 최고 속도
L2 캐시: 256KB~1MB (코어당)
→ 수만~수십만 float 배열이 L2에 들어가면 SoA 순회 시 유리
L3 캐시: 8~32MB (코어 공유)
→ 수백만 엔티티는 L3에도 안 들어가므로 메모리 대역폭이 병목
→ 이 경우 배치 크기를 L2/L3에 맞게 나누는 블로킹이 효과적
컴파일 옵션 예시
# GCC/Clang: 최대 최적화 + 네이티브 SIMD
g++ -std=c++17 -O3 -march=native -o bench bench.cpp
# 디버그 빌드에서는 캐시 최적화 효과가 상대적으로 작을 수 있음
# Release 빌드로 벤치마크 수행
8. 프로덕션 패턴
패턴 1: 게임 엔진 엔티티 컴포넌트 (SoA)
// 컴포넌트별 SoA 저장
struct TransformComponent {
std::vector<float> x, y, z;
std::vector<float> qx, qy, qz, qw; // 쿼터니언
};
struct VelocityComponent {
std::vector<float> vx, vy, vz;
};
// 시스템: 같은 컴포넌트를 가진 엔티티만 순회
void physicsSystem(TransformComponent& transform,
const VelocityComponent& velocity, float dt) {
for (size_t i = 0; i < transform.x.size(); ++i) {
transform.x[i] += velocity.vx[i] * dt;
transform.y[i] += velocity.vy[i] * dt;
transform.z[i] += velocity.vz[i] * dt;
}
}
패턴 2: 시뮬레이션 배치 처리
// 대량 데이터를 배치로 나눠 캐시에 맞게 처리
constexpr size_t BATCH_SIZE = 4096; // L1 캐시에 맞는 크기
void processInBatches(ParticlesSoA& particles, float dt) {
const size_t n = particles.x.size();
for (size_t start = 0; start < n; start += BATCH_SIZE) {
size_t end = std::min(start + BATCH_SIZE, n);
for (size_t i = start; i < end; ++i) {
particles.x[i] += particles.vx[i] * dt;
particles.y[i] += particles.vy[i] * dt;
particles.z[i] += particles.vz[i] * dt;
}
}
}
패턴 3: 스레드 풀 워커 (캐시 라인 분리)
struct alignas(64) WorkerState {
std::atomic<bool> has_work{false};
std::atomic<int> tasks_done{0};
// 워커별 로컬 큐 등
};
std::vector<WorkerState> workers;
void initWorkers(size_t num_threads) {
workers.resize(num_threads);
// 각 WorkerState가 별도 캐시 라인에 배치됨
}
패턴 4: 하이브리드 AoS/SoA
// 작은 덩어리는 AoS, 큰 덩어리는 SoA
// 예: 8개 float를 한 블록으로 (캐시 라인 1개)
struct ParticleBlock {
float x[8], y[8], z[8];
float vx[8], vy[8], vz[8];
};
std::vector<ParticleBlock> blocks; // 블록 단위 SoA
패턴 5: 조건부 SoA (컴파일 타임)
template <bool UseSoA>
struct EntityStorage;
template <>
struct EntityStorage<true> {
std::vector<float> x, y, z;
// SoA
};
template <>
struct EntityStorage<false> {
std::vector<Entity> entities;
// AoS
};
using Storage = EntityStorage<true>; // 프로덕션: SoA
패턴 6: ECS(Entity Component System) 스타일 SoA
// 컴포넌트 = SoA 배열, 시스템 = 컴포넌트별 순회
struct PositionComponent {
std::vector<float> x, y, z;
};
struct HealthComponent {
std::vector<int> current, max;
};
void damageSystem(HealthComponent& health, const std::vector<int>& damage, float dt) {
for (size_t i = 0; i < health.current.size(); ++i) {
health.current[i] -= static_cast<int>(damage[i] * dt);
if (health.current[i] < 0) health.current[i] = 0;
}
}
패턴 7: 캐시 라인 크기 블록 (Blocked/ Tiled 접근)
// 큰 행렬 연산 시 블록 단위로 처리해 캐시 재사용
constexpr size_t BLOCK = 32; // L1 캐시에 맞는 블록 크기
void matmulBlocked(const float* A, const float* B, float* C,
size_t N, size_t M, size_t K) {
for (size_t ii = 0; ii < N; ii += BLOCK) {
for (size_t jj = 0; jj < M; jj += BLOCK) {
for (size_t kk = 0; kk < K; kk += BLOCK) {
// 블록 내부: 작은 영역이라 캐시에 유지
for (size_t i = ii; i < std::min(ii + BLOCK, N); ++i) {
for (size_t j = jj; j < std::min(jj + BLOCK, M); ++j) {
float sum = C[i * M + j];
for (size_t k = kk; k < std::min(kk + BLOCK, K); ++k) {
sum += A[i * K + k] * B[k * M + j];
}
C[i * M + j] = sum;
}
}
}
}
}
}
패턴 8: SoA ↔ AoS 변환 유틸리티
// 외부 API(AoS)와 내부 처리(SoA) 간 변환
void convertAoS_to_SoA(const std::vector<Entity>& aos, World& soa) {
soa.resize(aos.size());
for (size_t i = 0; i < aos.size(); ++i) {
soa.x[i] = aos[i].x;
soa.y[i] = aos[i].y;
soa.z[i] = aos[i].z;
soa.vx[i] = aos[i].vx;
soa.vy[i] = aos[i].vy;
soa.vz[i] = aos[i].vz;
soa.id[i] = aos[i].id;
}
}
void convertSoA_to_AoS(const World& soa, std::vector<Entity>& aos) {
aos.resize(soa.x.size());
for (size_t i = 0; i < soa.x.size(); ++i) {
aos[i] = {soa.x[i], soa.y[i], soa.z[i],
soa.vx[i], soa.vy[i], soa.vz[i], soa.id[i]};
}
}
패턴 9: 프로파일링 기반 적용 순서
1. perf stat -e cache-misses,cache-references ./app 으로 캐시 미스 비율 확인
2. cache-misses / cache-references > 10% 이면 캐시 최적화 후보
3. 프로파일러(perf record, VTune)로 핫 루프 식별
4. 해당 루프가 "필드 일부만 순회"하면 SoA 검토
5. 멀티스레드에서 스레드 수 증가 시 성능 저하면 거짓 공유 의심
6. alignas(64) 패딩 적용 후 재측정
9. 정리
| 주제 | 요약 |
|---|---|
| DoD | AoS 대신 SoA로 필요한 데이터만 연속 접근 — 캐시·SIMD 유리 |
| 캐시 라인 | 64바이트 단위, alignas(64)로 정렬·패딩 |
| 거짓 공유 | 스레드별 데이터를 서로 다른 캐시 라인에 배치 |
| 문제 시나리오 | 엔티티 대량 업데이트, 멀티스레드 성능 저하, SIMD 미적용 |
| 일반적 에러 | SoA 인덱스 불일치, 과도한 패딩, AoS/SoA 혼용 오용 |
| 프로덕션 | 컴포넌트 SoA, 배치 처리, 워커 패딩, 하이브리드 |
39번의 첫 편으로, 데이터 배치와 캐시 라인을 의식하면 15번의 성능 최적화를 하드웨어 레벨까지 끌어올릴 수 있습니다.
구현 체크리스트
캐시·데이터 지향 설계 적용 시 확인할 항목:
- “위치만 갱신” 등 필드별 순회가 많은지 → SoA 검토
- 스레드 수를 늘렸을 때 성능이 오히려 떨어지는지 → 거짓 공유 의심
-
perf stat -e cache-misses로 캐시 미스 비율 측정 - SoA 사용 시 모든 배열 크기 일관성 유지
- 패딩은 핫 경로(자주 수정되는 변수)에만 적용
-
#pragma pack과alignas를 같은 구조체에서 혼용하지 않기
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 캐시 최적화 | 메모리 접근 패턴 바꿔서 성능 10배 향상시키기
- C++ 캐시 히트(Cache Hit)를 높이는 메모리 정렬과 패딩 | False Sharing 해결
- C++ ECS 패턴 완벽 가이드 | Entity·Component·System·쿼리·컴포넌트 스토리지 실전
이 글에서 다루는 키워드 (관련 검색어)
데이터 지향 설계, 캐시, AoS SoA, 거짓 공유, 캐시 라인 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 15번 성능 최적화를 하드웨어 레벨로 확장할 때 사용합니다. 게임 엔진, 물리 시뮬레이션, 대량 로그 처리, 멀티스레드 워커 풀 등에서 데이터 지향 설계와 캐시 라인 정렬·패딩을 적용하면 성능이 크게 개선됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다. 캐시 친화적 코드, 캐시 정렬·패딩을 먼저 읽으면 이해에 도움이 됩니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. “Data-Oriented Design” (Richard Fabian), Intel VTune, perf stat으로 캐시 프로파일링하는 방법도 학습하면 좋습니다.
한 줄 요약: 데이터 지향 설계·캐시 라인을 고려하면 메모리 접근이 효율적입니다. 다음으로 커스텀 알로케이터·pmr(#39-2)를 읽어보면 좋습니다.
다음 글: [고성능 C++ #39-2] 현대적 메모리 관리: 커스텀 알로케이터(Memory Pool) 제작과 std::pmr 활용
이전 글: [C++ 아키텍처 #38-3] 인터페이스 설계와 PIMPL: 컴파일 의존성을 끊고 바이너리 호환성(ABI) 유지하기
관련 글
- C++ std::chrono 완벽 가이드 | duration·time_point·클럭·시간 측정 실전 활용
- C++ 현대적 메모리 관리: 커스텀 알로케이터 제작과 std::pmr 가이드
- C++ std::pmr 완벽 가이드 | Polymorphic Memory Resources로 메모리 풀
- C++ SIMD와 병렬화: std::execution과 인트린직 가이드
- C++ 캐시 최적화 | 메모리 접근 패턴 바꿔서 성능 10배 향상시키기