에듀테크 프로젝트 대시보드
거짓 공유 (False Sharing)
두 스레드가 완전히 다른 변수를 하나씩 나눠 가지고 각자 건드리기만 했는데, 코드는 논리적으로 아무 문제가 없는데도 멀티코어에서 오히려 더 느려지는 현상이 있다. 원인은 데이터가 아니라 캐시 라인(cache line)이라는 하드웨어의 최소 이동 단위 때문이다.
CPU 캐시는 바이트 하나하나가 아니라 보통 64바이트짜리 캐시 라인 단위로 메모리를 읽고 쓴다. 문제는 int a, b;처럼 메모리 상 붙어 있는 두 변수가 있으면, 둘 다 같은 캐시 라인에 들어간다는 것이다.
■ counter_a (Core 0 전용) ■ counter_b (Core 1 전용) — 둘 다 같은 64바이트 라인 안에 있다.
Core 0이 counter_a를 쓰면, MESI 캐시 일관성 프로토콜은 "이 라인 전체"가 바뀌었다고 보고 Core 1이 들고 있던 사본을 통째로 무효화한다. Core 1은 counter_b만 읽었을 뿐인데도 캐시가 날아가 다시 메모리(혹은 Core 0)에서 그 라인을 다시 가져와야 한다. 실제로 공유하는 데이터는 없는데, 캐시 라인이라는 단위 때문에 억울하게 공유된 것처럼 취급된다 — 그래서 이름이 "거짓(false)" 공유다.
Core 0과 Core 1이 각자 자기 카운터를 반복해서 +1 한다. 아래에서 배치 방식을 바꿔가며 재생해 보자.
아래는 두 스레드가 각자 카운터를 1억 번 증가시키는 벤치마크에서 흔히 관찰되는 상대적인 소요 시간 비율(자료용 예시)이다. 실제 배수는 CPU·코어 배치·컴파일러 최적화에 따라 다르지만, 몇 배씩 느려지는 경향은 거의 항상 재현된다.
라인이 코어 사이를 오갈 때마다 수십~수백 사이클의 코어 간 통신 지연이 끼어들기 때문에, 논리적으로 락도 경합도 없는 코드가 캐시 미스만으로 몇 배씩 느려진다.
// 나쁨: a, b가 같은 64B 라인에 붙어 있을 가능성이 높음 struct Counters { long a; // Core 0이 갱신 long b; // Core 1이 갱신 }; // 좋음: 캐시 라인 크기만큼 패딩을 넣어 강제로 라인을 분리 struct Counters { alignas(64) long a; // 이 라인은 Core 0 전용 alignas(64) long b; // 이 라인은 Core 1 전용 };
배열로 여러 스레드가 각자 슬롯을 갱신하는 경우(counters[thread_id]++ 패턴)도 슬롯 사이에 패딩을 끼우거나, Java의 @Contended, C++17의 std::hardware_destructive_interference_size처럼 언어·런타임이 제공하는 정렬 힌트를 쓰면 같은 효과를 얻는다.
| 대응 방법 | 아이디어 |
|---|---|
| 패딩(padding) | 변수 사이에 더미 바이트를 채워 서로 다른 라인에 놓는다 |
| 정렬 지시자 | alignas(64) 등으로 컴파일러가 라인 경계에 맞춰 배치하게 한다 |
| 스레드-로컬 누적 후 합산 | 각 스레드가 자기 로컬 변수에 모았다가 마지막에만 한 번 합친다 |
| 배열 슬롯 간격 넓히기 | counters[i] 대신 counters[i * PAD]처럼 인덱스 간격을 늘린다 |
perf c2c 같은 도구로 실제 캐시 라인 경합을 먼저 측정하고 나서 고치는 게 순서다.