[Daily morning study] CPU 캐시 일관성(Cache Coherence)과 MESI 프로토콜

#daily morning study

Image


캐시 일관성 문제

멀티코어 CPU에서 각 코어는 독립된 L1/L2 캐시를 가진다. 동일한 메모리 주소를 여러 코어가 각자의 캐시에 복사해 두면, 한 코어가 값을 수정했을 때 다른 코어의 캐시에는 여전히 오래된 값이 남는다.

이를 캐시 일관성(Cache Coherence) 문제라고 한다. 하드웨어 수준에서 이를 해결하지 않으면 멀티스레드 프로그램은 올바른 결과를 낼 수 없다.

Core 0 캐시: x = 5
Core 1 캐시: x = 5  ← 둘 다 메모리에서 읽어 캐시에 보관
                      Core 0이 x = 10으로 수정
Core 0 캐시: x = 10
Core 1 캐시: x = 5  ← Core 1은 여전히 낡은 값을 봄 (stale data)

MESI 프로토콜

MESI는 캐시 일관성을 유지하기 위한 가장 널리 쓰이는 스누핑 기반(snooping-based) 프로토콜이다. 각 캐시 라인이 4가지 상태 중 하나를 가진다.

상태의미읽기쓰기
M (Modified)해당 코어만 보유, 메모리와 다름가능가능
E (Exclusive)해당 코어만 보유, 메모리와 동일가능즉시 M으로 전환
S (Shared)여러 코어가 동시에 보유가능다른 코어에 무효화 신호 필요
I (Invalid)무효, 사용 불가캐시 미스 발생캐시 미스 발생

상태 전이 과정

읽기(Read Hit / Miss)

초기 상태: 캐시에 데이터 없음 (Invalid)

1. Core 0이 주소 A를 읽음
   → 캐시 미스 → 메모리에서 로드
   → Core 0 캐시: A = Exclusive (혼자만 보유)

2. Core 1이 주소 A를 읽음
   → 버스에 Read 신호 브로드캐스트
   → Core 0이 스누핑으로 감지: Exclusive → Shared로 전환
   → Core 1도 A를 로드: Shared
   → Core 0, Core 1 캐시: A = Shared

쓰기(Write)

현재 상태: Core 0, Core 1 모두 A = Shared

Core 0이 A에 쓰기 시도:
  → 버스에 Invalidate 신호 브로드캐스트
  → Core 1의 캐시 라인 A: Shared → Invalid
  → Core 0의 캐시 라인 A: Shared → Modified
  → 이제 Core 0만 유효한 데이터 보유

Core 1이 이후 A를 읽으면:
  → 캐시 미스 (Invalid 상태)
  → 버스에 Read 신호
  → Core 0이 감지: Modified → Shared로 전환하며 데이터 제공
  → 메모리도 업데이트 (write-back)

스누핑(Snooping) 메커니즘

모든 코어의 캐시 컨트롤러가 공유 버스(또는 인터커넥트)를 모니터링(snooping)한다. 다른 코어가 버스에 Read/Write/Invalidate 신호를 올리면 자기 캐시에 영향이 있는지 확인하고 상태를 적절히 바꾼다.

[Core 0] ──┐
[Core 1] ──┤── 공유 버스 ── 메모리
[Core 2] ──┤    ↑
[Core 3] ──┘  모든 코어가 버스 트래픽을 스누핑

스누핑 방식은 코어 수가 적을 때 단순하고 효율적이다. 코어가 많아지면 버스 트래픽이 급증하므로, 수십~수백 코어 시스템에서는 디렉토리 기반(directory-based) 프로토콜을 쓴다.

False Sharing

MESI 프로토콜은 캐시 라인(일반적으로 64바이트) 단위로 작동한다. 서로 다른 변수가 같은 캐시 라인에 있으면 논리적으로는 독립적이어도 MESI 프로토콜 때문에 불필요한 무효화가 발생한다. 이를 False Sharing이라고 한다.

// 문제 있는 코드: counter[0]과 counter[1]이 같은 캐시 라인에 위치
int counter[2];  // 8바이트, 같은 64바이트 캐시 라인 안

// Thread 0: counter[0]++  → counter[1]도 같은 라인 → Core 1 캐시 무효화
// Thread 1: counter[1]++  → counter[0]도 같은 라인 → Core 0 캐시 무효화
// 서로 계속 무효화 → 캐시 미스 폭증 → 성능 저하

해결책: 패딩(padding)으로 다른 캐시 라인에 배치

// 각 카운터를 64바이트 경계로 정렬
struct PaddedCounter {
    int value;
    char padding[60];  // 64바이트 맞춤
} counter[2];

// 혹은 C++17 std::hardware_destructive_interference_size 사용
alignas(std::hardware_destructive_interference_size) int counter[2];

메모리 순서(Memory Ordering)와의 관계

MESI 프로토콜이 캐시 일관성을 보장하더라도, 메모리 가시성(visibility)과 순서(ordering) 문제는 별개다. 현대 CPU는 성능을 위해 스토어 버퍼(store buffer)와 인밸리데이트 큐(invalidate queue)를 사용한다.

  • 스토어 버퍼: 쓰기가 캐시에 즉시 반영되지 않고 버퍼에 쌓임
  • 인밸리데이트 큐: 무효화 신호를 즉시 처리하지 않고 큐에 보관

결과적으로 x = 1; y = 1; 순서로 써도 다른 코어에서는 다른 순서로 보일 수 있다. 이를 해결하려면 메모리 배리어(Memory Barrier, mfence/sfence/lfence)나 atomic 연산이 필요하다.

// 컴파일러/CPU 재배치 방지: 스토어 버퍼 플러시 + 인밸리데이트 큐 처리
asm volatile("mfence" ::: "memory");

// C++에서는 atomic + memory_order로 제어
std::atomic<int> x(0);
x.store(1, std::memory_order_release);  // 이전 쓰기가 모두 먼저 보임을 보장

실무 적용 포인트

멀티스레드 성능 디버깅

  • perf stat -e cache-misses,cache-references 로 캐시 미스율 측정
  • False Sharing이 의심되면 perf c2c 명령으로 공유 캐시 라인 충돌 분석

데이터 구조 설계

  • 자주 함께 접근하는 필드는 같은 캐시 라인에 (spatial locality)
  • 서로 다른 스레드가 쓰는 필드는 다른 캐시 라인에 (false sharing 방지)

락 구현의 이유

  • 뮤텍스의 내부 구현이 atomic CAS(Compare-And-Swap) + 메모리 배리어를 쓰는 이유가 MESI만으로는 순서 보장이 안 되기 때문이다.