Chapter 06

멀티코어와 캐시 일관성

두 사람이 같은 공책을 각자 복사해 들고 다닌다고 하자. 한 사람이 자기 사본에 "회의는 3시"를 "4시"로 고쳤는데, 다른 사람은 여전히 자기 사본을 보고 3시에 회의실에 간다. 스마트폰 SoC의 CPU 코어 여덟 개도 같은 처지다. 5장에서 본 대로 코어마다 L1·L2 캐시라는 사본을 들고 있기 때문이다. 이 장에서는 사본들이 서로 어긋나지 않게 하는 약속인 캐시 일관성(Cache coherence) 프로토콜을 직접 돌려 보고, 그것만으로는 해결되지 않는 더 미묘한 문제, 즉 메모리 순서까지 따라가 본다.

캐시가 둘이면 생기는 문제

코어 0과 코어 1이 공유 변수 X를 함께 쓴다. 각 코어의 캐시는 5장에서 본 나중 쓰기(write-back) 캐시다. 쓰기는 일단 자기 캐시에만 반영되고, 줄이 쫓겨날 때에야 메모리에 내려간다. 아래에서 버튼을 눌러 두 코어가 X를 읽고 1씩 올리게 해 보자. 먼저 "일관성 없음"으로 놓고 코어 0 쓰기 → 코어 1 읽기를 해 보자.

마지막으로 쓴 X (진짜 값)0
낡은 값을 읽은 횟수0
잃어버린 갱신0
버튼을 눌러 보자.
그림 6-1. 만져 보기"X+1"은 자기 캐시의 X를 읽어 1을 더해 쓰는 연산이다. 일관성이 없으면 코어 1은 메모리의 낡은 X를 읽고, 거기에 1을 더해 쓰면 코어 0이 올린 값이 사라진다(잃어버린 갱신). 프로토콜을 켜면 한 코어가 쓸 때 다른 캐시의 사본을 무효화하고, 읽기 미스 때 최신 값을 가진 캐시가 직접 데이터를 건넨다.

문제를 정확히 말하면 이렇다. 여러 사본이 있을 때 어떤 주소 하나에 대해 모든 코어가 같은 쓰기 순서를 보고, 읽기는 그 순서에서 가장 최근에 쓴 값을 돌려받아야 한다. 이 성질이 캐시 일관성이다. 흔히 두 가지 불변식으로 정리한다.

쓰기 전에 다른 사본을 모두 없애는 방식을 쓰기 무효화(Write-invalidate), 쓸 때마다 새 값을 다른 사본에 뿌리는 방식을 쓰기 갱신(Write-update)이라 한다. 같은 줄에 연달아 쓰는 일이 많아서 거의 모든 현대 CPU는 무효화 방식을 쓴다.

줄마다 붙는 상태표: MESI

무효화 프로토콜을 구현하려면 캐시의 줄마다 몇 비트짜리 상태를 붙인다. 가장 널리 알려진 것이 네 상태의 MESI다.

각 캐시 제어기는 자기 코어의 요청(PrRd, PrWr)과, 공유 버스에서 엿들은 다른 캐시의 요청(BusRd: 읽으려 함, BusRdX: 쓰려고 독점 사본을 원함)에 반응해 상태를 바꾼다. 이렇게 버스를 엿듣는 것을 스누핑(Snooping)이라 한다. 아래 상태 기계에 사건을 하나씩 넣어 보자.

이 줄의 상태I
내가 버스에 낸 요청0
메모리로 되쓴 횟수0
한 캐시의 한 줄을 지켜본다. 처음엔 I(사본 없음)다.
그림 6-2. 만져 보기원은 상태, 화살표는 가능한 전이다. 방금 일어난 전이가 진하게 표시되고 "사건 / 버스 동작"이 붙는다. "내가 읽기 → 내가 쓰기"를 MSI와 MESI에서 각각 해 보고 버스 요청 수를 비교하자. E 상태가 있으면 혼자 쓰는 데이터를 고칠 때 버스를 한 번 덜 쓴다.
M 상태인 줄에 대해 다른 코어가 읽기(BusRd)를 보냈다. 이 캐시는 무엇을 해야 할까?

메모리의 값은 낡았다는 점을 떠올리자.

