Skip to content

필사 모드: NUMA & Cache Coherence 완전 가이드 2025: MESI 프로토콜, False Sharing, Memory Ordering — 멀티코어 성능의 진짜 벽

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며: 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코어 서버로 옮기면?

작성 글자: 0원문 글자: 29,498작성 단락: 0/1038