필사 모드: NUMA & Cache Coherence 완전 가이드 2025: MESI 프로토콜, False Sharing, Memory Ordering — 멀티코어 성능의 진짜 벽
한국어들어가며: 16코어인데 왜 4배만 빠른가?
수상한 벤치마크
당신의 코드를 단일 코어에서 측정했다: **1 million ops/sec**. 16코어 서버로 옮기면?
기대: **16 million ops/sec**.
실제: **4 million ops/sec**.
멀티스레드로 병렬화했는데도 4배만 빠르다. 코어 사용률은 100%. 락도 없다. 뭐가 문제인가?
답: **Cache coherence**. 아마도 **false sharing**.
숨은 하드웨어의 진실
현대 CPU는 다음과 같이 복잡하다:
- **코어마다 L1, L2 캐시** (전용).
- **소켓 내 L3 캐시** (공유).
- **여러 NUMA 노드** (DRAM 접근 비대칭).
- **Cache coherence protocol** (MESI).
- **Memory ordering** (out-of-order execution).
- **Store buffer, invalidation queue**.
이 하드웨어 계층은 **보통 숨겨져 있다**. 그러나 **극한 성능**에서는 이를 이해해야 한다.
이 글에서 다룰 것
1. **Memory hierarchy**: L1부터 메모리까지.
2. **NUMA**: 소켓 간 비대칭 메모리.
3. **Cache line**: 64바이트의 지배자.
4. **MESI protocol**: 일관성의 수학.
5. **False sharing**: 숨은 성능 킬러.
6. **Memory ordering**: 순서의 미묘함.
7. **atomic operations**: 동시성의 원시 도구.
8. **실전 튜닝**: NUMA pinning, padding, prefetch.
왜 이걸 알아야 하는가?
- **고성능 서버**: 데이터베이스, 게임 엔진, HFT.
- **Lock-free 프로그래밍**: 이 지식 없이 불가능.
- **대형 메모리 시스템**: 수백 GB ~ TB.
- **디버깅**: "코어를 늘려도 안 빨라져"의 답.
**평범한 엔지니어**와 **성능 엔지니어**의 차이는 이 지식에서 나온다.
1. Memory Hierarchy 재확인
속도와 크기의 피라미드
┌──────────┐
│ Register │ ~1 cycle, ~1 KB
├──────────┤
│ L1 Cache │ ~4 cycles, 32~64 KB
├──────────┤
│ L2 Cache │ ~12 cycles, 256 KB~1 MB
├──────────┤
│ L3 Cache │ ~40 cycles, 8~64 MB (shared)
├──────────┤
│ DRAM │ ~200 cycles, GBs
├──────────┤
│ SSD/NVMe │ ~100,000 cycles
└──────────┘
**핵심 비율**:
- L1 → DRAM: **50배** 느림.
- DRAM → SSD: **500배** 느림.
왜 캐시 계층이 있는가
CPU 클럭은 GHz인데 메모리 접근은 수 백 cycles. **bottleneck**.
해결: **캐시**. 자주 쓰는 데이터를 가까이. **locality 원리**:
- **Temporal locality**: 최근 쓴 것은 곧 다시 쓸 것.
- **Spatial locality**: 인접한 주소가 다음에 쓰일 것.
Cache Line: 모든 것의 단위
CPU는 **바이트 단위가 아닌 cache line 단위**로 메모리를 다룬다:
- 기본 크기: **64 바이트** (x86-64, ARM 일반).
- 일부 아키텍처: 128 바이트.
메모리 접근 시:
1. 요청: `addr 0x1000`의 1 바이트.
2. 캐시 miss → DRAM에서 **64 바이트 전체** 로드.
3. 다음 63 바이트 접근은 **캐시 hit**.
이것이 spatial locality의 기반이다.
Cache miss의 비용
int x = array[0]; // Cache miss: ~200 cycles
int y = array[1]; // Cache hit: ~4 cycles
int z = array[16]; // 64 / 4 = 16. 같은 line. Hit.
int w = array[17]; // 같은 line. Hit.
int u = array[16384]; // 다른 line. Miss.
**Stride access** (16, 32, 48...) 또는 **large skip**은 캐시를 활용 못 한다.
2. NUMA: 비대칭 메모리의 세계
UMA vs NUMA
**UMA (Uniform Memory Access)**:
- 모든 CPU가 동일한 시간에 모든 메모리 접근.
- 단일 소켓 또는 작은 시스템.
**NUMA (Non-Uniform Memory Access)**:
- 여러 소켓이 각자의 메모리.
- 로컬 메모리는 빠름, 원격 메모리는 느림.
- 2 소켓 이상 서버에서 표준.
┌──────────────────┐ ┌──────────────────┐
│ Socket 0 │ │ Socket 1 │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ Cores 0-15 │ │ │ │ Cores 16-31 │ │
│ └──────────────┘ │ │ └──────────────┘ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ L3 Cache │ │ │ │ L3 Cache │ │
│ └──────────────┘ │ │ └──────────────┘ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ Local RAM │ │ │ │ Local RAM │ │
│ │ 128 GB │ │ │ │ 128 GB │ │
│ └──────────────┘ │ │ └──────────────┘ │
└────────┬─────────┘ └─────────┬────────┘
│ UPI / Infinity │
└────────────────────────┘
NUMA의 접근 비용
- **Local access**: ~100 cycles (DRAM).
- **Remote access**: ~300 cycles (UPI bus 경유).
- **1.5~2배** 느림.
Big workload에선 이 차이가 **CPU의 30%+ 성능 영향**.
NUMA node 확인
numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30
node 0 size: 128911 MB
node 0 free: 11234 MB
node 1 cpus: 1 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31
node 1 size: 129012 MB
node 1 free: 10234 MB
node distances:
node 0 1
0: 10 21
1: 21 10
**node distances**: 로컬=10, 원격=21 (상대값, 20이면 2배 느림).
NUMA policy
Linux가 제공하는 memory allocation 정책:
- **default**: 현재 CPU의 노드에서 우선 할당.
- **bind**: 특정 노드만 사용.
- **preferred**: 특정 노드 선호.
- **interleave**: 라운드 로빈으로 모든 노드에 분산.
특정 노드에서 실행
numactl --cpunodebind=0 --membind=0 ./my_app
Interleave (큰 메모리 앱)
numactl --interleave=all ./my_app
NUMA의 Thread Migration 문제
Linux 스케줄러는 **load balancing**을 위해 스레드를 다른 CPU로 이동시킨다. NUMA에선 위험:
스레드가 Node 0에서 실행 중.
모든 메모리가 Node 0에 할당됨.
스레드가 Node 1로 이동.
→ 모든 메모리 접근이 원격 (2배 느림).
**해결**:
1. **CPU affinity**: `taskset`으로 고정.
2. **numactl bind**: 메모리도 함께.
3. **AutoNUMA** (커널 기능): 자동 이동.
실측: JVM에서의 NUMA 효과
Java HotSpot의 `-XX:+UseNUMA` 옵션:
**비활성**:
- Young generation이 임의 노드에 할당.
- GC thread가 remote memory 많이 접근.
- 처리량 100%.
**활성**:
- Young generation을 각 NUMA node로 분할.
- 각 thread가 로컬 메모리 우선.
- 처리량 **120~130%**.
30% 향상은 공짜가 아니다. JVM 엔지니어들의 수년간 노력의 결과.
3. Cache Coherence: 동기화의 하드웨어
문제
각 코어가 자기 L1 캐시를 가진다. 두 코어가 같은 메모리를 읽고 쓰면?
Core 0의 L1: variable x = 10
Core 1의 L1: variable x = 10
Core 0: x = 20
Core 0의 L1: x = 20
Core 1의 L1: x = 10 (stale!)
Core 1: y = x + 1
→ y = 11 (잘못된 값)
이 문제가 **cache coherence**. 현대 CPU는 **하드웨어 수준**에서 해결한다.
Coherence 보장
**Cache coherence protocol**이 다음을 보장:
1. **Write propagation**: Core 0의 쓰기가 Core 1에게 **결국** 보임.
2. **Write serialization**: 모든 코어가 **같은 순서**로 쓰기 관찰.
3. **Consistency**: 캐시 상태가 일관성 유지.
이를 위한 가장 유명한 프로토콜이 **MESI**.
4. MESI Protocol
네 가지 상태
각 cache line은 **4가지 상태** 중 하나를 가진다 (MESI):
**M (Modified)**:
- 이 코어만 최신 버전 소유.
- **메모리와 다름** (dirty).
- 쓰기 권한 있음.
**E (Exclusive)**:
- 이 코어만 이 line 소유.
- **메모리와 같음** (clean).
- 쓰기 가능 (쓰면 M으로).
**S (Shared)**:
- 여러 코어가 이 line 소유.
- 모두 메모리와 같음.
- 쓰려면 다른 코어 invalidate 필요.
**I (Invalid)**:
- 유효하지 않음.
- 사용 전 메모리 또는 다른 캐시에서 가져와야.
상태 전이
┌────────┐
│ I │ (초기)
└───┬────┘
│ read miss (다른 캐시 O)
▼
┌────────┐ other write ┌────────┐
│ S │ ────────────→ │ I │
└───┬────┘ └────────┘
│ write (BusUpgr)
▼
┌────────┐
│ M │
└───┬────┘
│ other read (Flush)
▼
┌────────┐
│ S │
└────────┘
Read 시나리오
**Core 0가 address X를 처음 읽기**:
1. Core 0 L1: miss (I).
2. 버스에 BusRd 요청.
3. 아무 캐시도 X 없음 → 메모리에서 로드.
4. 상태: **E (Exclusive)**. Core 0만 소유.
**Core 1이 같은 X를 읽기**:
1. Core 1 L1: miss.
2. BusRd 요청.
3. Core 0가 응답 (스누핑).
4. Core 0: E → S (share 상태로).
5. Core 1: E가 아닌 S로 로드.
6. 결과: 둘 다 **S**.
Write 시나리오
**Core 0가 X를 쓰기 (현재 S)**:
1. BusUpgr 브로드캐스트: "내가 X 쓸 거야".
2. 다른 코어들이 X 캐시 있으면 **I로 invalidate**.
3. Core 0: S → M.
4. Core 0가 X 수정.
**Core 1이 X를 다시 읽기**:
1. Core 1 L1: I (방금 invalidate됨).
2. BusRd 요청.
3. Core 0가 **최신 값으로 응답** (writeback).
4. Core 0: M → S.
5. Core 1: S로 로드.
Memory Barrier
MESI는 일관성을 보장하지만 **모든 것을 해결하지 않는다**:
- **Store buffer**: Core가 쓰기를 버퍼링 후 나중에 cache에 반영.
- **Invalidate queue**: 다른 코어의 invalidation을 큐잉.
이 때문에 **메모리 순서가 예상과 다를 수 있다** (다음 섹션).
MOESI, MESIF의 확장
현대 CPU는 MESI를 확장한 프로토콜 사용:
- **MOESI** (AMD): O (Owned) 상태 추가. Shared이면서 Modified.
- **MESIF** (Intel): F (Forward) 상태 추가. 여러 Shared 중 하나만 응답.
기본 원리는 같음.
5. False Sharing: 숨은 킬러
문제의 시나리오
struct Counter {
int count;
};
Counter counters[16]; // 16 스레드용
void thread_func(int id) {
for (int i = 0; i < 1000000; i++) {
counters[id].count++;
}
}
각 스레드가 **자기 것만** 수정. 락 없음. 완벽한 병렬화?
**아니다**. 이 코드는 **매우 느리다**.
이유: False Sharing
`int`는 4 바이트. `Counter` 구조체도 4 바이트. `counters[16]` = 64 바이트.
**64 바이트는 정확히 하나의 cache line**. 모든 16개 counter가 **같은 line**.
결과:
- Thread 0가 `counters[0]` 수정 → cache line M.
- Thread 1이 `counters[1]` 수정 → Thread 0의 cache invalidate.
- Thread 0가 다시 수정 → Thread 1 invalidate.
- ... 끝없는 **ping-pong**.
**같은 변수를 수정하는 것이 아닌데도** cache coherence 때문에 **마치 공유 변수처럼** 동작. 이것이 **false sharing**.
성능 영향
벤치마크:
**False sharing 있음**:
- 1 thread: 10M ops/sec.
- 16 threads: 15M ops/sec. (1.5배만!)
**False sharing 없음**:
- 1 thread: 10M ops/sec.
- 16 threads: 150M ops/sec. (15배!)
**10배 차이**. 멀티코어의 효과가 거의 사라진다.
해결: Padding
struct alignas(64) PaddedCounter {
int count;
char padding[60]; // 64 바이트 채우기
};
PaddedCounter counters[16]; // 각 64 바이트
각 counter가 **다른 cache line**. False sharing 없음.
C++:
#include <new>
struct alignas(std::hardware_destructive_interference_size) Counter {
int count;
};
`hardware_destructive_interference_size`는 C++17의 표준 방법 (보통 64).
Rust의 해결
use crossbeam_utils::CachePadded;
struct Counter {
count: CachePadded<AtomicU64>,
}
`crossbeam_utils::CachePadded`가 자동으로 적절한 padding.
Java의 해결
JDK 8+:
@Contended
static class Counter {
volatile long value;
}
`-XX:-RestrictContended` JVM 옵션 필요 (혹은 애플리케이션 코드).
False Sharing 탐지
**perf c2c** (cache to cache):
sudo perf c2c record ./my_app
sudo perf c2c report
Cache line 단위로 동시 접근 패턴 분석. False sharing 후보 식별.
실전 사례
**Linux kernel**: **per-CPU variables** + padding.
**JVM**: `ConcurrentHashMap`의 counter 배열에 padding.
**Disruptor** (LMAX): Padding이 핵심 최적화.
**Redis**: 특정 구조체에 padding.
**Disruptor의 성능**:
- Padding 없음: ~5M ops/sec.
- Padding 추가: ~50M ops/sec.
**10배 차이**. Padding 한 줄로.
6. Memory Ordering: 순서의 환상
왜 순서가 문제인가
멀티코어에서 다음 코드를 생각해 보자:
// Thread 0:
x = 1; // (1)
flag = true; // (2)
// Thread 1:
while (!flag); // (3)
print(x); // (4)
**기대**: Thread 1이 `x = 1`을 출력.
**실제**: 경우에 따라 **0을 출력**할 수 있다.
**이유**: CPU와 컴파일러가 **순서를 재배치**. (1)과 (2)의 순서가 바뀌면 Thread 1은 flag만 보고 x는 아직 안 봄.
Out-of-Order Execution
현대 CPU는 명령어를 **순서대로 실행하지 않는다**:
- Instruction-level parallelism.
- 이전 명령이 메모리 대기 중이면 다음 명령 먼저.
- 결과는 **원래 순서로 retire**.
단일 스레드에선 상관없지만 **멀티스레드에선** 다른 스레드가 이 재배치를 볼 수 있다.
Store Buffer
각 코어는 **store buffer**를 가진다:
Core 0:
store x = 1 → store buffer
store flag = true → store buffer
Cache (아직 안 나감):
x = 0 (old)
flag = false (old)
Store가 **눈에 띄기 전에** 다른 store가 cache에 도달할 수 있다.
Memory Barriers
**Memory barrier** (또는 fence)는 CPU에게 순서를 강제:
x = 1;
memory_barrier(); // 모든 이전 store가 완료되어야
flag = true;
Barrier는 **파이프라인을 flush**. 성능 비용 있지만 순서 보장.
x86 vs ARM
**x86-64**: 상대적으로 **strong memory model**. 대부분의 reordering이 제한됨:
- Loads이 loads와 재배치 안 됨.
- Stores가 stores와 재배치 안 됨.
- Store-load 재배치는 있음 (store buffer).
**ARM, POWER**: **weak memory model**. 거의 모든 재배치 가능.
결과:
- x86에서만 개발하면 "운 좋은" 버그가 ARM에서 터짐.
- ARM 서버 시대에 **더 중요**해진 이슈.
Memory Ordering Types
C++11, Rust, Java 등은 **memory ordering** 레벨 제공:
- **Relaxed**: 순서 보장 없음. 가장 빠름.
- **Acquire**: 이후의 load/store가 이 연산 이후에 발생.
- **Release**: 이전의 load/store가 이 연산 이전에 발생.
- **AcqRel**: Acquire + Release.
- **SeqCst**: Sequentially consistent. 가장 강함, 가장 느림.
std::atomic<bool> flag{false};
std::atomic<int> data{0};
// Producer
data.store(42, std::memory_order_release);
flag.store(true, std::memory_order_release);
// Consumer
while (!flag.load(std::memory_order_acquire)) {}
int value = data.load(std::memory_order_acquire);
// value == 42가 보장됨
`release`와 `acquire`의 쌍이 **happens-before 관계**를 형성.
SeqCst는 왜 느린가
Sequentially consistent는 **모든 코어가 같은 순서로 관찰**을 보장한다:
- Store buffer flush.
- 모든 코어에 보이는 시점 동기화.
- MESI의 엄격한 적용.
비용: 수십~수백 cycles 추가. 가벼운 연산에선 큰 차이.
**최적화 원칙**: **필요한 최소 순서**만 사용. 대부분 `acquire/release`로 충분.
7. Atomic Operations
Atomic 연산의 의미
**원자적(atomic)**: 연산이 **인터럽트되지 않고** 완료됨. 중간 상태가 보이지 않음.
counter++ // non-atomic: load, add, store의 3단계
이 세 단계 사이에 다른 스레드가 끼어들면 race condition.
atomic_fetch_add(&counter, 1) // atomic: 단일 연산
하드웨어가 **락 없이** 이를 보장.
x86의 Atomic 명령어
- `LOCK ADD`: atomic 덧셈.
- `LOCK XADD`: atomic fetch-and-add.
- `LOCK CMPXCHG`: compare-and-swap.
- `LOCK CMPXCHG16B`: 128비트 CAS.
`LOCK` prefix는 명령 실행 동안 **메모리 버스를 독점**. 비용: ~20-100 cycles (일반 store의 수 배).
Compare-and-Swap (CAS)
lock-free 알고리즘의 기본 원시 연산:
bool cas(int *ptr, int expected, int desired) {
atomically {
if (*ptr == expected) {
*ptr = desired;
return true;
} else {
return false;
}
}
}
**사용 패턴**:
int old_val;
do {
old_val = *ptr;
int new_val = compute(old_val);
} while (!cas(ptr, old_val, new_val));
다른 스레드가 먼저 수정하면 재시도.
ABA 문제
CAS의 유명한 함정:
Thread 0: 값 읽기 A
Thread 1: A → B → A로 두 번 수정
Thread 0: CAS(A, C) → 성공 (여전히 A)
값은 같지만 **의미는 다를 수 있다**. 예: stack pop 중 node가 재사용되어 문제.
**해결**:
- **Tagged pointer**: 카운터와 포인터를 함께.
- **Hazard pointer**: 사용 중인 포인터 추적.
- **Epoch-based reclamation**: 세대 기반 GC.
LL/SC (Load-Linked / Store-Conditional)
RISC 아키텍처(ARM, POWER, RISC-V)의 CAS 대안:
LL addr, reg1 # 값 읽고 "예약"
(수정 로직)
SC reg2, addr # 예약이 유효하면 쓰기, 아니면 실패
ABA 문제 원천 해결 (포인터 값이 아닌 주소 예약).
x86의 fence 명령어
- **LFENCE**: load fence.
- **SFENCE**: store fence.
- **MFENCE**: full barrier.
대부분의 atomic 연산이 자체적으로 fence 포함.
성능
- **일반 load/store**: ~1 cycle.
- **Atomic load/store**: ~1-2 cycles.
- **CAS (uncontended)**: ~20 cycles.
- **CAS (contended)**: 수백 cycles (cache line 경합).
**핵심**: Contention이 있으면 atomic도 느려진다. **Data layout**이 중요.
8. Lock-free vs Lock-based
Lock의 본질
Mutex, semaphore는 내부적으로 **atomic operations + wait queue**:
void lock() {
while (cas(&locked, 0, 1) == false) {
wait_in_queue();
}
}
- Uncontended: ~20 cycles (CAS 한 번).
- Contended: 수천 cycles (system call, context switch).
Lock-free의 약속
**Lock-free**: 어떤 스레드도 block되지 않음. 진행 보장.
**장점**:
- **Deadlock 없음**.
- **Priority inversion 없음**.
- **Interrupt-safe**.
- **일부 경우 더 빠름**.
**단점**:
- **구현 복잡**.
- **ABA 문제**.
- **Memory reclamation 어려움**.
- **Contention이 심하면 오히려 느릴 수 있음**.
Wait-free, Lock-free, Obstruction-free
세 가지 보장 수준:
- **Wait-free**: 모든 스레드가 **유한 시간 내** 완료. 가장 강함.
- **Lock-free**: 최소 하나의 스레드가 항상 진행. 대부분 시도 만족.
- **Obstruction-free**: 한 스레드만 실행되면 완료. 가장 약함.
대부분의 "lock-free" 자료구조는 **lock-free** (wait-free는 드묾).
실전: Queue 구현
**Mutex-based queue**: 간단. push/pop 시 락.
**Lock-free queue (Michael-Scott)**:
push(x):
node = new Node(x)
loop:
tail = load(tail_ptr)
next = load(tail->next)
if (next == null):
if (cas(tail->next, null, node)):
cas(tail_ptr, tail, node)
return
else:
cas(tail_ptr, tail, next) // help others
복잡하지만 락 없음. 실제로는 **구현 더 복잡**.
성능 비교
벤치마크: 4 스레드 queue push/pop.
- **Mutex**: 10M ops/sec.
- **Lock-free**: 30M ops/sec.
- **Single thread**: 50M ops/sec.
Lock-free가 3배 빠름. 하지만 single thread보다는 느림 (조율 오버헤드).
**높은 contention에선** 락이 더 빠를 수 있다. **Lock-free가 항상 정답은 아니다**.
9. 실전 튜닝 기법
1. False Sharing 제거
가장 쉬운 개선:
// Before
struct Stats {
atomic<long> reads;
atomic<long> writes;
};
// After
struct alignas(64) Stats {
atomic<long> reads;
char pad[64 - sizeof(atomic<long>)];
atomic<long> writes;
};
2. NUMA-aware Allocation
#include <numa.h>
void* ptr = numa_alloc_onnode(size, numa_node_of_cpu(current_cpu));
또는 cgroup에서 NUMA 노드 지정.
3. Thread Pinning
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(cpu_id, &cpuset);
pthread_setaffinity_np(thread, sizeof(cpuset), &cpuset);
특정 코어에 고정. Cache 유지.
4. Prefetch
컴파일러나 명시적:
__builtin_prefetch(&array[i + 16]); // GCC
// 또는
_mm_prefetch(&array[i + 16], _MM_HINT_T0); // Intel intrinsics
데이터 접근 직전에 캐시로 미리 로드. Sequential access에는 자동 (prefetcher).
5. Data Layout 최적화
**Array of Structures (AoS)**:
struct Point { float x, y, z; };
Point points[1000];
**Structure of Arrays (SoA)**:
struct Points {
float x[1000], y[1000], z[1000];
};
`x`만 순회할 때 SoA가 캐시 효율 3배 높음. SIMD 친화.
6. Memory Ordering 최적화
// Strong (느림)
counter.fetch_add(1, memory_order_seq_cst);
// Weak (빠름)
counter.fetch_add(1, memory_order_relaxed);
단순 카운터엔 relaxed 충분. 동기화 지점만 acquire/release.
7. Huge Pages
echo 1024 > /proc/sys/vm/nr_hugepages # 1024 × 2MB
TLB miss 감소. DB buffer pool에 유용.
8. Avoid Contention
**예**: 공유 카운터 대신 **per-thread counter** + **주기적 집계**.
thread_local long local_count = 0;
void increment() {
local_count++;
if (local_count % 1000 == 0) {
global_counter.fetch_add(1000);
local_count = 0;
}
}
Contention 1000배 감소.
10. 디버깅 도구
perf stat
perf stat -e cache-misses,cache-references,L1-dcache-load-misses ./my_app
캐시 효율 측정.
perf c2c (cache to cache)
sudo perf c2c record ./my_app
sudo perf c2c report
False sharing 탐지의 표준 도구.
Intel VTune / AMD uProf
상세한 마이크로아키텍처 분석. False sharing, NUMA traffic, cache miss 등.
Valgrind Cachegrind
valgrind --tool=cachegrind ./my_app
느리지만 상세한 캐시 miss 분석.
bpftrace
L3 cache miss 추적
sudo bpftrace -e 'hardware:cache-misses:1000 { @[comm] = count(); }'
Thread Sanitizer
gcc -fsanitize=thread -g -o my_app my_app.c
Data race 탐지. Atomic 순서 버그 발견에 유용.
11. 실전 사례
사례 1: Redis의 Single-threaded 선택
Redis는 **single-threaded**다. 왜?
- 모든 데이터가 한 코어의 캐시.
- **False sharing 걱정 없음**.
- Lock 오버헤드 없음.
- 단순한 코드.
멀티스레드의 "이득"이 오버헤드 때문에 사라질 수 있다. Redis는 **극도의 효율**을 위해 단일 스레드를 선택했다.
사례 2: LinkedIn의 Per-CPU Counter
LinkedIn의 Java 시스템에서:
**Before** (전통 atomic counter):
- 4 스레드에서 극심한 contention.
- 성능 1/4로.
**After** (per-CPU counter + periodic aggregation):
- 거의 선형 확장.
- 12배 성능 향상.
사례 3: Netflix의 NUMA 최적화
Netflix의 **Open Connect Appliance**:
- **Dual-socket 서버** (NUMA).
- **CPU pinning**: 각 NIC의 interrupt를 로컬 코어에 고정.
- **Memory allocation**: NIC과 같은 NUMA 노드에서.
- **결과**: 단일 서버 200 Gbps 달성.
NUMA 무시 → 100 Gbps 한계.
사례 4: JVM의 Biased Locking (deprecated)
JDK 6~14: **Biased locking**. 락이 **동일 스레드에 반복 획득**되면 낙관적으로 빠른 경로.
- 단일 스레드: 매우 빠름.
- 다중 스레드: 오버헤드.
**JDK 15+에서 제거**: 멀티코어 환경에선 이득이 적고 복잡성만 남김.
사례 5: Cassandra의 Token Ring과 NUMA
Cassandra 초기:
- 토큰 ring 데이터가 random NUMA 노드에.
- Hot path에서 remote access.
개선:
- **NUMA-aware** 배치.
- 하나의 토큰 범위를 하나의 NUMA 노드에.
- 처리량 15% 향상.
12. 흔한 함정과 교훈
함정 1: "Atomic이 빠르다"
**오해**: Atomic은 락보다 빠르다.
**진실**: Atomic은 **uncontended 상황**에서만 빠르다. **High contention**에선 cache line ping-pong으로 더 느릴 수 있다.
**해결**: 측정하라. 단순 benchmark로 atomic과 mutex를 비교.
함정 2: False Sharing 무시
**증상**: 멀티스레드 성능이 기대보다 낮음.
**해결**: `perf c2c`로 확인. Padding 추가.
함정 3: NUMA 무시
**증상**: 2 소켓 서버에서 1 소켓보다 성능이 안 나옴.
**원인**: Remote memory access.
**해결**: NUMA pinning, interleave.
함정 4: x86에서만 테스트
**증상**: ARM 서버에서 이상한 동작.
**원인**: x86의 strong memory model에 의존.
**해결**: ARM에서도 테스트. 명시적 memory barrier 사용.
함정 5: Prefetch 남용
**증상**: Prefetch 추가했는데 더 느림.
**원인**: Prefetcher 방해, cache pollution.
**해결**: 측정. 자동 prefetcher가 대부분 충분.
함정 6: Lock-free 함정
**증상**: Lock-free 큐 구현이 버그 투성이.
**원인**: ABA, memory reclamation, memory ordering.
**해결**: 검증된 라이브러리 사용 (boost::lockfree, folly, crossbeam).
퀴즈로 복습하기
**A.**
**원인**: **Cache line 단위의 coherence**.
CPU는 바이트가 아닌 **64바이트 cache line** 단위로 메모리를 다룬다. Cache coherence protocol (MESI)도 cache line 단위로 동작.
**시나리오**:
struct Counters {
int a; // offset 0
int b; // offset 4
};
Counters c;
// Thread 0: c.a만 수정
// Thread 1: c.b만 수정
`c.a`와 `c.b`는 **다른 변수**지만 **같은 cache line**에 있다 (4 + 4 = 8 bytes < 64).
**MESI의 동작**:
1. Thread 0이 `c.a = 1` 실행:
- Core 0 cache line: M (Modified), `a=1, b=0`.
- Core 1 cache line: I (Invalidated).
2. Thread 1이 `c.b = 2` 실행:
- Core 1: cache miss (I).
- BusRd 요청.
- Core 0: cache line flush (writeback 또는 forward).
- Core 0: S → I.
- Core 1: `a=1, b=2` 로드, 상태 M.
3. Thread 0이 `c.a = 3` 실행:
- Core 0: I (방금 invalidate).
- 다시 로드해야.
- Core 1: M → I.
- Core 0: `a=3, b=2` 로드.
**결과**: 매 수정마다 **cache line이 두 코어 사이를 왕복**. 이것이 **cache line ping-pong**.
**비용**:
- **정상 L1 접근**: ~4 cycles.
- **Cache line invalidation**: ~30-100 cycles.
- **Remote L1 load (cross-socket)**: ~200+ cycles.
Thread가 "자기 것만" 수정하는 것처럼 보여도 **마치 공유 변수처럼** 동기화 오버헤드 발생.
**성능 영향**:
실제 측정:
- False sharing 없음: 16 threads = 16배 성능.
- False sharing 있음: 16 threads = 1.5배 성능 (또는 심지어 더 느림).
**10배 이상 손실**. 멀티코어의 효과가 거의 사라진다.
**해결**:
**Padding**:
struct alignas(64) PaddedCounter {
int count;
char pad[60];
};
각 counter를 **별도 cache line**에 배치. Ping-pong 완전히 제거.
**C++17**:
struct alignas(std::hardware_destructive_interference_size) Counter {
int count;
};
**Rust**:
use crossbeam_utils::CachePadded;
let counters: Vec<CachePadded<AtomicU64>> = vec![...];
**Java**:
@sun.misc.Contended
static long value;
**탐지**:
sudo perf c2c record ./my_app
sudo perf c2c report
HitM (Hit in Modified state) 이벤트가 많은 cache line = false sharing 후보.
**교훈**:
False sharing은 **"존재하지 않는 문제"를 찾아내는 것**이 핵심이다. 코드를 봐선 전혀 안 보인다. 하드웨어 지식 없이는 이해 불가.
**"내 코드가 왜 멀티코어에서 안 빨라지지?"** 라는 질문의 답이 70% 이상 false sharing이다. 현대 멀티코어 서버에서 이는 **가장 흔한 숨은 성능 킬러**다.
이것이 **평범한 엔지니어와 성능 엔지니어의 차이**다. 후자는 cache line을 보고, 전자는 보지 못한다.
**A.**
**네 가지 상태**:
**M (Modified)**:
- **이 cache에만 존재**하며 (exclusive).
- **메모리와 다름** (dirty, writeback 필요).
- 자유롭게 읽기/쓰기 가능.
**E (Exclusive)**:
- **이 cache에만 존재**하며.
- **메모리와 같음** (clean).
- 자유롭게 읽기 가능. 쓰려면 M으로 전환.
**S (Shared)**:
- **여러 cache에 존재** 가능.
- 모두 메모리와 같음.
- 읽기만 가능. 쓰려면 다른 cache를 invalidate 해야.
**I (Invalid)**:
- 유효하지 않음.
- 사용 전 메모리나 다른 cache에서 가져와야.
**왜 네 가지가 필요한가**:
언뜻 Modified와 Invalid만 있으면 되지 않을까? 더 효율적으로 만들려면 세 개? 네 개는 필수인가?
**Shared의 필요성**:
여러 코어가 같은 데이터를 **읽기만** 한다면? Modified로 두면 한 코어만 소유 가능 → 다른 코어는 읽을 때마다 invalidate + 재로드. 비효율.
Shared 상태로 두면 **모든 코어가 동시에 캐시 보유** 가능. 읽기는 로컬 캐시에서 즉시.
**Modified의 필요성**:
쓰기가 있으면 "누가 최신 버전을 가졌는가" 추적 필요. Modified = "나만 최신" = writeback 책임.
**Exclusive의 필요성**:
Shared와 Modified의 **중간 상태**. "나만 가졌지만 아직 안 바꿨다".
왜 필요한가? **최적화**. 만약 E 없이 S만 있다면:
- 한 코어가 처음 로드: S.
- 쓰기: S → M (BusUpgr로 invalidate 브로드캐스트).
- 다른 코어에 이 line이 없는데도 **불필요한 브로드캐스트**.
E가 있으면:
- 첫 로드: E (나만 가졌음을 알고).
- 쓰기: E → M (브로드캐스트 불필요, 조용히 전환).
- **버스 트래픽 절약**.
**Invalid의 필요성**:
명시적으로 "이 cache line은 stale"이라고 표시. 사용 시 무효함을 인식.
**상태 전이 예시**:
**Read 시나리오**:
[초기 상태: 모두 I]
Core 0 reads X:
- I → check bus → 아무 캐시도 없음 → memory에서 로드 → E
Core 1 reads X:
- I → BusRd → Core 0 responds → Core 0: E→S, Core 1: S
Core 2 reads X:
- I → BusRd → Core 0 or Core 1 responds → all: S
**Write 시나리오**:
[Core 0, 1이 S]
Core 0 writes X:
- S → BusUpgr → Core 1 invalidates → Core 0: M, Core 1: I
Core 1 reads X again:
- I → BusRd → Core 0 responds (writeback) → Core 0: S, Core 1: S
**MESI의 확장**:
**MOESI** (AMD의 선택):
- **O (Owned)** 상태 추가.
- O = Shared + Modified. 메모리와 다르지만 다른 캐시도 가질 수 있음.
- 이득: Modified 상태에서 다른 코어가 읽으려 하면, M → O → 다른 코어도 S (또는 O). writeback 지연 가능.
**MESIF** (Intel의 선택):
- **F (Forward)** 상태 추가.
- F = Shared 중 "응답할 대표".
- 여러 코어가 S로 가졌을 때, 한 코어만 F. 다른 코어가 read 시 F 상태의 코어가 응답 (메모리보다 빠름).
**프로토콜 복잡성의 이유**:
모든 최적화가 **성능을 위한 것**. 각 상태 추가는 특정 시나리오에서 몇 cycle 절약.
평범한 소프트웨어 엔지니어에겐 세부사항 무관. 그러나 **lock-free 프로그래머, OS 개발자, 컴파일러 엔지니어**는 이를 깊이 이해해야 한다. 메모리 모델의 정확한 이해가 **correctness**의 기반이다.
**실전 시각화**:
Core 0 read: I → E (메모리에서)
Core 1 read: I → S (Core 0: E → S)
Core 0 write: S → M (Core 1: S → I)
Core 1 read: I → S (Core 0: M → S, writeback)
Core 0 write: S → M (Core 1: S → I)
...
두 코어가 번갈아 쓰기 → **ping-pong**. 이것이 false sharing의 근본 원리.
**교훈**:
MESI는 **분산 시스템의 consistency를 하드웨어 수준에서 구현**한 것이다. 소프트웨어 distributed system이 해결하는 문제 (consistency, ordering)를 CPU가 수 GHz 속도로 해결한다.
이 우아함이 현대 멀티코어의 기반이다. 모든 프로그래머가 이를 활용하지만 대부분 인식 못한다. **하드웨어가 해주는 마법을 인식하는 것**이 엔지니어링의 깊이다.
**A.**
**NUMA의 물리적 구조**:
Socket 0 Socket 1
┌──────────────┐ ┌──────────────┐
│ CPUs 0-15 │ │ CPUs 16-31 │
│ L1/L2/L3 │ │ L1/L2/L3 │
└──────┬───────┘ └──────┬───────┘
│ Memory Channel │ Memory Channel
│ (100 GB/s) │ (100 GB/s)
▼ ▼
┌──────────────┐ ┌──────────────┐
│ DRAM 128GB │ │ DRAM 128GB │
│ (local to 0) │ │ (local to 1) │
└──────────────┘ └──────────────┘
▲ ▲
│ │
└─── UPI / Infinity Fabric ─┘
(slower, ~40 GB/s)
**Local access**:
- Socket 0 CPU → Socket 0 DRAM.
- 경로: CPU → Memory Controller → DRAM.
- 지연: ~100 cycles (~30 ns).
- 대역폭: 100 GB/s.
**Remote access**:
- Socket 0 CPU → Socket 1 DRAM.
- 경로: CPU → UPI → Remote Memory Controller → DRAM.
- 지연: ~300 cycles (~90 ns).
- 대역폭: 40 GB/s (UPI link 제한).
**숫자의 의미**:
- **3배 느린 지연**.
- **2.5배 낮은 대역폭**.
- 대규모 워크로드에선 **전체 성능 30-50% 손실**.
**Locality가 무너지는 상황**:
**1. Thread migration**:
Linux 스케줄러가 **load balancing**으로 thread를 이동:
Thread A가 Socket 0에서 시작.
Thread A의 working set (10 GB)이 Socket 0 memory에 할당.
시간이 지나 Load balance → Thread A가 Socket 1로.
이제 모든 memory access가 REMOTE.
**결과**: 성능이 30% 이상 급락.
**2. 잘못된 allocation**:
// Main thread (Socket 0)에서 allocation
void *buffer = malloc(1GB);
// First-touch policy: 접근 전까지는 physical page 할당 안 됨
// Worker threads (여러 socket) 생성
// 각 worker가 buffer의 일부를 처리
Linux의 **first-touch policy**는 처음 접근하는 CPU의 노드에 물리 페이지 할당. Main thread에서 메모리를 초기화하면 모든 페이지가 Socket 0에 할당됨. Socket 1의 worker는 전부 remote access.
**3. 거대 공유 자료구조**:
huge_hashmap map; // 100 GB
// 모든 thread가 접근
Map이 여러 노드에 분산되어 있으면 **반반 확률로 remote**. 평균 지연 증가.
**4. Buffer pool 문제**:
DB의 shared buffer pool이 한 노드에 모두 할당되면 다른 노드의 thread가 모두 remote access.
**측정: 실제 영향**
**PostgreSQL 벤치마크** (2 socket, 각 16 cores):
- **NUMA aware** (thread와 memory 고정): 100K TPS.
- **NUMA unaware** (기본): 70K TPS.
- **30% 손실**.
**JVM 벤치마크** (Spark 워크로드):
- `-XX:+UseNUMA`: 100%.
- 비활성: 80-85%.
- **15-20% 손실**.
**Redis**: Single-threaded이라 NUMA 영향 적음. 단, `CPU pinning` 없으면 스케줄링으로 간헐적 영향.
**해결책**:
**1. CPU Affinity**:
특정 노드에 바인드
numactl --cpunodebind=0 --membind=0 ./app
또는 taskset
taskset -c 0-15 ./app
**2. 애플리케이션 레벨 NUMA awareness**:
#include <numa.h>
void* alloc_on_node(size_t size, int node) {
return numa_alloc_onnode(size, node);
}
// 각 worker가 자기 노드에 할당
for (int i = 0; i < num_workers; i++) {
int node = i % num_numa_nodes;
worker_buffers[i] = alloc_on_node(BUFFER_SIZE, node);
// worker를 해당 node의 CPU에 pin
pin_thread_to_node(worker_thread[i], node);
}
**3. AutoNUMA (Linux 커널)**:
echo 1 > /proc/sys/kernel/numa_balancing
커널이 자동으로 page와 thread를 가까이 이동. 완벽하진 않지만 어느 정도 도움.
**4. Interleave policy (큰 공유 데이터)**:
numactl --interleave=all ./app
모든 노드에 고르게 분산. 평균적 접근. 최적은 아니지만 worst case 방지.
**5. JVM tuning**:
java -XX:+UseNUMA \
-XX:+UseG1GC \
-XX:ParallelGCThreads=16 \
MyApp
JVM이 young generation을 NUMA node별로 분할.
**실전 고려사항**:
**Container 환경**:
Kubernetes, Docker에서 NUMA를 인식하려면:
- **CPU Manager**: static policy로 exclusive CPU 할당.
- **Topology Manager**: CPU와 memory의 NUMA 정렬.
Kubernetes Node
kubelet 옵션:
--cpu-manager-policy=static
--topology-manager-policy=single-numa-node
**언제 NUMA가 중요한가**:
- **2+ socket 서버**: 주로 여기서.
- **Memory-bound 워크로드**: DB, 빅데이터, 캐시.
- **High throughput**: 처리량이 핵심.
**언제 덜 중요한가**:
- **Single socket**: NUMA 없음.
- **Compute-bound**: CPU가 bottleneck (ML 학습).
- **Small dataset**: L3 cache에 들어가면 memory 접근 적음.
**교훈**:
NUMA는 **"눈에 안 보이는 세금"** 이다. 설정이 틀리면 20-30% 성능 손실이 발생하지만 어디에서 손실이 일어나는지 명확하지 않다.
현대 서버의 표준 아키텍처이므로 **고성능 애플리케이션은 반드시** NUMA를 고려해야 한다. 그렇지 않으면 하드웨어의 잠재력을 활용하지 못한다.
**"16 core로 확장했는데 왜 8 core 때만큼 빠르지 않지?"** 의 답이 종종 NUMA다. 이것을 이해하는 것이 **진짜 시스템 엔지니어링**이다.
**A.**
**문제의 근원**: 현대 CPU는 **out-of-order execution**을 한다. 명령어를 순서대로 실행하지 않고 의존성이 없는 것부터 병렬로 처리. 단일 스레드에선 결과가 올바르지만 **멀티스레드에선 다른 스레드가 "잘못된 순서"로 관찰**할 수 있다.
**예시**:
// Shared variables
int data = 0;
bool ready = false;
// Thread 0 (Producer):
data = 42; // (1)
ready = true; // (2)
// Thread 1 (Consumer):
while (!ready); // (3)
print(data); // (4)
**기대**: Thread 1이 `42`를 출력.
**실제로 일어날 수 있는 일**:
**Store buffer의 영향**:
- Thread 0이 `data = 42` 실행 → store buffer로.
- Thread 0이 `ready = true` 실행 → store buffer로.
- Store buffer가 **순서를 바꿔서** memory에 flush 가능.
- 가능한 관찰 순서: `ready=true`가 `data=42`보다 먼저.
**Out-of-order의 영향**:
- CPU가 `ready = true`를 `data = 42`보다 먼저 retire.
- Thread 1이 `ready=true`를 보지만 `data`는 여전히 0.
**결과**: Thread 1이 **0을 출력할 수 있다**. 매우 드물지만 발생.
**Memory Barrier의 역할**:
CPU에게 "이 지점 이전의 모든 memory 연산이 완료되어야 이후 연산 시작" 이라고 명령:
// Thread 0:
data = 42;
memory_barrier(); // 모든 이전 store가 visible
ready = true;
이제 `ready=true`가 보이면 **반드시** `data=42`도 보임. Thread 1이 42를 본다.
**Barrier의 종류**:
**Full barrier (MFENCE in x86)**:
- 이 지점 이전의 **모든 load/store**가 완료.
- 이후 load/store는 아직 시작 안 함.
- 가장 강한 보장, 가장 비쌈.
**Load barrier (LFENCE)**:
- 이전 load 완료.
- Store는 무관.
**Store barrier (SFENCE)**:
- 이전 store 완료.
- Load는 무관.
**CPU 아키텍처 차이**:
**x86/x86-64**: Strong memory model
- Store-store reordering 없음.
- Load-load reordering 없음.
- **Store-load reordering 있음** (store buffer 때문).
- 대부분의 코드가 barrier 없이도 작동.
**ARM, POWER, RISC-V**: Weak memory model
- 거의 모든 reordering 가능.
- **반드시 명시적 barrier**.
**x86에서만 테스트한 코드가 ARM에서 버그나는 전형적 이유**.
**고수준 언어의 지원**:
C++11 `std::atomic`:
std::atomic<int> data{0};
std::atomic<bool> ready{false};
// Thread 0
data.store(42, std::memory_order_release);
ready.store(true, std::memory_order_release);
// Thread 1
while (!ready.load(std::memory_order_acquire));
int value = data.load(std::memory_order_acquire);
// value == 42 보장
**Acquire-Release pattern**:
- `release` store는 이전 memory 연산이 먼저 끝나도록.
- `acquire` load는 이후 memory 연산이 나중에 시작하도록.
- 두 쌍이 맞으면 happens-before 관계 형성.
**Memory ordering levels (C++ 기준)**:
1. **relaxed**: 순서 없음. Atomic만 보장.
2. **acquire**: 이후 load/store가 이 load 이후.
3. **release**: 이전 load/store가 이 store 이전.
4. **acq_rel**: 둘 다.
5. **seq_cst**: 전 코어가 같은 순서로 관찰. 가장 강함, 기본값.
**실전 사용**:
**단순 카운터**:
counter.fetch_add(1, std::memory_order_relaxed);
// 순서 무관. 단지 atomic 증가만.
**Publish-Subscribe**:
// Publisher
config_ptr.store(new_config, std::memory_order_release);
// Subscriber
Config* cfg = config_ptr.load(std::memory_order_acquire);
**Lock 구현**:
// Lock acquire
while (locked.exchange(true, std::memory_order_acquire)) { /* spin */ }
// Lock release
locked.store(false, std::memory_order_release);
**성능 영향**:
- **Relaxed**: 1-2 cycles.
- **Acquire/Release**: 1-5 cycles (x86에선 거의 공짜).
- **SeqCst**: 20-100 cycles (full barrier, store buffer flush).
**권장**: 필요한 **최소 순서**만 사용. 대부분 acquire/release 충분. seq_cst는 정말 필요할 때만.
**언제 barrier가 필요한가**:
1. **Lock-free 자료구조** 구현.
2. **Producer-consumer** 패턴.
3. **Double-checked locking**.
4. **Signal handler**.
5. **Memory-mapped I/O** (드라이버).
**언제 필요 없는가**:
1. **Mutex 사용 시**: Mutex가 내부적으로 barrier 포함.
2. **단일 스레드**: Out-of-order 투명.
3. **High-level 언어의 atomic**: 자동 처리.
**Volatile의 한계**:
C/C++의 `volatile`은 **barrier 아님**:
- 컴파일러 최적화 방지만.
- Memory ordering 보장 없음.
- Java/C#의 `volatile`과 다름.
// BAD
volatile int ready = 0;
data = 42;
ready = 1; // reordering 가능!
**올바른 방법**:
std::atomic<int> ready{0};
data = 42;
ready.store(1, std::memory_order_release);
**교훈**:
Memory barrier는 **"cost without visible benefit"** 의 전형. 코드에 없으면 정상 동작하는 것처럼 보이지만 특정 조건에서 **예측 못한 버그**. 멀티코어, 여러 아키텍처를 타겟팅할수록 중요.
"나는 lock-free 코드를 안 쓰니까 괜찮아"? **틀렸다**. 다음 경우에도 필요:
- 간단한 flag 기반 통신.
- Initialization order.
- Lock 구현.
**규칙**: 멀티스레드 코드를 작성하면 memory ordering을 이해하거나, **mutex/condition variable 같은 고수준 primitive만 사용**하라. 중간은 위험하다.
**A.**
**일반적 오해**: "Lock-free = 빠름, Mutex = 느림".
**현실**: **Depends**. 워크로드, 경합 수준, 구현에 따라 다름.
**Lock-free의 약속**:
1. **Deadlock 없음**: 락 없으니 dead할 수 없음.
2. **Priority inversion 없음**: 우선순위 낮은 스레드가 높은 것을 block 안 함.
3. **Signal-safe**: 인터럽트에서 사용 가능.
4. **Progress guarantee**: 최소 하나는 진행.
**Lock-free의 숨은 비용**:
**1. Atomic operation 자체의 비용**:
일반 increment: 1 cycle
Atomic increment: 5-20 cycles (uncontended)
Atomic increment (contended): 50-500 cycles (cache miss + MESI traffic)
Uncontended lock-free는 빠르지만, **contended**면 cache line ping-pong으로 극도로 느려진다.
**2. Retry loops**:
Lock-free 알고리즘은 보통 **CAS + 재시도**:
do {
old = load(ptr);
new = compute(old);
} while (!cas(ptr, old, new));
경합이 심하면 **CAS가 계속 실패**. 많은 재시도. CPU 낭비.
**3. Memory reclamation 복잡성**:
누가 언제 메모리를 해제할까? 여러 스레드가 참조 중일 수 있음. 해결책들:
- Hazard pointer: 복잡.
- Epoch-based reclamation: 주기적 cleanup.
- RCU: 특수 패턴.
모두 **추가 오버헤드와 복잡도**.
**4. False sharing 위험**:
Atomic 변수들이 같은 cache line에 있으면 성능 파괴. Padding 필요.
**Mutex의 숨은 이점**:
**1. Queue-based fairness**:
좋은 mutex 구현 (FIFO mutex, ticket lock)은:
- 공정한 순서.
- 경합 시 **효율적 대기** (futex: kernel queue).
- CPU 낭비 없음 (잠들어 있음).
**2. OS 통합**:
Linux futex: 경합이 있으면 kernel sleep, 풀리면 wake up. CPU 시간 낭비 없음.
**3. 단순성**:
Mutex 기반 코드는 **읽기 쉽고 검증하기 쉽다**. 버그가 적음.
**벤치마크로 본 실제**:
**Scenario 1: Low contention (1~2 threads)**
Counter increment (1M times):
- Mutex: 30 ms
- std::atomic<int>: 10 ms
- Custom lock-free: 8 ms
Lock-free가 3배 빠름. Atomic primitive만 사용.
**Scenario 2: Medium contention (8 threads)**
Concurrent queue (1M ops):
- Mutex-based queue: 100 ms
- Lock-free queue (Michael-Scott): 60 ms
Lock-free가 40% 빠름.
**Scenario 3: High contention (32 threads)**
Increment shared counter (1M × 32 threads):
- Mutex: 800 ms
- std::atomic: 1200 ms ← 더 느림!
- Per-thread counter + aggregate: 50 ms ← 압도적
**왜 atomic이 더 느린가**:
- 32 스레드가 같은 cache line을 동시에 수정.
- 매 atomic 연산이 cache line invalidation.
- Cache line이 코어 사이를 계속 이동.
- 실제로는 **직렬화**되는데 overhead가 더 큼.
**Mutex는 경합 시 잠들기** 때문에 오히려 나을 수 있다.
**교훈**: **알고리즘 설계가 primitive 선택보다 중요**. Per-thread counter는 단순 atomic보다 10배 빠르다.
**Scenario 4: Very high contention + short critical section**
Increment with tiny critical section:
- Spin lock: 가장 빠름
- Mutex: 보통
- Lock-free: 느림 (재시도 많음)
Critical section이 수 nanosecond면 **spin lock이 이김**.
**Scenario 5: Long critical section**
- Mutex: 빠름 (잠들기)
- Spin lock: 느림 (CPU 낭비)
- Lock-free: 중간 (복잡)
Critical section이 수 μs+면 mutex가 낫다 (CPU 절약).
**결론: When to use what**:
**Lock-free 선호**:
- **Uncontended 또는 low contention** 워크로드.
- **Priority inversion** 걱정.
- **Signal handler**에서 공유.
- **Low latency** (꼬리 지연 중요).
- **성능이 증명되면**.
**Mutex 선호**:
- **Simple and correct** 원하면.
- **Long critical section**.
- **High contention with sleeping 허용**.
- **표준 동시성 패턴**.
**Alternative approaches**:
종종 더 나은 선택:
1. **Immutable data structures**: 수정 대신 복사.
2. **Per-thread data + aggregation**: 접촉 최소화.
3. **Message passing (Actor model)**: 공유 자체 회피.
4. **Read-write locks**: 많은 reader + 적은 writer.
5. **Optimistic concurrency**: 버전 기반.
**실전 권장**:
1. **측정하라**: 추측하지 말고 벤치마크.
2. **Profile부터**: 정말 이 부분이 병목인가?
3. **가장 단순한 것부터**: Mutex부터 시작.
4. **검증된 라이브러리**: 자체 lock-free 구현 금물.
5. **Data layout 우선**: False sharing 제거가 알고리즘 선택보다 중요할 수 있음.
**검증된 lock-free 라이브러리**:
- **C++**: boost::lockfree, folly.
- **Rust**: crossbeam.
- **Java**: java.util.concurrent.
- **Go**: sync package (goroutine 기반 alternative).
**교훈**:
**"Lock-free is faster"는 마케팅**이다. 현실은 더 복잡:
- Uncontended: lock-free 유리.
- Low contention: lock-free 약간 유리.
- Medium contention: 비슷.
- High contention: 알고리즘 설계가 중요 (둘 다 느릴 수 있음).
- Very high contention: **공유 자체를 피하라**.
정답: **측정 + 문제에 맞는 선택**. 독단적 주장 (lock-free 만세, mutex 나빠)은 경험 부족의 표시다.
**Martin Thompson의 금언**: "**Mechanical sympathy**". 하드웨어를 이해하면 어떤 알고리즘을 선택할지 명확해진다. Lock-free는 도구이지 만능이 아니다.
마치며: 하드웨어의 숨은 언어
핵심 정리
1. **Cache line 64 바이트**: 모든 것의 단위.
2. **NUMA**: 멀티 소켓의 비대칭. 무시하면 30% 손실.
3. **MESI**: 하드웨어 동기화의 수학.
4. **False sharing**: 10배 느려질 수 있는 숨은 함정.
5. **Memory ordering**: 순서는 환상, barrier가 필요.
6. **Atomic**: 빠르지만 contention 시 폭발.
7. **Lock-free**: 항상 답은 아님.
언제 이 지식이 필요한가
**필수**:
- Lock-free 자료구조 구현.
- 데이터베이스 엔진.
- 고성능 네트워크 스택.
- 게임 엔진.
- HFT (고빈도 거래).
- OS 커널.
**유용**:
- 대규모 서버 튜닝.
- JVM 성능 최적화.
- Rust/C++ 멀티스레드 코드.
- NUMA 환경.
**불필요**:
- 단일 스레드 앱.
- 일반 CRUD 백엔드.
- 스크립트 언어 앱.
마지막 교훈
하드웨어를 아는 프로그래머와 모르는 프로그래머의 차이는 **극단적 성능 차이**로 나타난다. 10배, 100배가 드물지 않다.
그러나 이 지식은 **독이 될 수 있다**. 조기 최적화, 불필요한 복잡성, 검증 안 된 lock-free 코드. 규칙:
1. **먼저 측정하라**. 병목이 정말 여기인가?
2. **알고리즘이 먼저**. O(N^2) → O(N log N)이 하드웨어 trick보다 낫다.
3. **단순함이 기본**. Mutex로 시작, lock-free는 증명된 필요 시만.
4. **False sharing 체크**. 가장 흔한 숨은 이슈.
5. **측정 반복**. 환경, 코드 변경 시마다.
당신이 **진짜 성능 엔지니어**가 되고 싶다면, 이 지식은 **선택이 아니라 필수**다. Cache line, MESI, memory barrier — 이들이 당신의 도구가 될 때, 하드웨어가 당신과 말하기 시작한다.
그리고 그 순간 **"왜 이 코드가 느리지?"** 의 답이 보인다. 보이지 않던 것이 보이게 되는 경험, 그것이 엔지니어링의 가장 큰 보상 중 하나다.
참고 자료
- [What Every Programmer Should Know About Memory (Ulrich Drepper, 2007)](https://akkadia.org/drepper/cpumemory.pdf) - 이 분야의 바이블
- [Intel 64 and IA-32 Architectures Software Developer's Manual Vol 3A](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html)
- [ARM Architecture Reference Manual](https://developer.arm.com/documentation/)
- [Memory Barriers: a Hardware View for Software Hackers (Paul McKenney)](http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf)
- [C++ Memory Model (cppreference)](https://en.cppreference.com/w/cpp/atomic/memory_order)
- [Martin Thompson: Mechanical Sympathy](https://mechanical-sympathy.blogspot.com/)
- [Herb Sutter: Atomic Weapons (talks)](https://herbsutter.com/2013/02/11/atomic-weapons-the-c-memory-model-and-modern-hardware/)
- [Linux NUMA documentation](https://www.kernel.org/doc/html/latest/vm/numa.html)
- [LMAX Disruptor Technical Paper](https://lmax-exchange.github.io/disruptor/files/Disruptor-1.0.pdf)
- [Intel VTune Profiler](https://www.intel.com/content/www/us/en/developer/tools/oneapi/vtune-profiler.html)
- [perf c2c: False sharing detection](https://joemario.github.io/blog/2016/09/01/c2c-blog/)
현재 단락 (1/1038)
당신의 코드를 단일 코어에서 측정했다: **1 million ops/sec**. 16코어 서버로 옮기면?