답 보기

메모리 대신 자기가 최신 데이터를 내보내야 한다(Flush). 동시에 그 값을 메모리에도 써서 깨끗하게 만들고 S로 내려간다. 요청한 코어도 S로 받는다. 다른 캐시가 데이터를 직접 건네는 것을 캐시 간 전송(Cache-to-cache transfer)이라 한다. MOESI 프로토콜은 여기서 메모리 쓰기를 생략하고 O(Owned) 상태로 남아 "더티지만 공유 중인 사본의 주인" 역할을 맡는다. 다음 절의 시뮬레이터에서 확인해 보자.

멀티코어 일관성 시뮬레이터

이제 코어를 2~4개로 늘리고 줄도 A~D 네 개로 늘려 보자. 각 캐시 상자 안의 줄을 누르면 그 코어가 그 줄을 읽거나 쓴다. 아래 시나리오를 고르면 정해진 순서로 접근을 재생하고, 같은 시나리오를 세 프로토콜로 돌렸을 때의 트래픽을 표로 비교한다.

SIMULATOR

스누핑 버스 일관성 시뮬레이터

프로토콜
코어 수
줄을 누르면
캐시 상자 안의 줄(A~D)을 눌러 보자.
버스 트랜잭션0
무효화한 사본0
캐시 → 캐시 전송0
메모리 읽기 / 쓰기0 / 0
이 시나리오 전체를 세 프로토콜로 돌리면
버스 트랜잭션은 BusRd·BusRdX·BusUpgr(이미 S인 줄을 M으로 올리며 다른 사본만 무효화, 데이터 없음)를 센다. M·O 상태 캐시는 읽기 요청에 데이터를 직접 건네고, 그 밖에는 메모리가 응답한다. 실제 칩에서는 버스 대신 L3·NoC가 이 방송을 중계하며, 줄이 용량 때문에 쫓겨날 때의 되쓰기는 여기서는 생략했다.

표를 보면 프로토콜마다 잘하는 일이 다르다. 각자 자기 데이터를 고치는 경우 MESI는 MSI보다 버스 트랜잭션이 절반이다. 읽은 직후 쓰는 일이 프로그램에 아주 흔하기 때문에 E 상태는 거의 모든 프로세서가 채택했다. 이동하는 데이터처럼 더티 줄이 코어 사이를 옮겨 다니면 MOESI가 메모리 쓰기를 아낀다. Arm의 AMBA ACE·CHI 버스가 정의한 다섯 상태(UniqueDirty, SharedDirty, UniqueClean, SharedClean, Invalid)는 사실상 MOESI다. 반면 두 코어가 번갈아 쓰기는 어떤 프로토콜로도 줄이지 못한다. 쓰기 하나마다 줄이 통째로 건너가야 한다. 이것이 뒤에서 볼 거짓 공유와 락 경합의 뿌리다.

프로토콜상태특징대표 사용처 (대략)
MSIM, S, I가장 단순. 읽고 바로 쓰면 요청이 두 번교과서, 일부 단순한 설계
MESI+ E혼자 가진 깨끗한 줄은 조용히 M으로대부분의 CPU (일리노이 프로토콜)
MOESI+ O더티 줄을 메모리에 쓰지 않고 공유AMD x86, Arm ACE/CHI (UD·SD·UC·SC·I)
MESIF+ F공유 사본 중 응답할 하나(Forward)를 지정Intel x86 (2008년 이후)

스누핑은 얼마나 커질 수 있나: 디렉터리와 SoC의 일관성

스누핑은 모든 미스를 모든 캐시에 방송한다. 코어가 4개일 때는 문제가 없지만, 64개가 되면 미스 하나마다 63개 캐시가 태그를 뒤져야 하고 버스의 대역폭도 금세 바닥난다. 대안은 줄마다 "지금 누가 사본을 가졌는지"를 기록하는 디렉터리(Directory)다. 미스는 그 줄의 담당(홈) 노드로 가고, 디렉터리를 보고 사본을 가진 캐시에만 메시지를 보낸다. 메시지 수는 코어 수가 아니라 공유자 수에 비례한다.

