멀티스레드는 더 많은 코어가 아니라
더 적은 공유 write로 빨라집니다.

멀티스레드에서 제일 흔한 착각이 있습니다.

atomic이 느린 이유 = 락(lock) 때문

 

아니요. 많은 경우 atomic이 느린 이유는 락이 아니라,
캐시 라인 소유권(ownership) 경쟁 때문에 생기는 coherency 트래픽입니다.

3편에서 본 규칙을 그대로 가져오면 됩니다.

  • read는 공유(S)로 버틸 수 있지만
  • write는 독점(M/E)을 요구하고
  • 누가 write를 시작하면 다른 코어의 라인은 invalidate 된다

atomic은 여기서 한 단계 더 빡셉니다.
atomic은 보통 RMW(Read-Modify-Write) 이기 때문입니다.

Write 1번으로 캐시 라인 소유권 전쟁이 시작한다.

1) atomic이 비싼 진짜 이유: RMW는 라인 독점을 강제한다

atomic_fetch_add 같은 연산을 생각해봅시다.

  • 값을 읽고
  • 수정하고
  • 다시 쓴다

이 3단계를 중간에 끼어들지 못하게 보장해야 합니다.
그래서 하드웨어는 대체로 다음을 요구합니다.

해당 캐시 라인을 내가 독점(M/E)해야만 RMW를 수행할 수 있다.

 

문제는 여러 코어가 같은 atomic을 동시에 건드릴 때 터집니다.

  • Core0가 라인 독점 → Core1은 invalidate
  • Core1이 라인 독점 → Core0는 invalidate
  • 결과: 라인이 ping-pong (False Sharing과 구조가 동일)

여기서 중요한 결론:

atomic 경쟁(contended atomic)은 연산 비용이 아니라 라인 이동 비용으로 느려진다.

 

그래서 스레드를 늘리면 오히려 느려지는 현상이 나옵니다.
CPU가 일을 더 하는 게 아니라, 서로 밀어내느라 시간을 씁니다.


2) Uncontended atomic vs Contended atomic (진단 기준)

atomic이 항상 나쁜 건 아닙니다. 구분이 필요합니다.

Uncontended atomic (경쟁 없음)

  • 한 코어만 해당 atomic을 자주 업데이트
  • 또는 업데이트 빈도가 낮아서 충돌이 거의 없음

이 경우는 대체로 괜찮습니다.

Contended atomic (경쟁 있음)

  • 여러 스레드가 같은 atomic(같은 캐시 라인)을 고빈도로 RMW
  • 대표: 전역 카운터, 전역 큐 인덱스, 글로벌 통계, 글로벌 work counter

이 경우는 거의 확실히 병목입니다.
락이 없어도 느립니다. (락이 아니라 coherency 경쟁이니까)


3) 처방의 방향은 단 하나: 공유 write-hot을 없애라

4편의 실전 처방은 여기로 수렴합니다.

  1. 공유 write-hot을 스레드 로컬로 분해
  2. 필요한 순간에만 합친다(reduction)
  3. 합치는 지점도 가능하면 저빈도 / 배치(batch)로 만든다

즉, atomic을 잘 쓰는 법이 아니라

atomic을 ‘뜨거운 루프에서’ 치워버리는 법

이 핵심입니다.


4) 처방 1: per-thread counter + reduction (가장 강력하고 가장 흔함)

나쁜 예: 모든 스레드가 전역 atomic++

std::atomic<uint64_t> gCount = 0;
void Worker()
{
	for (...)
    {
    	gCount.fetch_add(1, std::memory_order_relaxed);
    }
}

이 코드는 연산이 아니라 라인 소유권 ping-pong으로 느려집니다.

좋은 예: 스레드 로컬 누적 + 마지막 합산(reduction)

std::atomic<uint64_t> gCount = 0;
void Worker()
{
	uint64_t local = 0;
    for (...)
    {
    	local++;
    }
    gCount.fetch_add(local, std::memory_order_relaxed); // 빈도 1회
}

핵심은 fetch_add 횟수를 줄이는 게 아닙니다.
라인 소유권 경쟁을 루프 밖으로 빼는 것입니다.

