C++ 캐시 효율적인 코드: 데이터 지향 설계 가이드
들어가며: 캐시가 성능을 좌우한다
프로파일링과 캐시 친화적 코드를 다뤘다면, 39번은 하드웨어 최적화까지 파고듭니다. 현대 CPU에서는 메모리 접근 지연이 연산 비용보다 훨씬 크기 때문에, 캐시 라인 단위로 데이터를 배치하고 접근하는 데이터 지향 설계(Data-Oriented Design, DoD)(데이터 배치와 접근 패턴을 먼저 설계해 캐시·SIMD에 맞추는 방식)가 실전에서 큰 차이를 냅니다. AoS(Array of Structures—구조체의 배열. 한 요소에 여러 필드가 묶임) 대신 SoA(Structure of Arrays—필드별로 배열을 두고 같은 인덱스로 접근)로 바꾸면, 순회 시 캐시 미스가 줄고 SIMD와도 잘 맞습니다. 캐시 라인 정렬과 패딩으로 거짓 공유(false sharing—서로 다른 스레드가 같은 캐시 라인을 수정해 캐시가 무효화되는 현상)를 피하면 멀티스레드 성능도 안정됩니다.
캐시 최적화가 필요한 상황
캐시가 병목이 되는 전형적인 경우는 몇 가지로 정리됩니다. 첫째, 수만 개 이상의 엔티티에 같은 연산을 적용하는데 루프가 구조체 필드 중 일부만 쓰는 경우입니다. 연산 자체는 O(n)이고 단순한데도 느리다면, 쓰지 않는 필드까지 캐시 라인에 실려 메모리 대역폭을 낭비하고 있을 가능성이 큽니다. 둘째, 스레드를 늘렸는데 처리량이 거의 늘지 않는 경우입니다. 스레드별 카운터처럼 각자 다른 변수를 쓰는데도 그 변수들이 같은 캐시 라인에 있으면, 코어끼리 라인 소유권을 계속 주고받는 거짓 공유가 생깁니다. 셋째, 컴파일러가 루프를 자동 벡터화하지 못하는 경우입니다. AoS에서는 entities[i].x와 entities[i+1].x가 구조체 크기만큼 떨어져 있어, 연속된 float 여러 개를 한 번에 읽는 SIMD 로드에 맞지 않습니다.
같은 원리가 데이터베이스에서도 나타납니다. 행 단위 저장은 AoS와 같아서, 100만 행에서 ‘나이’ 열 하나만 집계해도 이름·주소까지 함께 읽게 됩니다. 컬럼형 스토리지가 분석 쿼리에 빠른 이유가 바로 SoA처럼 열 하나를 연속으로 저장하기 때문입니다.
데이터 지향 설계 (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바이트
// 모두 4바이트 정렬이라 패딩 없이 sizeof(Entity) == 28 (일반적인 ABI 기준)
};
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에서 28바이트 Entity는 64바이트 캐시 라인에 2개 남짓 들어가고, 일부 엔티티는 두 라인에 걸칩니다. 위치 갱신 루프는 28바이트 중 id를 뺀 24바이트를 쓰므로 이 경우 낭비는 크지 않습니다. 반면 SoA의 x, vx 같은 배열은 64바이트 라인 하나에 float 16개가 빈틈없이 들어가고, 루프는 6개 배열을 각각 앞에서부터 순차로 읽으므로 하드웨어 프리페처가 잘 동작하고 컴파일러도 쉽게 벡터화합니다. 루프가 쓰는 필드 비율이 낮을수록(예: x만 읽는 경우) SoA의 이득은 커집니다. 더 기초적인 내용은 캐시 친화적 코드를 참고하세요.
AoS vs SoA 선택 가이드
| 조건 | 권장 |
|---|---|
| 엔티티 수 100개 미만 | AoS (단순성 우선) |
| 엔티티 수 수천~수만 개 이상 | SoA 검토 |
| ”특정 필드만” 순회하는 루프가 많음 | SoA |
| 전체 엔티티를 한 번에 다 쓰는 경우 | AoS도 무방 |
| SIMD 자동 벡터화가 중요 | SoA |
| 랜덤 인덱스 접근이 주 | AoS 또는 인덱스 정렬 후 SoA |
| 여러 시스템이 다른 필드 조합 사용 | SoA + 컴포넌트별 배열 |
메모리 레이아웃 비교 (개념도)
AoS (Entity 28바이트, 64바이트 캐시 라인):
┌──────────────────────────────────────────────────────────────────┐
│ Entity0 (28B) │ Entity1 (28B) │ Entity2의 앞 8B ... │ ← 라인 경계에 걸치는 엔티티 발생
└──────────────────────────────────────────────────────────────────┘
x만 읽을 때: 28바이트 중 4바이트만 사용, 나머지는 함께 로드됨
SoA:
x[]: [x0][x1][x2][x3][x4]...[x15] ← 64바이트에 16개 float
y[]: [y0][y1][y2]...
z[]: [z0][z1][z2]...
x만 읽을 때: x 배열만 순차 로드, 라인의 모든 바이트를 사용
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();
}
캐시 라인과 정렬
64바이트 단위로 생각하기
대부분의 x86-64 CPU와 많은 ARM CPU에서 캐시 라인은 64바이트이고, 한 바이트를 읽어도 그 바이트가 속한 라인 전체가 캐시에 올라옵니다(Apple M 시리즈처럼 128바이트 라인을 쓰는 CPU도 있습니다). alignas(64)로 구조체나 배열을 라인 경계에 맞추면 한 객체가 두 라인에 걸치지 않고, 다른 변수와 한 라인을 나눠 쓰지도 않게 할 수 있습니다. 자주 함께 쓰는 멤버를 한 라인 안에 모으는 것도 같은 원리입니다. 자세한 내용은 캐시 정렬·패딩에서 다룹니다.
캐시 라인 로딩 다이어그램
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 -->|L1 히트| Fast[수 사이클]
Memory -->|DRAM까지 가는 미스| Slow[수십~100ns 안팎]
alignas(64)로 정렬
alignas(64) 로 이 구조체가 64바이트 경계에 정렬되므로, value 하나가 한 캐시 라인을 차지하고 다른 스레드의 변수와 같은 라인을 쓰지 않아 거짓 공유를 피할 수 있습니다. atomic 카운터를 여러 스레드가 쓸 때 이런 정렬이 있으면 성능이 안정됩니다.
struct alignas(64) CacheFriendlyCounter {
std::atomic<int> value;
// 나머지 바이트는 패딩 → 다른 캐시 라인과 분리
};
플랫폼별 캐시 라인 크기
#include <cstddef>
#include <new>
#if defined(__cpp_lib_hardware_interference_size)
constexpr std::size_t kCacheLine = std::hardware_destructive_interference_size;
#elif defined(__APPLE__) && defined(__aarch64__)
constexpr std::size_t kCacheLine = 128; // Apple Silicon
#else
constexpr std::size_t kCacheLine = 64;
#endif
C++17의 std::hardware_destructive_interference_size는 거짓 공유를 피하기 위한 최소 간격을 컴파일 타임 상수로 알려 주지만, 표준 라이브러리마다 지원 시점이 달라(GCC는 12부터) 기능 테스트 매크로로 확인하는 것이 안전합니다. 또 이 값은 컴파일 대상 CPU 옵션에 따라 달라질 수 있어, GCC는 헤더나 ABI 경계에서 쓰면 -Winterference-size 경고를 냅니다. 실행 중인 CPU의 실제 라인 크기가 필요하면 Linux에서는 sysconf(_SC_LEVEL1_DCACHE_LINESIZE) 등으로 런타임에 확인합니다.
거짓 공유와 패딩
서로 다른 변수가 같은 캐시 라인을 공유할 때
캐시 일관성 프로토콜은 바이트가 아니라 라인 단위로 소유권을 관리합니다. 그래서 서로 다른 스레드가 서로 다른 변수를 수정하더라도 두 변수가 같은 라인에 있으면, 한 코어가 쓸 때마다 다른 코어의 사본이 무효화되고 라인이 코어 사이를 계속 오갑니다. 코드상으로는 공유가 없는데 하드웨어 수준에서 공유가 생기므로 거짓 공유라고 부릅니다. 해결은 스레드별로 자주 쓰는 데이터(카운터, 로컬 버퍼 등)를 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];
AoS→SoA 파티클, 병렬 카운터 패딩, 행렬 전치 예제
예제 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: 수동 프리페치 (간접 접근)
배열을 앞에서부터 순차로 읽는 루프는 CPU의 하드웨어 프리페처가 이미 다음 라인을 미리 가져오므로, 수동 프리페치를 넣어도 대개 이득이 없습니다. 수동 프리페치가 의미 있는 경우는 하드웨어가 다음 주소를 예측할 수 없는 간접 접근입니다.
#include <xmmintrin.h> // _mm_prefetch (x86)
// idx가 가리키는 위치는 예측 불가 → 몇 번 뒤에 접근할 원소를 미리 요청
void updateIndirect(float* x, const float* vx, const unsigned* idx, size_t n, float dt) {
constexpr size_t D = 16; // 몇 번 앞을 미리 가져올지: 메모리 지연과 루프 비용으로 정함
for (size_t i = 0; i < n; ++i) {
if (i + D < n) {
_mm_prefetch(reinterpret_cast<const char*>(&x[idx[i + D]]), _MM_HINT_T0);
}
x[idx[i]] += vx[idx[i]] * dt;
}
}
프리페치 거리가 너무 짧으면 데이터가 도착하기 전에 접근해 효과가 없고, 너무 길면 쓰기 전에 다른 데이터에 밀려나 버립니다. 적정 거리는 CPU와 루프마다 다르므로 반드시 측정하며 조정해야 하고, 효과가 없으면 지우는 것이 맞습니다. GCC·Clang에서는 이식성 있는 __builtin_prefetch도 쓸 수 있습니다.
예제 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]; // 캐시 미스 많음
}
}
}
vector<vector<float>>는 행마다 따로 할당되므로 행끼리도 메모리상 떨어져 있습니다. 큰 행렬은 vector<float> 하나에 row * cols + col로 접근하는 1차원 배열로 두는 편이 캐시와 프리페처에 더 유리합니다.
SoA 인덱스 불일치, 과도한 패딩, 랜덤 접근 같은 실수
에러 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가 O(1)입니다.
// 올바른 예: 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: 과도한 패딩 (메모리 폭증)
거짓 공유가 걱정된다고 모든 멤버에 alignas(64)를 붙이면 구조체가 불필요하게 커지고, 같은 라인에 함께 있으면 좋았을 데이터까지 흩어져 오히려 캐시 효율이 떨어집니다.
// 잘못된 예: 읽기 전용 변수까지 패딩
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: alignas와 #pragma pack 혼용
// 잘못된 예: pack이 alignas를 무시할 수 있음
#pragma pack(push, 1)
struct Mixed {
alignas(64) int x;
};
#pragma pack(pop)
#pragma pack은 멤버의 최대 정렬을 낮추고 alignas는 높이라는 요구라서, 둘을 섞었을 때 어느 쪽이 이기는지는 컴파일러마다 다릅니다. 캐시 라인 정렬이 목적인 타입에는 pack을 쓰지 말고, static_assert(alignof(T) == 64)로 의도대로 정렬되었는지 확인해 두는 편이 안전합니다.
에러 4: SoA 초기화 누락
일부 배열만 resize하고 나머지는 비어 있으면, 인덱스로 접근하는 순간 범위 밖 접근이 됩니다.
// 잘못된 예
World world;
world.x.resize(1000);
// y, z, vx, vy, vz, id는 resize 안 함 → world.y[i] 크래시
크기를 바꾸는 연산은 SoA 타입의 멤버 함수 하나로 모아 모든 배열에 함께 적용합니다.
// 올바른 예
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);
}
};
에러 5: 작은 데이터에 SoA 적용
엔티티가 수십 개뿐인데 SoA로 바꾸면 코드만 복잡해지고 성능 차이는 거의 없습니다.
// 과한 최적화: 50개 엔티티에 SoA
struct TinyWorld {
std::vector<float> x, y, z, vx, vy, vz;
std::vector<int> id;
// 50개 × 7배열 = 관리 오버헤드
};
데이터 양이 적으면(수백 개 이하) AoS가 더 단순하며, 캐시에 전부 들어가므로 SoA 이점이 작습니다. 수천~수만 개 이상에서 SoA를 검토합니다.
에러 6: 랜덤 접근이 주인데 SoA 적용
인덱스가 indices[i]처럼 무작위라면 SoA로 바꿔도 캐시 개선이 거의 없습니다. 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) 기법을 사용합니다.
에러 7: 구조체 크기와 캐시 라인 관계 무시
구조체가 65바이트처럼 라인 크기를 살짝 넘으면, 거의 모든 원소가 두 라인에 걸쳐 원소 하나를 읽을 때 라인 두 개가 필요해집니다.
// Entity 65바이트: 64바이트 경계를 넘어감
struct Entity {
char data[65];
};
// 원소 대부분이 64바이트 경계에 걸침
자주 같이 접근하는 데이터를 64바이트 이하로 줄이거나 핫/콜드 필드로 나눕니다. alignas(64)로 맞추면 경계 문제는 사라지지만 원소 크기가 128바이트로 커지므로 메모리 사용량과 맞바꾸는 셈입니다.
AoS vs SoA, 패딩 전후 측정과 perf 확인
AoS vs SoA (10만 파티클, 위치 갱신)
| 구성 | 캐시에 올라오는 데이터 중 실제 사용 비율 | 기대 효과 |
|---|---|---|
AoS (Entity 28바이트) | 위치 갱신에 쓰는 24바이트 / 28바이트 | 기준 |
| SoA (연속 배열) | 필요한 배열만 읽으므로 거의 전부 | 대역폭 낭비 감소 + SIMD 벡터화가 쉬움 |
SoA의 이득은 루프가 구조체 필드 중 일부만 쓸 때 커집니다. 위 Entity로 위치를 갱신하면 7개 필드 중 6개를 쓰므로 AoS도 캐시 라인의 대부분을 활용하고, 차이는 주로 컴파일러가 SoA 루프를 더 쉽게 벡터화하는 데서 나옵니다. 반대로 x만 읽는 루프라면 AoS는 28바이트 중 4바이트만 쓰는 셈이라 차이가 훨씬 커집니다. 10만 개 × 28바이트는 약 2.8MB로 L2보다 크고 L3에는 들어갈 수 있는 크기라, 데이터가 캐시에 다 들어가는지 여부에 따라서도 결과가 달라집니다.
거짓 공유 패딩 전/후 (4스레드, 1천만 회 카운터 증가)
| 구성 | 기대되는 경향 |
|---|---|
| 패딩 없음 (False Sharing) | 네 카운터가 한 캐시 라인에 있어, 쓰기마다 다른 코어의 사본이 무효화되어 스레드를 늘려도 빨라지지 않음 |
| alignas(64) 패딩 적용 | 각 카운터가 자기 캐시 라인을 가져 스레드 수에 가까운 확장성 |
실제 배수는 CPU의 코어 간 구조, 메모리 대역폭, 컴파일러 최적화에 따라 크게 다르므로 아래 perf stat으로 직접 비교하세요.
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)로 측정하고, SIMD 효과를 보려면 -march=native를 함께 씁니다. 첫 실행은 페이지 폴트와 캐시 워밍업 비용이 섞이므로 워밍업 반복을 버리고, 여러 번 실행해 평균과 편차를 함께 봅니다.
캐시 계층별 크기와 의미
(대략적인 범위이며 CPU 세대마다 다름)
L1 데이터 캐시: 코어당 수십 KB (32~48KB가 흔하고, Apple M 시리즈 성능 코어는 128KB)
→ 수 KB 단위 데이터를 반복 접근할 때 L1에 유지되면 최고 속도
L2 캐시: 코어당 수백 KB~수 MB
→ 수만~수십만 float 배열이 L2에 들어가면 SoA 순회 시 유리
L3 캐시: 여러 코어가 공유, 수 MB~수십 MB
→ 수백만 엔티티는 L3에도 안 들어가므로 메모리 대역폭이 병목
→ 이 경우 배치 크기를 L2/L3에 맞게 나누는 블로킹이 효과적
컴파일 옵션 예시
# GCC/Clang: 최대 최적화 + 네이티브 SIMD
g++ -std=c++17 -O3 -march=native -o bench bench.cpp
# 디버그 빌드에서는 캐시 최적화 효과가 상대적으로 작을 수 있음
# Release 빌드로 벤치마크 수행
ECS 스타일 SoA, 하이브리드 레이아웃, 타일 접근
패턴 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: 여러 패스를 배치 단위로 묶기
같은 데이터를 여러 번 훑는 처리(예: 위치 갱신 후 수명 감소, 그다음 경계 검사)는 전체 배열을 패스마다 따로 돌면 데이터가 캐시보다 클 때 매번 메모리에서 다시 읽게 됩니다. 배치를 캐시에 들어가는 크기로 나누고 한 배치에 모든 패스를 적용하면, 두 번째 패스부터는 캐시에서 읽습니다. 패스가 하나뿐인 순차 루프는 나눠도 이득이 없습니다.
#include <algorithm>
// 배치 하나: 2048개 × 사용하는 float 배열 7개 × 4바이트 ≈ 56KB (L2 크기를 기준으로 조정)
constexpr size_t BATCH = 2048;
void stepInBatches(ParticlesSoA& p, float dt) {
const size_t n = p.x.size();
for (size_t start = 0; start < n; start += BATCH) {
const size_t end = std::min(start + BATCH, n);
for (size_t i = start; i < end; ++i) { // 패스 1: 이동
p.x[i] += p.vx[i] * dt;
p.y[i] += p.vy[i] * dt;
p.z[i] += p.vz[i] * dt;
}
for (size_t i = start; i < end; ++i) { // 패스 2: 같은 배치의 데이터를 재사용
p.life[i] -= dt;
if (p.y[i] < 0.0f) { p.y[i] = 0.0f; p.vy[i] = -p.vy[i]; }
}
}
}
패턴 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
// AoSoA: 8개 엔티티씩 묶은 블록 안에서는 SoA, 블록들은 배열(AoS)
// 블록 하나 = 6필드 × 8 float × 4바이트 = 192바이트 (캐시 라인 3개)
struct ParticleBlock {
float x[8], y[8], z[8];
float vx[8], vy[8], vz[8];
};
std::vector<ParticleBlock> blocks;
이 형태를 AoSoA(Array of Structures of Arrays)라고 부릅니다. 블록 안의 x[8]은 AVX 레지스터 하나(float 8개)에 그대로 들어가 SIMD에 유리하면서도, 한 엔티티의 필드들이 같은 블록 안에 가까이 있어 엔티티 단위 접근도 SoA보다 덜 흩어집니다.
패턴 5: 블록(타일) 단위 행렬 곱
// 큰 행렬 연산 시 블록 단위로 처리해 캐시 재사용
constexpr size_t BLOCK = 32; // 32×32 float 블록 3개 ≈ 12KB, L1에 들어가는 크기
// C는 호출 전에 0으로 초기화되어 있어야 함
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;
}
}
}
}
}
}
패턴 6: 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]};
}
}
적용 순서
먼저 perf record나 VTune 같은 프로파일러로 시간이 실제로 쓰이는 핫 루프를 찾고, 그 루프에 대해 perf stat으로 캐시 미스를 봅니다. 미스 비율에 “이 이상이면 문제”라는 보편적인 기준은 없으므로, 레이아웃을 바꾸기 전후의 실행 시간과 미스 수를 비교하는 것이 판단 기준입니다. 핫 루프가 구조체 필드 일부만 순회한다면 SoA를, 스레드 수를 늘려도 빨라지지 않는다면 perf c2c로 거짓 공유를 확인한 뒤 해당 데이터만 캐시 라인 단위로 분리합니다.
같이 보면 좋은 글
- C++ 캐시 친화 코드: AoS vs SoA, 데이터 지향 설계, False Sharing
- C++ 캐시 히트(Cache Hit)를 높이는 메모리 정렬과 패딩 | False Sharing 해결
- C++로 ECS 구현하기
- time() 대신 std::chrono
- std::pmr로 메모리 할당 최적화하기
- C++ SIMD와 병렬화: std::execution과 인트린직 가이드