← 문제로

14. 거짓 공유(false sharing)와 캐시 라인 정렬

난이도 중
내 리뷰 · C++
해설 · C++

해설 — 거짓 공유(false sharing)와 캐시 라인 정렬

난이도: 중상

요약

서로 다른 스레드가 서로 다른 변수를 갱신해도, 그 변수들이 같은 캐시 라인(보통 64바이트) 에 놓이면 캐시 일관성 프로토콜 때문에 라인 소유권이 코어 사이를 핑퐁한다. 논리적으로는 공유가 없는데 하드웨어 수준에서 공유가 생기는 것이 거짓 공유다. alignas(64)로 각 카운터를 별도 라인에 떼어놓으면 사라진다.

예측값(실측, 이 환경 g++ -O2 x86-64)

sizeof(Counters)=16        alignof(Counters)=8
sizeof(PaddedCounters)=128 alignof(PaddedCounters)=64
shared : ~1000~1300 ms
padded : ~140~150 ms   (≈ 7~9배 차이; 절대값은 CPU/부하마다 다름)
  • Counters: atomic<long> 두 개 = 8+8 = 16바이트, 정렬 8. 두 카운터가 한 64바이트 라인 안에 같이 들어간다.
  • PaddedCounters: 멤버마다 alignas(64) → 각 멤버가 자기 라인을 독차지, 구조체 크기 128바이트, 정렬 64.
  • 같은 라인을 두 코어가 번갈아 쓰면 매 증가마다 라인 무효화·재전송이 일어나 수 배~수십 배 느려진다.

단계별 내부 동작

  1. CPU는 메모리를 바이트가 아니라 캐시 라인(보통 64B) 단위로 캐시한다.
  2. MESI 류 일관성 프로토콜에서, 한 코어가 라인에 쓰려면 그 라인을 Modified(독점 소유) 상태로 가져와야 하고, 다른 코어의 사본은 Invalid가 된다.
  3. 코어 A가 a(라인 L에 위치)에 쓰면 L을 M으로 가져오고 코어 B의 L 사본을 무효화한다. 직후 코어 B가 b(역시 L에 위치)에 쓰면 다시 L을 빼앗아온다.
  4. ab는 서로 다른 변수라 진짜 경쟁(데이터 의존)은 없지만, 같은 라인이라 소유권이 코어 사이를 왕복(cache line ping-pong)한다. 이 라인 전송(코어간 버스/인터커넥트 트래픽)이 비용의 정체다.
  5. alignas(64)ab를 다른 라인에 두면, 각 코어가 자기 라인을 M 상태로 계속 보유하며 캐시 히트로 갱신 → 무효화 트래픽이 사라진다.

흔한 오해·함정

  • "atomic 이라 느린 것"이 아니다. relaxed atomic 증가 자체는 싸다. 느린 원인은 라인 공유다. 패딩만으로 같은 atomic 연산이 7~8배 빨라지는 게 증거.
  • 패딩 크기: 64B는 흔한 값일 뿐 절대 상수가 아니다(일부 x86은 인접 라인 프리페치로 128B처럼 동작). 표준은 std::hardware_destructive_interference_size()를 제공하지만, libstdc++에서는 ABI 경고가 날 수 있어 실무에선 상수 64/128을 두고 플랫폼별로 조정하는 경우가 많다.
  • 단일 라인에 너무 많이 패딩하면 캐시 용량·메모리를 낭비한다(여기선 16B가 128B로 8배). 핫한 카운터에만 적용한다.
  • 인접 배열 원소를 여러 스레드가 인덱스별로 나눠 쓰는 패턴(counts[threadId]++)이 거짓 공유의 단골이다. per-thread 누적 후 합치거나 원소를 라인 단위로 패딩한다.

면접 포인트

  • "거짓 공유가 뭐고 어떻게 진단·해결하나?" — 서로 다른 변수가 같은 캐시 라인을 공유해 무효화 핑퐁이 생기는 현상. perf c2c/VTune로 진단, alignas(캐시라인) 패딩이나 per-thread 분리로 해결.
  • "왜 sizeof가 16→128로 커지나?" — alignas(64)가 각 멤버를 64B 경계에 두고 구조체 정렬도 64로 올려 멤버당 한 라인을 차지하기 때문.
  • 트레이드오프: 패딩은 메모리·캐시 점유를 늘리므로 진짜 핫한 공유 변수에만. 읽기 위주면 거짓 공유가 거의 문제되지 않는다(무효화는 쓰기에서 발생).