전역 atomic은 루프마다가 아니라 배치로 접근하라.


5) 처방 2: per-thread data layout (스레드별 write-hot을 구조적으로 분리)

reduction이 항상 가능한 건 아닙니다.
큐 인덱스, 작업 분배, 스레드 상태처럼 실시간으로 공유해야 하는 값이 존재합니다.

이때는 최소한 이렇게 해야 합니다.

서로 다른 스레드가 쓰는 데이터가 같은 캐시 라인에 들어가지 않게 한다.

 

즉, 2편에서 다룬 alignas(64)의 진짜 목적이 여기서 다시 등장합니다.

패턴: thread slot을 캐시 라인 단위로 고정

 
struct alignas(64) ThreadSlot
{
	uint64_t counter; // padding은 컴파일러/플랫폼에 따라 달라질 수 있음
    char pad[64 - sizeof(uint64_t)];
};

ThreadSlot slots[MAX_THREADS];
void Worker(int tid)
{
	for (...)
    {
    	slots[tid].counter++; // 각자 자기 라인만 건드림
    }
}

이 설계는 속도 최적화라기보다
coherency 트래픽 최악 케이스 제거(안정화)입니다.


6) 처방 3: 큐/링버퍼는 head/tail(메타데이터)을 분리하라

Lock-free 큐에서 흔한 병목은 알고리즘이 아니라 메타데이터 배치입니다.

  • head와 tail이 같은 캐시 라인에 있으면
  • producer/consumer가 서로 다른 변수를 업데이트해도
  • 같은 라인을 두고 ping-pong이 납니다 (False Sharing)

원칙

  • head와 tail을 다른 캐시 라인으로 분리
  • size/flags 같은 write-hot 메타데이터도 분리

이건 구조가 복잡해지기 전에 적용할수록 효과가 큽니다.


7) atomic을 써야 한다면: 최소한 이 관점으로 사용해라

atomic을 완전히 없앨 수 없는 경우도 있습니다.
그럴 때의 원칙은 단순합니다.

(1) hot loop에서 atomic을 빼라

  • 루프 안에서 1회를 루프 밖에서 1회로 바꿔라 (batch)

(2) 공유 write-hot을 줄여라

  • write 빈도를 낮추거나, 스레드별로 나누고 합쳐라

(3) 같은 라인을 두 스레드가 건드리지 않게 배치하라

  • per-thread slot
  • alignas(64) 또는 명시적 padding

여기까지 하면 atomic이 락처럼 느려지는 대부분의 상황은 정리됩니다.


8) 체크리스트: 이 조건이면 atomic 병목을 의심해라

  • 스레드를 늘렸는데 throughput이 거의 안 오르거나 떨어진다
  • 전역 카운터/통계/큐 인덱스가 hot path에 있다
  • 프로파일에서 모두가 같은 위치를 건드린다는 냄새가 난다
  • 락은 없는데도 CPU가 바쁘고 일이 안 끝난다

이때는 먼저 묻는 게 맞습니다.

“나는 지금 연산을 하고 있나, 아니면 캐시 라인 소유권을 주고받고 있나?”


마무리: 멀티스레드 최적화의 본질

시리즈를 4편까지 오면 결론은 선명해집니다.

  • 1편: CPU는 연산보다 물류(메모리/캐시)가 문제다
  • 2편: 멀티코어는 shared cache line 때문에 무너진다 (False Sharing)
  • 3편: Invalidate는 감이 아니라 MESI 규칙으로 발생한다
  • 4편(오늘): atomic의 비용은 락이 아니라 라인 소유권 경쟁이다

그래서 실전 처방도 단순합니다.

  1. 공유 write-hot을 없애라
  2. 스레드별로 누적하고 마지막에 합쳐라(reduction)
  3. 어쩔 수 없이 공유하면 라인을 분리하라(alignas(64), layout)

이전글

 

Invalidate를 줄이는 법: MESI로 이해하는 캐시 라인 소유권 경쟁

