QA 컴퓨터구조 심화
Chapter 5

스레드 수준 병렬성

여러 코어가 같은 방을 쓸 때 필요한 예절과 감시 카메라

클럭을 올리고, 파이프라인을 깊게 하고, 명령어 수준 병렬성을 긁어모으던 시대는 어느 순간 벽을 만났습니다. 벽의 이름은 전력, 발열, 복잡도입니다. 그래서 아키텍처는 방향을 틀었습니다. 한 명의 천재 코어를 더 몰아붙이는 대신, 적당히 똑똑한 코어 여러 개가 일을 나눠 하게 만들자. 이것이 스레드 수준 병렬성의 출발점입니다.

큰 그림

멀티코어의 핵심 질문은 “코어를 몇 개 넣을 수 있나”가 아닙니다. 진짜 질문은 “그 코어들이 데이터를 공유하면서도 서로 발목을 잡지 않게 할 수 있나”입니다. 성능 문제처럼 보이지만, 절반은 질서 문제입니다.

이 장의 지도
  1. 스레드 수준 병렬성의 자리
  2. 멀티코어와 공유 메모리
  3. 동기화: 빠른데 안전해야 한다
  4. 캐시 일관성: 같은 주소, 같은 값?
  5. Snooping과 Directory
  6. 메모리 일관성 모델
  7. False Sharing
  8. 정리

스레드 수준 병렬성의 자리

병렬성에는 여러 층이 있습니다. 벡터와 GPU가 같은 연산을 많은 데이터에 뿌리는 데 능하다면, 스레드 수준 병렬성은 서로 다른 실행 흐름을 동시에 굴립니다. 웹 서버는 요청마다 스레드를 나눌 수 있고, 데이터베이스는 질의 처리와 로그 기록과 백그라운드 정리를 동시에 합니다. 한 작업을 잘게 쪼갤 수도 있고, 애초에 독립적인 작업들을 여러 코어에 배치할 수도 있습니다.

문제는 병렬화 가능한 부분만 빨라진다는 점입니다. 암달의 법칙은 여기서도 냉정합니다. 코어를 64개 넣어도 직렬 구간, 락 대기, 캐시 미스, 메모리 대역폭 병목이 남으면 성능 그래프는 어느 순간 얌전해집니다. 코어 수가 늘수록 “계산”보다 “조율”이 더 비싸지는 순간이 옵니다.

Speedup(N) = 1 / (Serial + Parallel / N + Overhead(N))

Serial      : 병렬화되지 않는 부분
Parallel    : 나눠 처리할 수 있는 부분
Overhead(N) : 락, 통신, 캐시 일관성, 스케줄링 비용
감 잡기

코어를 늘리는 일은 조별 과제 인원을 늘리는 일과 비슷합니다. 처음엔 빨라집니다. 그러다 어느 순간부터 문서 형식 맞추기, 최종본 합치기, 누가 최신 파일을 들고 있는지 확인하기가 본업이 됩니다.

멀티코어와 공유 메모리

공유 메모리 멀티프로세서는 모든 코어가 하나의 주소 공간을 보는 것처럼 프로그래밍하게 해줍니다. 개발자 입장에서는 편합니다. 포인터 하나를 넘기면 다른 스레드도 같은 자료구조를 볼 수 있으니까요. 하지만 하드웨어 입장에서는 이 약속이 꽤 빡셉니다. 각 코어에는 자기 캐시가 있고, 같은 주소의 복사본이 여러 캐시에 동시에 존재할 수 있습니다.

구조 장점 어려운 점
공유 메모리 프로그래밍 모델이 직관적이고 자료구조 공유가 쉬움 동기화, 캐시 일관성, 메모리 일관성 비용
메시지 패싱 소유권과 통신 경로가 비교적 명확함 데이터 분할과 통신 코드를 직접 설계해야 함
하이브리드 노드 안은 공유 메모리, 노드 사이는 메시지로 확장 두 세계의 복잡도를 모두 이해해야 함