스누핑: 미스당 메시지—
디렉터리: 미스당 메시지—
완전 비트 벡터 디렉터리 저장 부담—
그림 6-3. 만져 보기스누핑은 요청 1 + 다른 캐시 N−1곳의 태그 조회 + 데이터 1, 디렉터리는 요청 1 + 공유자 k곳에 무효화·응답 2k + 데이터 1(+ 홈을 거치는 전달 1)로 셌다. 공유자는 보통 적어서(실측 평균 1~2개) 디렉터리가 코어 수와 무관하게 거의 평평하다. 대신 줄마다 코어 수만큼의 비트(64 B 줄 = 512비트 대비)를 저장해야 한다.

스마트폰 SoC는 두 방식을 섞는다. 한 CPU 클러스터 안(예: Arm DynamIQ의 DSU)에서는 공유 L3 옆에 스누프 필터(Snoop filter)를 두어, 각 코어의 L1·L2에 어떤 줄이 있는지 추적한다. 필터에 없는 코어에는 스누프를 보내지 않으니 사실상 작은 디렉터리다. 클러스터 바깥, 즉 GPU·NPU·DMA까지 아우르는 일관성은 NoC(8장)의 일관성 노드가 맡는다.

CPU 밖의 블록들: I/O 일관성

GPU나 카메라 ISP, DMA 엔진은 CPU가 준비한 버퍼를 읽고 결과를 써서 돌려준다. 그런데 CPU가 막 쓴 데이터는 CPU 캐시에 더티로 남아 있을 수 있다. 이들이 메모리를 직접 읽으면 낡은 데이터를 본다. 해결법은 두 가지다. 소프트웨어가 넘기기 전에 캐시를 청소(clean, 더티 줄을 메모리로 내림)하고 받기 전에 무효화(invalidate)하거나, 하드웨어가 그 블록의 메모리 접근을 CPU 캐시에 스누프해 주는 I/O 일관성(I/O coherency)을 쓰는 것이다. Arm은 이런 블록용으로 스누프는 받지 않고 보내기만 하는 ACE-Lite 인터페이스를 정의했다.

낡은 값을 읽은 줄0
캐시 유지 연산 (청소·무효화)0
하드웨어 스누프0
①부터 차례로 눌러 보자. 비일관 모드에서 ②나 ⑤를 건너뛰면 어떻게 될까?
그림 6-4. 만져 보기버퍼는 캐시 줄 8개다. 칸의 숫자는 그 줄에 담긴 "프레임 번호"이고, 빨간 칸은 진짜 최신 값과 다른 낡은 값이다. 비일관 모드에서는 드라이버가 청소·무효화를 빠뜨리는 순간 GPU나 CPU가 낡은 프레임을 본다. I/O 일관 모드에서는 GPU의 읽기가 CPU 캐시를 스누프해 최신 값을 가져오고, GPU의 쓰기가 CPU 사본을 무효화한다.
"가끔 화면이 깨진다"의 단골 원인

캐시 청소·무효화를 빠뜨린 드라이버 버그는 타이밍에 따라 가끔만 드러나서 찾기 어렵다. 반대로 매번 버퍼 전체를 청소하면 큰 버퍼(예: 4K 프레임 수십 MB)에서는 그 자체가 수 ms를 잡아먹는다. 그래서 최근 SoC는 GPU·NPU·DMA 대부분을 I/O 일관으로 연결하고, 디스플레이처럼 대역폭이 크고 CPU가 거의 건드리지 않는 데이터만 비일관 경로로 보낸다.

같은 줄에 사는 남남: 거짓 공유

일관성은 캐시 줄 단위로 관리된다. 두 스레드가 서로 다른 변수를 쓰더라도 그 변수들이 같은 64 B 줄에 들어 있으면, 하드웨어 눈에는 같은 줄을 번갈아 쓰는 것과 똑같다. 쓸 때마다 줄을 상대에게서 빼앗아 와야 한다. 이것이 거짓 공유(False sharing)다. 아래에서 코어 0은 a++, 코어 1은 b++만 계속한다. 둘은 아무것도 공유하지 않는다.

