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 0L1: variable x = 10
Core 1L1: variable x = 10

Core 0: x = 20
Core 0L1: x = 20
Core 1L1: 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+:

import sun.misc.Contended;

@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가 보장됨

releaseacquire의 쌍이 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: ABA로 두 번 수정
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).


퀴즈로 복습하기

Q1. False sharing이 왜 "같은 변수를 공유하지 않는데도" 성능을 파괴하는가?

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.ac.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을 보고, 전자는 보지 못한다.

Q2. MESI protocol의 네 가지 상태와 각 상태가 존재하는 이유는?

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:
- IBusRdCore 0 responds → Core 0: ES, Core 1: S

Core 2 reads X:
- IBusRdCore 0 or Core 1 responds → all: S

Write 시나리오:

[Core 0, 1S]

Core 0 writes X:
- SBusUpgrCore 1 invalidates → Core 0: M, Core 1: I

Core 1 reads X again:
- IBusRdCore 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:     IE (메모리에서)
Core 1 read:     IS (Core 0: ES)
Core 0 write:    SM (Core 1: SI)
Core 1 read:     IS (Core 0: MS, writeback)
Core 0 write:    SM (Core 1: SI)
...

두 코어가 번갈아 쓰기 → ping-pong. 이것이 false sharing의 근본 원리.

교훈:

MESI는 분산 시스템의 consistency를 하드웨어 수준에서 구현한 것이다. 소프트웨어 distributed system이 해결하는 문제 (consistency, ordering)를 CPU가 수 GHz 속도로 해결한다.

이 우아함이 현대 멀티코어의 기반이다. 모든 프로그래머가 이를 활용하지만 대부분 인식 못한다. 하드웨어가 해주는 마법을 인식하는 것이 엔지니어링의 깊이다.

Q3. NUMA에서 "메모리 locality가 무너지면" 왜 성능이 급락하는가?

A.

NUMA의 물리적 구조:

Socket 0                    Socket 1
┌──────────────┐            ┌──────────────┐
CPUs 0-15    │            │ CPUs 16-31L1/L2/L3     │            │ L1/L2/L3└──────┬───────┘            └──────┬───────┘
Memory ChannelMemory 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 ASocket 0에서 시작.
Thread A의 working set (10 GB)Socket 0 memory에 할당.
시간이 지나 Load balance → Thread ASocket 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다. 이것을 이해하는 것이 진짜 시스템 엔지니어링이다.

Q4. Memory barrier (fence)가 왜 필요하며 언제 사용해야 하는가?

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=truedata=42보다 먼저.

Out-of-order의 영향:

  • CPU가 ready = truedata = 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++의 volatilebarrier 아님:

  • 컴파일러 최적화 방지만.
  • 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만 사용하라. 중간은 위험하다.

Q5. 왜 lock-free 알고리즘이 항상 mutex 기반보다 빠르지는 않은가?

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 — 이들이 당신의 도구가 될 때, 하드웨어가 당신과 말하기 시작한다.

그리고 그 순간 "왜 이 코드가 느리지?" 의 답이 보인다. 보이지 않던 것이 보이게 되는 경험, 그것이 엔지니어링의 가장 큰 보상 중 하나다.


참고 자료

현재 단락 (1/1053)

당신의 코드를 단일 코어에서 측정했다: **1 million ops/sec**. 16코어 서버로 옮기면?

작성 글자: 0원문 글자: 29,876작성 단락: 0/1053