칩 안에서는 보통 공유 메모리 모델이 강합니다. 코어마다 L1/L2가 있고, 더 큰 L3를 공유하거나 여러 조각으로 나눠 배치합니다. 주소 하나를 읽는 일도 실제로는 태그 확인, 캐시 라인 상태 확인, 다른 코어와의 협상, 메모리 접근이 뒤섞인 작은 외교전입니다.

동기화: 빠른데 안전해야 한다

병렬 프로그램에서 가장 흔한 사고는 두 스레드가 같은 데이터를 동시에 건드릴 때 생깁니다. 은행 계좌 잔액을 두 스레드가 동시에 읽고 각각 1만 원씩 더한 뒤 다시 쓰면, 결과가 2만 원 증가가 아니라 1만 원 증가로 끝날 수 있습니다. 이건 산수가 틀린 게 아니라 순서가 틀린 겁니다.

Thread A: r1 = balance
Thread B: r2 = balance
Thread A: balance = r1 + 10000
Thread B: balance = r2 + 10000

결과: 두 번 입금했는데 한 번만 반영될 수 있음

그래서 락, 세마포어, 조건 변수, 원자적 명령어가 등장합니다. 하드웨어는 보통 test-and-set, compare-and-swap, load-linked/store-conditional 같은 원자적 연산을 제공합니다. 이 연산들은 “읽고 판단하고 쓰는” 짧은 구간을 쪼갤 수 없는 하나의 동작처럼 보이게 만듭니다.

핵심 원칙

동기화는 정확성을 사는 비용입니다. 락이 너무 크면 병렬성이 죽고, 락이 너무 작으면 설계가 복잡해집니다. 좋은 병렬 코드는 락을 없애는 코드가 아니라, 공유해야 할 것을 줄이고 공유하는 순간을 짧게 만드는 코드입니다.

캐시 일관성: 같은 주소, 같은 값?

코어 A와 코어 B가 같은 변수 x를 읽습니다. 둘 다 자기 캐시에 x = 0을 들고 있습니다. 이제 A가 x = 1을 씁니다. B가 다시 x를 읽을 때 0을 보면 곤란합니다. 프로그래머는 같은 주소라면 최신 값을 기대합니다. 캐시 일관성은 바로 이 기대를 어느 정도 만족시키는 장치입니다.

일관성이 보장하려는 두 가지 감각

실제 프로토콜은 캐시 라인 단위로 움직입니다. 대표적으로 MSI, MESI, MOESI 같은 상태 기계가 있습니다. 라인이 수정되었는지, 여러 캐시에 공유 중인지, 독점적으로 들고 있는지에 따라 읽기와 쓰기의 행동이 달라집니다.

상태 느낌
Modified 이 캐시가 최신 값을 갖고 있고 메모리는 낡았음 내가 원본이다
Exclusive 깨끗한 복사본을 나만 갖고 있음 혼자 조용히 읽는 중
Shared 여러 캐시가 같은 깨끗한 복사본을 갖고 있음 다 같이 보는 공지문
Invalid 이 복사본은 더 이상 믿으면 안 됨 폐기된 메모

Snooping과 Directory

캐시 일관성을 유지하는 방법은 크게 두 계열로 볼 수 있습니다. 작은 공유 버스 기반 시스템에서는 snooping이 자연스럽습니다. 모든 캐시가 버스를 엿보다가, 누가 어떤 주소를 읽거나 쓰는지 보고 자기 라인 상태를 바꿉니다. 이름은 조금 수상하지만 하는 일은 단순합니다. 모두가 같은 공지 채널을 듣는 구조입니다.

코어 수가 늘면 모두가 모든 일을 듣는 방식은 시끄러워집니다. 그래서 directory 방식이 등장합니다. 각 메모리 블록마다 “누가 이 라인을 들고 있는가”를 기록해 두고, 필요한 캐시에만 무효화나 갱신 메시지를 보냅니다. 방송에서 주소록 기반 우편으로 바뀌는 셈입니다.