코어 0의 a++0
코어 1의 b++0
줄이 오간 횟수0
패딩했을 때 대비 속도—
그림 6-5. 만져 보기줄 하나가 코어 사이를 건너가는 데 60클럭이 걸린다고 가정했다(클러스터 안 캐시 간 전송의 대략값). 같은 줄이면 줄이 두 코어 사이를 쉴 새 없이 오가며(핑퐁), 증가 사이의 일이 적을수록 패딩한 경우보다 몇 배나 느려진다. 패딩하면 각 줄이 자기 코어에 M 상태로 머물러 증가가 1클럭에 끝난다.

해결법은 간단하다. 스레드마다 자주 쓰는 변수를 다른 캐시 줄에 두면 된다. C/C++에서는 alignas(64)나 std::hardware_destructive_interference_size로, 자바에서는 @Contended로 패딩을 넣는다. 스레드별 카운터를 따로 모았다가 마지막에 합치는 것도 같은 생각이다. 거짓 공유는 정답은 맞게 나오고 속도만 느려서, 프로파일러로 캐시 간 전송(HITM) 이벤트를 세어 보기 전에는 알아채기 어렵다.

a와 b가 같은 줄에 있을 때, 코어 1이 b를 쓰지 않고 읽기만 한다면 핑퐁은 사라질까?

코어 0은 계속 a를 쓴다.

답 보기

사라지지 않는다. 코어 0이 쓸 때마다 코어 1의 S 사본이 무효화되고, 코어 1이 다시 읽으면 BusRd로 줄을 가져오며 코어 0의 M 줄을 S로 끌어내린다. 그러면 코어 0의 다음 쓰기가 또 BusUpgr를 내야 한다. 한쪽만 써도 다른 쪽이 같은 줄을 자주 읽으면 트래픽이 생긴다. 줄을 나누는 것이 근본 해결이다.

일관성만으로는 부족하다: 메모리 순서 모델

일관성은 주소 하나에 대한 약속이다. 그런데 프로그램은 여러 주소를 함께 쓴다. 코어 0이 data = 1을 쓰고 이어서 flag = 1을 썼을 때, 코어 1이 flag == 1을 봤다면 data도 반드시 1로 보일까? 이것은 일관성이 아니라 메모리 일관성 모델(Memory consistency model), 줄여서 메모리 모델이 정한다.

가장 직관적인 모델은 순차 일관성(SC, Sequential consistency)이다. 모든 코어의 명령을 하나의 순서로 섞되 각 코어의 프로그램 순서는 지키는 실행만 허용한다. 하지만 SC는 비싸다. 5장에서 본 쓰기 버퍼에 쓰기를 넣어 두고 다음 읽기를 먼저 하는 것조차 금지하기 때문이다. 그래서 x86은 쓰기 → 읽기 순서 바꾸기만 허용하는 TSO(Total store order)를, Arm은 서로 다른 주소라면 거의 모든 순서 바꾸기를 허용하는 약한 모델을 택했다. 순서가 중요한 곳에서는 프로그래머가 펜스(Fence, Arm의 DMB)나 acquire·release 명령으로 순서를 강제한다.

모델의 차이는 짧은 리트머스 테스트(Litmus test)로 드러난다. 아래에서 테스트와 모델을 고르고, 실행할 명령을 직접 눌러 실행 순서를 만들어 보자. 밝은 테두리가 그 모델에서 지금 실행할 수 있는 명령이다. 처음에 모든 메모리 값은 0이다.

EXPLORER

리트머스 테스트 실행 탐색기

테스트
메모리 모델
각 스레드 두 명령 사이에
가능한 결과 (모든 실행 순서를 끝까지 탐색한 결과)
명령을 눌러 실행해 보자.
이 탐색기는 "앞선 명령이 아직 실행되지 않았어도 모델이 허용하면 뒤 명령을 먼저 실행할 수 있다"는 순서 바꾸기 모델이다. SC는 순서 바꾸기 없음, TSO는 앞선 쓰기를 뒤의 다른 주소 읽기가 앞지르는 것만(쓰기 버퍼), Arm은 다른 주소끼리 모든 순서 바꾸기를 허용한다. 펜스는 어떤 메모리 명령과도 순서를 바꾸지 않는다. 같은 주소끼리는 늘 순서를 지킨다. 실제 Arm 모델에는 의존성 순서 등 더 세밀한 규칙이 있다.
허용되는 순서 바꾸기 (다른 주소)SCTSO (x86)Arm v8 / RISC-V (RVWMO)
쓰기 → 읽기✗✓ (쓰기 버퍼)✓
쓰기 → 쓰기✗✗✓
읽기 → 읽기, 읽기 → 쓰기✗✗✓ (의존성이 있으면 ✗)
순서를 강제하는 법—MFENCE, LOCK 접두 명령DMB/DSB, LDAR·STLR (RISC-V: FENCE, .aq/.rl)
왜 약한 모델을 택하나