다른 코어가 같은 캐시 라인을 가진 상태에서,누군가 그 라인을 write(특히 RMW)로 독점하려는 순간.Invalidate가 터진다.우리는 False Sharing을 캐시 라인 ping-pong으로 봤습니다.그런데 여기서 한 단계

chessire.tistory.com

 

다른 코어가 같은 캐시 라인을 가진 상태에서,
누군가 그 라인을 write(특히 RMW)로 독점하려는 순간.
Invalidate가 터진다.

우리는 False Sharing을 캐시 라인 ping-pong으로 봤습니다.
그런데 여기서 한 단계 더 정확해져야 해요.

Invalidate는 캐시 미스가 아니라, 쓰기 권한(소유권) 경쟁 때문에 발생한다.

 

즉, 멀티코어에서 느려지는 이유는 데이터가 멀리 있어서가 아니라
누가 그 캐시 라인을 ‘쓰기 가능한 최신 상태’로 갖고 있느냐를 맞추느라 트래픽이 터지기 때문입니다.

이걸 설명하는 최소 모델이 MESI입니다.


0) MESI를 한 문장으로 정의

캐시 라인(보통 64B)마다 CPU는 “이 라인이 지금 어떤 상태인가”를 관리합니다.

  • M (Modified): 내가 수정했고(Dirty), 최신. 다른 코어는 못 가짐
  • E (Exclusive): 나만 가지고 있고 최신. 아직 수정은 안 했음
  • S (Shared): 여러 코어가 읽기용으로 공유 중
  • I (Invalid): 무효(없다고 봐도 됨)

여기서 핵심은 딱 하나:

쓰기(write)는 ‘독점(Exclusive)’ 상태를 요구한다.
그래서 누가 쓰려고 하면, 다른 코어의 같은 라인은 Invalidate(I) 된다.


1) 읽기만 하면 보통 큰 싸움이 없다 (Read / Read)

두 코어가 같은 주소 X를 읽기만 한다고 하자.

  • Core0: X를 읽음 → E 또는 S (상황에 따라)
  • Core1: X를 읽음 → 둘 다 S로 수렴

여기서는 보통 Invalidate 폭발이 없습니다.
왜냐하면 읽기는 최신성만 맞으면 되고, 독점 소유권이 필요 없기 때문입니다.

결론: Read-mostly 공유는 비교적 안전하다.


Write 독점 경쟁 → Invalidate → Ping-Pong (MESI)

2) 누군가 쓰기를 하는 순간부터 Invalidate가 시작된다 (Read / Write)

이제 Core1이 X를 write 한다고 하자. (X++ 같은 수정)

이미 Core0이 X를 S 상태로 갖고 있었다면?

  • Core1은 쓰기 위해 X를 독점 상태(M / E)로 만들어야 함
  • 그래서 Core0의 X는 I(Invalid) 로 바뀜

즉, 규칙은 간단합니다.

다른 코어가 같은 캐시 라인을 가지고 있는데, 누군가 write를 하려는 순간 → Invalidate 발생

 

이때 Core0는 내 캐시에 분명히 있었는데(I로 바뀌어서) 다시 가져와야 하고,
이게 Coherency Miss로 관측됩니다.


3) 최악은 Write / Write: 소유권이 공처럼 튕긴다 (Ping-Pong)

False Sharing이 터지는 대표 상황이 이거죠.

  • Core0: 라인 X의 어떤 필드(변수 A)를 계속 write
  • Core1: 같은 라인 X의 다른 필드(변수 B)를 계속 write

둘은 논리적으로 다른 변수지만, 캐시 라인이 같으면 같은 전쟁입니다.

흐름은 이렇게 됩니다.

  1. Core0 write → X를 M로 만든다 (Core1 쪽 X는 I)
  2. Core1 write → X를 M로 만들려 한다 → Core0 쪽 X는 I
  3. Core0 write → 다시 독점 필요 → Core1 쪽 I
    … 무한 반복

여기서 중요한 포인트:

  • 값 자체의 충돌이 없어도
  • 캐시 라인 소유권 때문에
  • Invalidate가 매번 발생한다.