방식 잘 맞는 규모 장점 부담
Snooping 소수 코어, 공유 버스 단순하고 지연이 낮음 방송 트래픽과 버스 확장성 한계
Directory 많은 코어, 분산 메모리/NoC 필요한 곳에만 메시지를 보냄 디렉터리 저장 공간과 추가 조회 비용
쓰기 미스의 전형적 흐름
1. 코어 A가 라인 X를 쓰고 싶어 함
2. 프로토콜이 다른 복사본을 찾음
3. 다른 캐시의 X를 Invalid로 만들거나 최신 값을 회수
4. A가 X를 Modified 상태로 만들고 쓰기 진행

메모리 일관성 모델

캐시 일관성이 “한 주소에 대한 값”의 문제라면, 메모리 일관성은 “여러 주소에 대한 연산 순서”의 문제입니다. 단일 코어에서는 프로그램 순서가 비교적 자연스럽게 느껴집니다. 하지만 멀티코어에서는 컴파일러, 코어, 저장 버퍼, 캐시가 성능을 위해 읽기와 쓰기의 관찰 순서를 바꿔 보이게 할 수 있습니다.

초기값: x = 0, y = 0

Thread 1: x = 1; r1 = y;
Thread 2: y = 1; r2 = x;

질문: r1 == 0 이고 r2 == 0 인 결과를 허용할 것인가?

순차 일관성은 모든 연산이 어떤 하나의 전역 순서로 끼워 맞춰지고, 각 스레드 안의 순서도 지켜진다고 보는 가장 직관적인 모델입니다. 하지만 이 모델은 하드웨어 최적화의 손발을 묶습니다. 그래서 실제 ISA들은 더 느슨한 모델을 택하고, 프로그래머에게 fence, acquire, release 같은 도구를 줍니다. 성능을 위해 평소에는 느슨하게, 중요한 문 앞에서는 줄을 세우는 방식입니다.

체크포인트

캐시 일관성과 메모리 일관성을 섞어 생각하면 머리가 아파집니다. 전자는 “같은 주소의 복사본들이 모순되지 않게”, 후자는 “여러 주소의 읽기/쓰기가 어떤 순서로 보이는지”를 다룹니다.

False Sharing

false sharing은 멀티코어 성능의 얄미운 함정입니다. 두 스레드가 서로 다른 변수를 쓰는데, 하필 그 변수들이 같은 캐시 라인에 들어 있으면 어떻게 될까요? 논리적으로는 공유하지 않지만 하드웨어는 캐시 라인 단위로 일관성을 유지합니다. 한쪽이 쓰면 다른 쪽의 라인이 무효화되고, 반대쪽이 쓰면 다시 무효화됩니다. 데이터는 따로 노는데 캐시 라인이 같이 산다는 이유로 계속 싸웁니다.

struct Counters {
  long a;  // Thread 1이 주로 갱신
  long b;  // Thread 2가 주로 갱신
};

// a와 b가 같은 64바이트 캐시 라인에 있으면
// 두 스레드는 서로 다른 변수를 써도 라인을 계속 빼앗는다.

해결은 보통 패딩, 정렬, 데이터 배치 변경입니다. 자주 갱신되는 스레드별 카운터는 캐시 라인 경계에 맞춰 떨어뜨려 놓고, 마지막에 합산합니다. 알고 보면 고급 최적화가 아니라 자리 배치 문제입니다. 회의실에서 서로 팔꿈치가 닿지 않게 앉히는 일과 닮았습니다.

정리

스레드 수준 병렬성은 현대 성능의 필수 축이지만, 공짜로 오지 않습니다. 코어를 늘리면 계산 능력은 늘지만 공유 데이터, 동기화, 캐시 일관성, 메모리 순서, 대역폭 병목도 같이 커집니다. 좋은 멀티코어 설계는 빠른 코어의 모음이 아니라, 코어들이 다투지 않고 협력하게 만드는 규칙의 묶음입니다.

하드웨어와 소프트웨어의 악수

하드웨어는 원자적 명령어와 일관성 프로토콜을 제공하고, 소프트웨어는 공유를 줄이고 동기화를 명확히 해야 합니다. 둘 중 하나만 잘해도 부족합니다. 병렬성은 혼자 이기는 게임이 아니라 인터페이스를 잘 맞추는 게임입니다.