캐시 계층과 지역성(Locality)
캐시 계층 구조
- CPU는 RAM보다 훨씬 빠르기 때문에 중간에 캐시를 둡니다.
- 일반적으로 L1(가장 빠름) -> L2 -> L3(가장 큼) 계층을 사용합니다.
- 데이터는 보통 캐시 라인(대개 64바이트) 단위로 이동합니다.
| 항목 | 개념 | 성능 영향 |
|---|
| 시간 지역성 | 최근 접근한 데이터를 다시 접근 | 같은 데이터 반복 접근 시 유리 |
| 공간 지역성 | 인접한 데이터를 연속 접근 | 연속 메모리 순회 시 유리 |
멀티스레드에서 왜 어려워지나
- 코어마다 캐시가 달라 같은 변수의 최신값이 즉시 보이지 않을 수 있습니다.
- 이를 맞추기 위해 CPU는 캐시 일관성 프로토콜(MESI 계열)을 수행합니다.
- 일관성 트래픽이 커지면 성능이 크게 떨어질 수 있습니다.
핵심 정리
- 멀티스레드 성능은 "연산량"뿐 아니라 "캐시 친화성 + 공유 패턴"에 크게 좌우됩니다.
캐시 성능 실험 (행 우선 vs 열 우선)
2차원 배열 순회 순서 비교
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
sum += buffer[i][j];
}
}
for (int j = 0; j < N; ++j) {
for (int i = 0; i < N; ++i) {
sum += buffer[i][j];
}
}
측정 시 주의사항
- Debug 빌드가 아닌 Release 빌드에서 측정
- 첫 실행(warm-up) 제외 후 여러 번 반복 측정
- CPU 주파수 변화(절전/터보) 영향을 가능한 한 통제
결과 해석
- 보통 행 우선이 열 우선보다 유의미하게 빠릅니다.
- 정확한 배수는 CPU/캐시 크기/데이터 크기에 따라 달라지므로 절대값보다 경향을 봅니다.
False Sharing (멀티스레드 병목)
개념
- 서로 다른 변수를 수정해도 같은 캐시 라인에 있으면 충돌이 납니다.
- 이를 False Sharing이라고 하며 락이 없어도 성능이 무너질 수 있습니다.
나쁜 예시
struct Counters {
std::atomic<long long> a{0};
std::atomic<long long> b{0};
};
Counters c;
개선 방향
struct alignas(64) PaddedCounter {
std::atomic<long long> v{0};
};
PaddedCounter a, b;
- 스레드별 로컬 변수로 누적 후 마지막에 합치기(reduction)도 매우 효과적입니다.
CPU/컴파일러 재배치와 메모리 모델
왜 순서가 바뀌는가
- CPU와 컴파일러는 단일 스레드 의미를 유지하는 범위에서 명령 순서를 바꿔 최적화합니다.
- 싱글 스레드에서는 문제가 없지만, 멀티스레드에서는 관측 순서 차이로 버그가 생길 수 있습니다.
고전 예제 (x, y, r1, r2)
std::atomic<int> x{0}, y{0};
int r1 = 0, r2 = 0;
x.store(1, std::memory_order_relaxed);
r1 = y.load(std::memory_order_relaxed);
y.store(1, std::memory_order_relaxed);
r2 = x.load(std::memory_order_relaxed);
- "코드 순서대로 보일 것"이라는 직관이 항상 성립하지 않습니다.
- 참고: 비원자 변수로 같은 실험을 하면 데이터 레이스로 정의되지 않은 동작(UB) 입니다.
동기화 기준선
- 공유 플래그/상태 전달은 최소한
release-acquire 쌍으로 동기화합니다.
- 순서를 가장 직관적으로 보장하고 싶다면
seq_cst를 먼저 사용하고, 필요 시 최적화합니다.
volatile 오해 바로잡기
volatile이 하는 일
- C++에서
volatile은 "해당 객체 접근을 생략하지 말라"는 힌트에 가깝습니다.
- 메모리 매핑 I/O 같은 특수 목적에는 의미가 있습니다.
volatile이 하지 못하는 일
- 원자성(atomicity)을 보장하지 않습니다.
- 스레드 간 순서/가시성 동기화를 보장하지 않습니다.
- 즉, 동시성 제어 도구(
mutex, atomic)의 대체재가 아닙니다.
신호 플래그 예시 (권장)
std::atomic<bool> ready{false};
data = 42;
ready.store(true, std::memory_order_release);
while (!ready.load(std::memory_order_acquire)) {
}
강의 시 유의사항
강조 포인트
- 성능 문제는 "알고리즘"뿐 아니라 캐시/메모리 접근 패턴에서도 크게 발생합니다.
- 버그 문제는 "락 유무"뿐 아니라 메모리 순서 모델까지 봐야 합니다.
volatile과 atomic의 역할 차이를 분명히 구분해서 설명하세요.
자주 하는 오해
| 오해 | 바로잡기 |
|---|
| 캐시는 CPU가 알아서 하므로 신경 쓸 필요 없다 | 순회 순서/데이터 배치에 따라 성능이 크게 달라짐 |
| 서로 다른 변수면 항상 충돌이 없다 | 같은 캐시 라인에 있으면 false sharing 가능 |
volatile이면 멀티스레드 안전하다 | 동기화 보장은 atomic/mutex가 담당 |
체크 질문 (스스로 답해보기)
- 왜 2차원 배열에서 행 우선 순회가 유리한가?
- false sharing이 락 없이도 성능 저하를 만들 수 있는 이유는?
volatile bool ready 대신 std::atomic<bool>를 써야 하는 이유는?