결론: False Sharing은 shared data가 아니라 shared cache line 문제다.


4) 왜 RMW(atomic)이 특히 비싼가: 독점 + 순서를 강제한다

다음 편에서 atomic을 깊게 다루겠지만, 원리만 여기서 박아두면 이렇습니다.

atomic_fetch_add 같은 연산은 단순 write가 아니라 Read-Modify-Write입니다.

  • 값을 읽고
  • 수정하고
  • 다시 쓰는 작업

이건 의미상 중간에 다른 코어가 끼면 안 되기 때문에,
하드웨어는 보통 해당 라인을 더 강하게 독점하려고 합니다.

즉, 경쟁 상황에서 atomic은 이렇게 동작해요.

  • 여러 코어가 동일 라인에 대해 RMW를 시도
  • 각 코어가 “내가 지금 독점해서 처리해야 함”을 강제
  • 결과적으로 invalidate + 소유권 이동이 더 자주, 더 비싸게 발생

그래서 atomic 비용을 락이니까 느리다고만 보면 진단이 반쯤 틀립니다.

atomic의 핵심 비용은 락이 아니라, 캐시 라인 소유권 경쟁(= coherency 트래픽)이다.

 

이게 4편의 주제가 됩니다.


5) 이 규칙을 코딩 관점으로 번역하면

MESI를 외우는 목적은 상태도를 잘 그리자는 게 아닙니다.
코드를 이렇게 분류하기 위해서예요.

안전한 패턴(대체로)

  • read-mostly 공유 (초기화 후 읽기 전용)
  • 스레드마다 자기 데이터에 write (서로 다른 라인)

위험한 패턴(거의 확실히 터짐)

  • 여러 스레드가 같은 라인에 write
  • 서로 다른 변수라도 같은 캐시 라인이면 동일하게 위험
  • 경쟁 상황의 atomic RMW (특히 hot counter)

여기서 2편에서 말한 alignas(64)가 다시 의미를 갖습니다.

  • alignas(64)는 단순히 빠르게 하는 것이 아니라
  • write-hot 데이터가 같은 캐시 라인을 공유하지 않게 하는 장치
  • 즉 Invalidate 규칙을 회피하는 설계입니다.

마무리

여기까지 정리하면, 멀티스레드에서 성능이 무너지는 이유는 CPU가 느려서가 아닙니다.

대부분은 캐시 라인(64B) 소유권을 두고 벌어지는 규칙적인 싸움입니다.

  • 읽기(Read)는 공유(S)로 공존할 수 있지만
  • 쓰기(Write)는 독점(M/E)을 요구하고
  • 그 순간 다른 코어의 동일 라인은 Invalidate(I) 됩니다.
  • 이 Invalidate가 반복되면, 우리가 2편에서 본 ping-pong(= False Sharing)이 됩니다.

즉, False Sharing은 공유 데이터 문제가 아니라
공유된 캐시 라인(shared cache line) 문제입니다.

이제 다음 질문이 자연스럽게 남습니다.

“그럼 atomic은 왜 이렇게 비싼가? 락이 없는데도 왜 느려지나?”

 

답은 이미 보입니다. atomic은 단순 write가 아니라 RMW(Read-Modify-Write)이고,
RMW는 캐시 라인의 독점 소유권과 순서를 더 강하게 요구합니다.
그래서 병목은 락이 아니라 coherency 트래픽으로 나타납니다.

다음 편에서는 이 지점을 정확히 파고들어,

 

atomic의 비용을 락이 아니라 캐시 라인 소유권 경쟁으로 재정의하고

실전에서 바로 쓰는 per-thread data layout / reduction 패턴으로
멀티스레드 성능을 안정화하는 방법까지 마무리하겠습니다.

이전글

 

현대 CPU 최적화의 본질: 연산(ALU)이 아닌 메모리(LSU)

CPU는 연산보다데이터를 가져오는 방식에훨씬 민감하다.CPU 최적화를 연산을 줄이는 일로만 보면, 체감 성능이 잘 안 나옵니다. 실전 병목은 대개 계산(ALU)이 아니라 메모리 접근(Load/Store)에서 터