약한 모델은 하드웨어에 자유를 준다. 캐시 미스가 난 쓰기를 기다리지 않고 뒤의 쓰기를 먼저 내보내고, 비순차 코어(4장)가 읽기를 마음껏 앞당길 수 있다. 대가는 프로그래밍의 어려움이다. 다행히 C/C++11·자바·러스트의 원자 변수(std::atomic와 memory_order)를 쓰면 컴파일러가 대상 CPU에 맞는 펜스를 넣어 준다. 데이터 경쟁이 없는 프로그램은 SC처럼 동작한다는 보장(SC-for-DRF)이 이 언어 모델들의 핵심이다.

원자 연산과 락: 일관성 위에 지은 집

여러 코어가 공유 자료를 고치려면 한 번에 한 코어만 들어가게 하는 락(Lock)이 필요하다. 락은 읽고-고치고-쓰기를 쪼갤 수 없게 하는 원자 연산(Atomic operation) 위에 만든다. Arm v8.1부터는 SWP, LDADD, CAS 같은 원자 명령(LSE)이 있고, 그 전에는 LDXR/STXR(배타적 읽기·쓰기) 쌍으로 같은 일을 했다. 원자 연산은 그 줄을 M 상태로 독점해야 실행할 수 있다. 그래서 락을 기다리는 코어가 많으면 락이 든 줄이 코어 사이를 미친 듯이 오간다.

SIMULATOR

락 경합 시뮬레이터

락 종류
코어 수
1만 클럭당 락 획득—
획득 한 번당 버스 트랜잭션—
평균 대기 시간—
버스 점유율—
공정성 (최소/최대 획득)—
코어마다 "락 획득 → 임계 구역 → 해제 → 임계 구역 길이 정도의 다른 일"을 반복한다. 버스 트랜잭션 하나는 20클럭이 걸리고 한 번에 하나씩 처리된다(MSI 무효화). 위쪽은 실시간 애니메이션(코어 색: 회색 다른 일, 주황 대기, 초록 임계 구역, 파랑 해제 중, 글자는 락 줄의 상태), 아래 막대는 코어 수 2~16에서 각 락을 6만 클럭씩 돌려 얻은 획득당 버스 트랜잭션이다. 오른쪽 숫자는 고른 설정의 6만 클럭 결과다.

결과는 생각보다 단순하지 않다. 임계 구역 길이를 400클럭, 코어를 4개로 놓아 보자. TAS는 기다리는 코어들이 서로 줄을 빼앗느라 버스를 90% 가까이 점유하지만, TTAS와 티켓 락은 기다리는 동안 조용해서 점유율이 그 1/3 수준이다. 버스가 비어 있으면 락과 무관한 다른 코어의 메모리 접근도 빨라진다. 그런데 코어를 16개로 늘리고 경합을 심하게 하면 TTAS는 오히려 TAS보다 트래픽이 많아진다. 락이 풀리는 순간 모든 대기자의 사본이 한꺼번에 무효화되고, 15개의 다시 읽기와 여러 swap 시도가 버스로 몰리기 때문이다. 1990년 T. Anderson이 버스 기반 멀티프로세서에서 측정해 보인 바로 그 현상이다. 티켓 락도 해제 때마다 대기자 전원이 다시 읽으므로 트래픽은 코어 수에 비례하지만, 공정성(최소/최대 획득)이 거의 완벽하다. TAS·TTAS에서는 운 나쁜 코어가 거의 굶는다.

근본 해법은 대기자들이 서로 다른 줄을 보며 기다리게 하는 것이다. 리눅스 커널의 qspinlock이 쓰는 MCS 큐 락은 대기자마다 자기 줄을 따로 두고 그 줄만 보며 기다리게 해서, 해제가 다음 대기자 한 명의 줄만 건드린다. 획득당 트래픽이 코어 수와 무관해진다. 실패할 때마다 기다리는 시간을 늘리는 지수 백오프도 TAS·TTAS의 폭주를 크게 줄인다.

