캐시, 파이프라인, 메모리 모델

Jaemyeong Lee·2024년 12월 24일

게임 서버1

목록 보기
112/220

캐시 계층과 지역성(Locality)

캐시 계층 구조

  • CPU는 RAM보다 훨씬 빠르기 때문에 중간에 캐시를 둡니다.
  • 일반적으로 L1(가장 빠름) -> L2 -> L3(가장 큼) 계층을 사용합니다.
  • 데이터는 보통 캐시 라인(대개 64바이트) 단위로 이동합니다.
항목개념성능 영향
시간 지역성최근 접근한 데이터를 다시 접근같은 데이터 반복 접근 시 유리
공간 지역성인접한 데이터를 연속 접근연속 메모리 순회 시 유리

멀티스레드에서 왜 어려워지나

  • 코어마다 캐시가 달라 같은 변수의 최신값이 즉시 보이지 않을 수 있습니다.
  • 이를 맞추기 위해 CPU는 캐시 일관성 프로토콜(MESI 계열)을 수행합니다.
  • 일관성 트래픽이 커지면 성능이 크게 떨어질 수 있습니다.

핵심 정리

  • 멀티스레드 성능은 "연산량"뿐 아니라 "캐시 친화성 + 공유 패턴"에 크게 좌우됩니다.

캐시 성능 실험 (행 우선 vs 열 우선)

2차원 배열 순회 순서 비교

// C/C++ 2차원 배열은 행(Row) 우선 배치
// 행 우선(i -> j): 연속 접근 -> 캐시 히트율 높음
for (int i = 0; i < N; ++i) {
    for (int j = 0; j < N; ++j) {
        sum += buffer[i][j];
    }
}

// 열 우선(j -> i): 점프 접근 -> 캐시 미스 증가
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;
// 스레드1: c.a 증가
// 스레드2: c.b 증가
// a,b가 같은 캐시 라인에 있으면 캐시 라인 핑퐁 발생 가능

개선 방향

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;

// Thread 1
x.store(1, std::memory_order_relaxed);
r1 = y.load(std::memory_order_relaxed);

// Thread 2
y.store(1, std::memory_order_relaxed);
r2 = x.load(std::memory_order_relaxed);

// 특정 환경/타이밍에서 r1 == 0 && r2 == 0 가능
  • "코드 순서대로 보일 것"이라는 직관이 항상 성립하지 않습니다.
  • 참고: 비원자 변수로 같은 실험을 하면 데이터 레이스로 정의되지 않은 동작(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)) {
    // spin or sleep
}
// 여기서 data를 안전하게 관측

강의 시 유의사항

강조 포인트

  • 성능 문제는 "알고리즘"뿐 아니라 캐시/메모리 접근 패턴에서도 크게 발생합니다.
  • 버그 문제는 "락 유무"뿐 아니라 메모리 순서 모델까지 봐야 합니다.
  • volatileatomic의 역할 차이를 분명히 구분해서 설명하세요.

자주 하는 오해

오해바로잡기
캐시는 CPU가 알아서 하므로 신경 쓸 필요 없다순회 순서/데이터 배치에 따라 성능이 크게 달라짐
서로 다른 변수면 항상 충돌이 없다같은 캐시 라인에 있으면 false sharing 가능
volatile이면 멀티스레드 안전하다동기화 보장은 atomic/mutex가 담당

체크 질문 (스스로 답해보기)

  • 왜 2차원 배열에서 행 우선 순회가 유리한가?
  • false sharing이 락 없이도 성능 저하를 만들 수 있는 이유는?
  • volatile bool ready 대신 std::atomic<bool>를 써야 하는 이유는?

profile
李家네_공부방

0개의 댓글