chessire.tistory.com

 

다음글

 

Atomic: 락이 아니라 캐시 라인 소유권 경쟁이다.

멀티스레드는 더 많은 코어가 아니라더 적은 공유 write로 빨라집니다. 멀티스레드에서 제일 흔한 착각이 있습니다.atomic이 느린 이유 = 락(lock) 때문 아니요. 많은 경우 atomic이 느린 이유는 락이

chessire.tistory.com

 

Coherency Miss(일관성 미스)로 보는
캐시 라인 전쟁과 해결 전략,
alignas(64)는 속도가 아니라
멀티코어 안정성이다.

싱글 스레드 최적화는 보통 캐시 히트율로 설명이 끝납니다.
하지만 멀티스레드에 들어가면 캐시 미스의 얼굴이 하나 더 늘어납니다.

Coherency Miss(일관성 미스).

이건 캐시에 없어서가 아니라, 다른 코어가 내 캐시 라인을 건드려서 내 캐시가 무효화(invalidate)되는 미스입니다. 즉, 데이터가 원래 캐시에 있었는데도, 멀티코어 환경 때문에 있었지만 없어진 것이 됩니다.


1) Coherency Miss란 무엇인가

멀티코어 CPU는 각 코어가 L1/L2 같은 빠른 캐시를 따로 들고 있습니다.
그럼 문제가 생겨요.

  • 코어 A가 어떤 메모리 주소 X를 캐시에 들고 있음
  • 코어 B도 같은 주소 X를 캐시에 들고 있음
  • 코어 B가 X를 쓰기(write) 하면?
  • 코어 A가 들고 있던 X는 더 이상 최신이 아님

그래서 CPU는 캐시 일관성 프로토콜(MESI 계열)로 누가 최신인지를 맞춥니다.
이때 흔히 일어나는 현상이:

  • invalidate(무효화): 다른 코어가 수정한 라인을 내 캐시에서 폐기
  • 다음에 내가 읽을 때는 다시 가져와야 함 → coherency miss

이게 Coherency Miss의 정체입니다.
핵심은 데이터가 멀리 있어서 느린 게 아니라, 서로 방해해서 느린 것입니다.


2) False Sharing: 같은 데이터를 공유한 적이 없는데 공유가 발생한다

False Sharing(거짓 공유)은 더 악질입니다.

두 스레드가 서로 다른 변수를 쓰는데도 느려져요.
왜냐하면 캐시는 변수 단위가 아니라 캐시 라인(보통 64B) 단위로 일관성을 맞추기 때문입니다.

예를 들어, 같은 캐시 라인 안에 아래 두 변수가 들어있다고 하자.

  • counterA (스레드 A가 계속 증가)
  • counterB (스레드 B가 계속 증가)

둘은 다른 변수고, 논리적으로는 공유가 아닙니다.
하지만 캐시 라인 단위로 보면 둘은 같은 64B 박스 안에 들어 있음.

그러면 벌어지는 일:

  1. 스레드 A가 counterA++
    → 해당 캐시 라인(64B)을 쓰기 가능한 최신 상태로 만들기 위해 독점화
  2. 스레드 B가 counterB++
    → 똑같은 캐시 라인을 독점화하려고 함
    → A 쪽 라인 invalidate
  3. A가 또 counterA++
    → 다시 라인 가져오고 invalidate 반복

이게 흔히 말하는 ping-pong입니다.
캐시가 공처럼 튕기면서 coherency 트래픽이 폭발하고, 성능이 급격히 떨어집니다.

즉, False Sharing은 데이터 공유가 아니라
캐시 라인 공유가 문제입니다.

False Sharing (공유된 캐시 라인의 비용)


3) 왜 alignas(64)는 속도가 아니라 안정성인가

많은 사람들이 alignas(64)를 빠르게 하려고 붙입니다.
하지만 멀티스레드에서 alignas(64)의 진짜 목적은 다른 쪽입니다.

서로 다른 스레드가 쓰는 데이터가 같은 캐시 라인에 들어가지 않게 보장하는 것.

 