가장 좋은 락은 쓰지 않는 락

코어 간 줄 전송은 수십~100 ns다. 락 경합이 심한 코드는 코어를 늘려도 빨라지지 않는다. 데이터를 코어별로 나누고(샤딩), 읽기가 대부분이면 RCU처럼 읽는 쪽이 아무것도 쓰지 않는 기법을 쓰고, 꼭 필요한 공유만 원자 연산으로 처리하는 것이 멀티코어 SoC에서 성능을 끌어내는 길이다.

핵심 정리

  1. 코어마다 나중 쓰기 캐시를 가지면 낡은 값과 잃어버린 갱신이 생긴다. 일관성은 "주소 하나에 대해 쓰기는 한 명, 읽기는 여럿"과 "최신 값 읽기"를 보장한다.
  2. MESI는 줄마다 M·E·S·I 상태를 두고 자기 요청과 엿들은 버스 요청에 따라 전이한다. E는 혼자 쓰는 데이터의 버스 요청을 줄이고, O(MOESI)는 더티 공유 줄의 메모리 쓰기를 줄인다.
  3. 스누핑은 방송이라 코어 수가 늘면 확장이 어렵고, 디렉터리·스누프 필터는 공유자에게만 메시지를 보낸다. GPU·DMA를 위한 I/O 일관성이 없으면 소프트웨어가 캐시를 청소·무효화해야 한다.
  4. 일관성은 줄 단위다. 서로 다른 변수라도 같은 줄에 있으면 거짓 공유로 줄이 핑퐁한다. 패딩으로 줄을 나눈다.
  5. 메모리 모델은 여러 주소 사이의 순서를 정한다. SC ⊂ TSO ⊂ Arm 순으로 허용 결과가 늘고, 펜스·acquire·release로 순서를 강제한다. 락은 원자 연산과 일관성 트래픽의 비용을 그대로 물려받는다.

확인 퀴즈

Q1. MESI에서 E 상태인 줄에 자기 코어가 쓰기를 하면?

E는 "나만 가진 깨끗한 사본"이므로 무효화할 다른 사본이 없다. 그래서 조용히 M으로 올라간다. MSI라면 같은 상황(S)에서 BusUpgr가 필요하다.

Q2. 다른 캐시가 M 상태로 가진 줄을 이 코어가 읽으려 할 때 데이터는 누가 주는가?

M 줄의 값이 유일한 최신 값이다. 그 캐시가 스누프에 응답해 데이터를 내보내고 S(MESI)나 O(MOESI)로 내려간다.

Q3. 64코어 시스템에서 디렉터리 방식이 스누핑보다 유리한 가장 큰 이유는?

공유자 수는 보통 1~2개라 메시지 수가 코어 수와 거의 무관하다. 대신 줄마다 공유자 정보를 저장하는 비용과 홈 노드를 거치는 지연이 생긴다.

Q4. 두 스레드의 카운터 a, b가 같은 64 B 줄에 있어 느려지는 현상과 해결책은?

일관성은 줄 단위로 관리되어 서로 다른 변수라도 같은 줄이면 핑퐁이 생긴다. 변수를 다른 줄에 정렬하면 각자 M 상태로 머문다.

Q5. SB 리트머스 테스트(T0: x=1; r0=y / T1: y=1; r1=x)에서 r0=0, r1=0이 나올 수 있는 모델은?

쓰기가 쓰기 버퍼에 머무는 동안 뒤의 읽기가 먼저 실행되면 둘 다 0을 읽는다. TSO가 허용하는 유일한 순서 바꾸기가 바로 이 쓰기 → 읽기다. 두 명령 사이에 펜스를 넣으면 막힌다.

Q6. TAS 스핀락에서 기다리는 코어가 많을수록 락을 쥔 코어의 해제까지 늦어지는 이유는?

해제(lock = 0)도 줄을 M으로 가져와야 하는 쓰기다. 대기자의 BusRdX가 버스를 가득 채우면 해제 요청이 밀린다. TTAS나 큐 락이 이를 줄인다.