즉, False Sharing을 구조적으로 차단하는 장치입니다.

여기서 속도라는 표현이 위험한 이유가 있어요.

  • 싱글 스레드에서 alignas(64)는 오히려 패딩이 늘어 캐시 효율을 떨어뜨릴 수 있음
  • 멀티스레드에서 alignas(64)는 패딩을 감수하더라도 coherency 폭발을 막아 성능을 안정화함

그래서 alignas(64)는 평균 성능 상승이 아니라
최악의 경우를 제거하는 안정성 장치에 가깝습니다.


4) 멀티스레드에서 캐시 라인을 분리/배치하는 실전 전략

아래는 현업에서 바로 적용하는 순서입니다.

(1) 쓰기를 분리하라: write-hot 데이터부터 격리

False Sharing은 거의 항상 write-write 또는 write-read에서 터집니다.
그래서 먼저 찾아야 하는 건:

  • 여러 스레드가 자주 쓰는 변수(카운터, 플래그, 상태값, work queue head/tail 등)

이런 변수는 각 스레드 전용 구조체로 분리하거나, 최소한 캐시 라인을 분리해야 합니다.

(2) Thread-local(스레드 로컬)로 바꾸고 마지막에 합쳐라

공유 카운터를 매번 증가시키는 대신:

  • 스레드별 로컬 카운터에 누적
  • 프레임 끝/작업 끝에 한 번 합산(reduction)

이건 캐시 최적화라기보다 coherency 최적화입니다.

(3) 구조체를 역할로 쪼개라: read-mostly vs write-hot

AoS/SoA와 같은 결로, 멀티스레드에서도 데이터는 성격이 갈립니다.

  • read-mostly(거의 안 변함): 공유해도 비교적 안전
  • write-hot(자주 변함): 공유하면 폭발

write-hot만 따로 빼서 alignas(64)를 주는 식으로,
전체 구조체에 무작정 패딩을 박지 않는 게 포인트입니다.

(4) 캐시 라인 패딩 패턴(실전 템플릿)

가장 흔한 패턴:

struct alignas(64) ThreadCounter
{
	std::atomic<uint64_t> value;
	char pad[64 - sizeof(std::atomic<uint64_t>)];
};

핵심은 변수 하나를 64B 박스 하나에 고정시키는 것.
이러면 서로 다른 스레드의 카운터가 같은 라인에 섞일 일이 없습니다.

(5) 큐/링버퍼는 헤더와 테일을 분리한다

멀티프로듀서/멀티컨슈머 구조에서 자주 터지는 포인트가:

  • head / tail
  • size / flags

이런 메타데이터가 같은 캐시 라인에 붙어있으면 False Sharing이 쉽게 납니다.
헤더와 테일을 다른 캐시 라인으로 분리하면 체감이 크게 바뀌는 경우가 많습니다.


5) 체크리스트: 이 증상이면 False Sharing을 의심해라

  • 스레드를 늘렸는데 성능이 안 오르거나 오히려 떨어진다
  • CPU 사용률은 높은데 처리량이 늘지 않는다
  • 특정 공유 카운터/플래그를 제거하면 성능이 갑자기 좋아진다
  • 동일 코드를 단일 스레드로 돌리면 안정적이다

이 경우는 연산 최적화가 아니라
coherency/false sharing 최적화를 먼저 의심하는 게 맞습니다.


마무리

Coherency Miss와 False Sharing은 캐시가 부족해서가 아니라
캐시가 서로 싸워서 생기는 성능 붕괴입니다.

그래서 alignas(64)는 “빠르게 만들기”가 아니라
멀티코어에서의 안정성(최악 제거)을 위한 설계 도구입니다.

다음글

 

현대 CPU 최적화의 본질: 연산(ALU)이 아닌 메모리(LSU)

CPU는 연산보다데이터를 가져오는 방식에훨씬 민감하다.CPU 최적화를 연산을 줄이는 일로만 보면, 체감 성능이 잘 안 나옵니다. 실전 병목은 대개 계산(ALU)이 아니라 메모리 접근(Load/Store)에서 터

chessire.tistory.com