RV64 컴퓨터 구조 위트 가이드
Chapter 5

크고 빠르게: 메모리 계층 활용

"빠른 건 작고, 큰 건 느리다"는 우주적 진리를 속이는 법

이상적으로는 무한히 큰 메모리 용량을 원하지만… 결국 우리는 메모리의 계층을 만들 수밖에 없다. 각 계층은 그 위 계층보다 크지만 그만큼 느리다. — Burks, Goldstine, von Neumann (1946) — 무려 80년 전인데 지금도 정답
이 장에서 배우는 것
  1. 5.1 도입 — 책상·서랍·창고의 비유
  2. 5.2 메모리 기술 — SRAM, DRAM, 플래시, 자기 디스크
  3. 5.3 캐시 기초 — 직접 사상부터 쓰기 정책까지
  4. 5.4 캐시 성능 측정과 개선
  5. 5.5 의존성 있는 메모리 계층 — ECC와 친구들
  6. 5.6 가상 머신 — 컴퓨터 안의 컴퓨터
  7. 5.7 가상 메모리 — 거대한 환상의 세계
  8. 5.8 공통 프레임워크 — 네 가지 질문, 세 가지 미스
  9. 5.9 FSM으로 캐시 제어
  10. 5.10 캐시 일관성 — 멀티코어의 골칫거리
  11. 5.13 ARM Cortex-A53 / Intel Core i7 메모리 계층
  12. 5.15 행렬 곱셈 가속 — 캐시 블로킹
  13. 5.16 오류와 함정
  14. 5.17 결론

5.1 도입 — 책상·서랍·창고의 비유

컴퓨터 사용자라면 누구나 한 번쯤은 이런 생각을 합니다. "메모리가 무한히 크고, 무한히 빠르고, 무한히 싸면 얼마나 좋을까?" 안타깝게도 우주의 법칙이 이걸 허락하지 않아요. 빠른 메모리는 비싸고 작고, 큰 메모리는 싸지만 굼뜹니다. 이 셋을 동시에 만족시킬 수 있는 메모리는 아직 발명되지 않았고, 앞으로도 한동안 그럴 겁니다.

그래서 컴퓨터 구조 설계자들은 사기를 칩니다. 여러 종류의 메모리를 층층이 쌓아서 "마치 크고 빠른 메모리 하나가 있는 것처럼" 보이게 만드는 거죠. 이게 바로 메모리 계층(memory hierarchy)입니다.

책상 위 vs 책상 서랍 vs 창고

원서는 도서관 비유를 좋아하지만, 우리는 한국식으로 가봅시다. 시험공부 하는 대학생을 상상해보세요. 책상 위에는 지금 펼친 교재 한 권과 노트가 놓여 있어요. 팔만 뻗으면 닿습니다. 책상 서랍에는 자주 쓰는 책 대여섯 권이 들어 있고, 의자에서 일어나야 꺼낼 수 있죠. 그리고 창고에는 1학년 때부터 모은 모든 전공 서적이 먼지 쌓인 채 잠들어 있어요. 가지러 가려면 신발 신고 엘리베이터 타고 내려가야 합니다.

당신이 똑똑한 학생이라면 어떻게 할까요? 모든 책을 책상 위에 올려두면 좋겠지만 책상이 좁아서 안 됩니다. 그렇다고 매번 창고에 다녀오면 시험은 망합니다. 답은 명확하죠: 지금 보고 있는 부분은 책상 위에, 곧 볼 만한 건 서랍에, 안 쓸 것 같은 건 창고에. 컴퓨터의 메모리 계층도 똑같은 발상입니다.

큰 그림

메모리 계층은 속도와 용량의 거짓말입니다. 가장 빠른 계층(레지스터, L1 캐시)을 사용자에게 보여주면서, 실제 데이터의 99%는 가장 느리고 큰 계층(디스크, SSD)에 숨겨두죠. 이 거짓말이 들통나지 않으려면 한 가지 조건이 필요합니다 — 지역성.

지역성의 원리(Principle of Locality)

프로그램이 메모리를 진짜 무작위로 접근한다면 캐시고 뭐고 다 무용지물입니다. 다행히 사람이 쓰는 프로그램은 그렇게 무작위로 굴러가지 않아요. 실제로는 두 가지 패턴을 가집니다.

시간적 지역성 (Temporal Locality)
방금 접근한 데이터는 곧 또 접근될 가능성이 높다. 반복문 카운터 변수, 방금 읽은 SNS 알림창, "재방문" 버튼 — 모두 같은 원리.
공간적 지역성 (Spatial Locality)
방금 접근한 데이터의 주변(근처 주소)도 곧 접근될 가능성이 높다. 배열 순회, 한 줄짜리 텍스트 읽기, 영화의 다음 프레임 — 모두 옆 동네를 또 본다.

프로그램의 90% 이상은 코드의 10% 안에서 시간을 보냅니다(이른바 90/10 법칙). 반복문이 그 대표죠. 반복문은 시간적 지역성(같은 코드 줄을 또 실행)과 공간적 지역성(루프 안에서 배열 a[i], a[i+1] 순회)을 동시에 만족시키는, 메모리 계층 입장에서는 아주 사랑스러운 패턴입니다.

계층의 구성

현대 컴퓨터의 메모리 계층은 대략 다음과 같이 생겼습니다. 위로 갈수록 빠르고 비싸고 작고, 아래로 갈수록 싸고 느리고 큽니다.

계층 구현 기술 대략 용량 접근 시간 비유
레지스터 플립플롭 수백 바이트 < 1 ns 지금 머릿속
L1 캐시 SRAM 수십 KiB ~1 ns 책상 위 펼친 책
L2/L3 캐시 SRAM 수 MiB ~ 수십 MiB 5–30 ns 책상 서랍
주 메모리 DRAM 수 GiB ~ 수백 GiB 50–100 ns 방 안 책장
SSD/플래시 플래시 메모리 수백 GiB ~ 수 TiB 10–100 µs 다용도실 박스
HDD 자기 디스크 수 TiB 5–10 ms 창고

여기서 주목할 건 단위가 한두 자리씩 점프한다는 점입니다. L1과 DRAM은 약 100배, DRAM과 SSD는 또 1000배, SSD와 HDD는 또 100배. 마치 게임의 보스 스테이지처럼 한 층 내려가면 세계관이 바뀝니다.

블록과 라인, 그리고 히트/미스

메모리 계층 사이에서 데이터는 블록(block) 또는 라인(line)이라고 부르는 덩어리 단위로 옮겨집니다. 한 바이트만 옮기면 비효율이거든요. 책 한 페이지가 필요해도 우리는 보통 책 전체를 가져오죠 — 같은 발상입니다.

히트(hit)
찾는 데이터가 상위 계층(예: 캐시)에 이미 있는 경우. 좋은 일.
미스(miss)
찾는 데이터가 상위 계층에 없어서 하위 계층까지 가야 하는 경우. 한숨 나오는 일.
적중률(hit rate) / 실패율(miss rate)
전체 접근 중 히트/미스의 비율. miss rate = 1 − hit rate.
적중 시간(hit time)
히트 시 데이터를 받아오는 데 걸리는 시간. 캐시를 검색하고 태그를 비교하는 시간 포함.
실패 손실(miss penalty)
미스가 났을 때 하위 계층에서 블록을 가져와 상위 계층에 채워 넣고 프로세서로 전달하는 데 추가로 걸리는 시간. 보통 적중 시간보다 한참 큼.
위트 한 스푼

"캐시 미스 한 번이 캐시 히트 100번보다 비싸다"는 말은 비유가 아니라 거의 사실입니다. 그래서 캐시 설계자는 "미스 한 번을 어떻게 줄일까"를 신앙처럼 고민합니다. 종교 비슷합니다.

5.2 메모리 기술 — SRAM, DRAM, 플래시, 자기 디스크

계층마다 다른 기술이 들어가는 이유는 단순합니다. 각 기술이 속도·밀도·가격·전력을 서로 다른 비율로 절충하기 때문입니다. 여기서는 핵심 네 가지를 살펴봅니다.

SRAM — 비싸고 빠른 귀족

SRAM(Static RAM)은 한 비트를 저장하는 데 트랜지스터 약 6개를 씁니다. 크로스 커플드 인버터 두 개가 서로 출력을 물고 늘어지면서 0이나 1 상태를 유지하죠. 전원만 들어와 있으면 리프레시가 필요 없고, 접근 시간이 아주 짧습니다(~0.5–2.5 ns). 단점은 트랜지스터가 많이 들어가서 비싸고 덜 빽빽하다는 것. 그래서 SRAM은 캐시처럼 작지만 빨라야 하는 곳에만 씁니다.

DRAM — 싸고 느린 서민

DRAM(Dynamic RAM)은 한 비트당 트랜지스터 1개와 커패시터 1개. 끝. 엄청 단순해서 SRAM보다 한 자릿수 더 빽빽하게 칩에 넣을 수 있어요. 문제는 커패시터가 시간이 지나면 전하를 흘려버린다는 점. 그래서 주기적으로 리프레시(refresh)를 해줘야 합니다 — 안 그러면 0이 1로 바뀌고 운영체제가 폭발합니다. 보통 8 ms마다 모든 행을 한 번씩 다시 써요.

DRAM은 행(row)과 열(column)로 조직돼 있습니다. 한 번에 한 행 전체를 내부 버퍼(row buffer)에 가져오고, 그 안에서 열을 골라 읽습니다. 그래서 같은 행에 있는 인접 데이터를 연달아 읽으면 매우 빠르고(공간적 지역성!), 다른 행으로 넘어가면 다시 활성화 비용을 치러야 합니다.

SDRAM, DDR, 그리고 무한히 늘어나는 숫자들

DRAM은 시간이 흐르며 발전했습니다. 동기식 DRAM(SDRAM)은 클럭 신호에 맞춰 동작해서 비동기식보다 깔끔하게 파이프라인을 짤 수 있고, DDR(Double Data Rate)은 클럭의 상승 에지와 하강 에지 모두에서 데이터를 전송해서 대역폭을 두 배로 늘렸습니다. DDR2, DDR3, DDR4, DDR5로 버전이 올라갈수록 클럭이 빨라지고 전압이 낮아지죠.

또한 DRAM 칩 여러 개를 모아 작은 보드에 붙인 게 DIMM(Dual Inline Memory Module)이고, DIMM 안에는 한 번에 같이 동작하는 칩들의 집합인 랭크(rank)가 있고, 각 칩 안에는 또 독립적으로 동작 가능한 뱅크(bank)가 여러 개 있습니다. 메모리 컨트롤러는 이 뱅크들을 인터리빙해서 한 뱅크가 활성화 중일 때 다른 뱅크에 명령을 보내는 식으로 대기 시간을 숨겨요. 마치 식당에서 주방 한 곳이 아니라 여러 곳을 동시에 굴리는 셈.

플래시 메모리 — 비휘발성의 신성

플래시는 EEPROM(전기적으로 지울 수 있는 ROM)의 한 종류로, 전원이 꺼져도 데이터가 남아 있는 비휘발성 메모리입니다. 우리가 쓰는 USB 메모리, SSD, 스마트폰 저장소가 다 이거죠. 디스크보다 빠르지만 DRAM보다는 느리고, DRAM보다 싸지만 디스크보다는 비쌉니다. 딱 그 사이의 빈자리를 메우러 나타났어요.

플래시의 큰 약점은 쓰기 횟수에 한계가 있다는 것. 같은 셀을 수만~수십만 번 쓰면 물리적으로 닳습니다. 그래서 웨어 레벨링(wear leveling)이라는 기술을 씁니다 — 컨트롤러가 같은 셀에만 계속 쓰지 않도록 쓰기 부하를 전체 칩에 골고루 퍼뜨리는 거예요. 마치 기름통에서 한쪽만 닳지 않도록 돌려가며 쓰는 것과 비슷합니다. 사용자에게는 보이지 않는 마법.

자기 디스크 — 회전하는 늙은 거인

HDD는 1956년 IBM이 처음 만든 후 무려 70년째 같은 원리로 굴러가는 노장입니다. 플래터(원판)가 5400~15000 RPM으로 빙빙 돌고, 헤드(자기 읽기/쓰기 장치)가 그 위를 지나가며 데이터를 읽거나 씁니다. 한 플래터에는 트랙(track)이라는 동심원 띠들이 있고, 각 트랙은 다시 섹터(sector)로 쪼개져요.

HDD에서 데이터를 읽는 데 걸리는 시간은 세 가지의 합입니다.

7200 RPM 디스크라면 평균 회전 지연이 약 4.2 ms입니다. 한 번 디스크에 갔다 오면 DRAM 접근 1만 번 정도가 가능한 시간이 흐르는 거죠. 그래서 우리는 캐시를 사랑합니다.

기술 비트당 트랜지스터 접근 시간 비용 (대략) 휘발성?
SRAM~60.5–2.5 ns$$$휘발성
DRAM1 + 캐패시터50–70 ns$휘발성, 리프레시 필요
플래시(NAND)1 (플로팅 게이트)5–50 µs¢비휘발성
HDD(자기 도메인)5–10 ms¢/10비휘발성

5.3 캐시 기초

이제 본격적으로 캐시(cache) 이야기입니다. 캐시라는 단어는 원래 "감춰두는 곳"이란 뜻인데, 컴퓨터 구조에서는 프로세서와 주 메모리 사이에 끼어 자주 쓰는 데이터를 몰래 들고 있는 작은 SRAM 메모리를 가리킵니다.

가장 단순한 캐시 — 직접 사상(Direct-Mapped)

캐시에 들어 있는 블록을 어떻게 찾을까요? 가장 간단한 방법은 각 메모리 블록이 캐시의 정해진 자리에만 들어갈 수 있도록 미리 약속하는 겁니다. 이게 직접 사상(direct-mapped) 캐시예요.

공식은 단순합니다. 캐시에 블록이 N개 있고 메모리 블록의 주소가 A라면:

(블록 주소) mod (캐시의 블록 수) = 캐시 인덱스

"modulo"라고 쓰니 거창해 보이지만, N이 2의 제곱수면 그냥 주소의 하위 비트 몇 개를 떼어내는 것과 같습니다. 컴퓨터는 2의 제곱수를 좋아하고, 모든 캐시는 2의 제곱수 블록을 가집니다. 우연이 아니죠.

예를 들어 캐시에 8개 블록이 있다면(인덱스 3비트), 메모리 블록 주소 ...01101은 인덱스 101(=5)에 자리 잡습니다. 같은 인덱스를 노리는 다른 블록(예: ...11101)이 들어오면 기존 블록을 쫓아내야 하죠. 그래서 어느 블록이 거기 있는지 식별할 방법이 필요합니다.

태그(tag)와 유효 비트(valid bit)

각 캐시 라인에는 데이터 외에 두 가지가 더 들어 있습니다.

태그(tag)
인덱스로 식별되지 않는 주소의 상위 비트들. 같은 인덱스를 공유하는 여러 블록 중 "지금 여기 있는 게 누구인지" 알려준다.
유효 비트(valid bit)
이 캐시 라인에 들어 있는 데이터가 진짜인지 쓰레기인지 알려주는 1비트. 부팅 직후엔 모두 0(쓰레기). 한 번 채워지면 1로 바뀐다.

그래서 32비트 주소가 들어오면 일반적으로 다음과 같이 쪼갭니다.

태그인덱스블록 오프셋바이트 오프셋
나머지 상위 비트log2(블록 수)log2(블록 안의 워드 수)2비트(워드=4B)

캐시 접근 시퀀스 예제

캐시가 8블록(인덱스 3비트), 블록 크기 1워드, 처음에는 모두 빈 상태라고 합시다. 다음 메모리 워드 주소들을 차례로 접근합니다(이진수): 22, 26, 22, 26, 16, 3, 16, 18.

주소(10진)주소(2진)인덱스히트?설명
2210110110미스처음 들어옴 → 캐시[110] 채움
2611010010미스처음 들어옴 → 캐시[010] 채움
2210110110히트!이미 [110]에 22가 있음
2611010010히트!이미 [010]에 26이 있음
1610000000미스처음 들어옴 → 캐시[000] 채움
300011011미스처음 → 캐시[011] 채움
1610000000히트!이미 [000]에 16
1810010010미스[010]에 26이 있었지만 태그가 다름 → 18로 교체

마지막 줄이 핵심입니다. 18과 26은 둘 다 인덱스 010에 사상되지만 태그가 다르죠. 캐시 자리는 하나뿐이니 누군가는 쫓겨납니다. 이걸 충돌 미스(conflict miss) 라 부릅니다(뒤에서 더 자세히).

블록 크기의 영향

한 블록에 워드가 여러 개 들어가게 만들면 어떤 일이 벌어질까요? 공간적 지역성이 강한 프로그램(예: 배열 순회)은 한 미스로 여러 워드를 한꺼번에 받아올 수 있어서 미스율이 줄어듭니다. 좋네요.

그런데 무한정 블록을 키우면 또 문제가 생겨요.

그래서 미스율 곡선이 U자 모양을 그립니다. 너무 작으면 공간 지역성 못 살리고, 너무 크면 블록 개수 부족과 미스 페널티 폭증. 실제로는 16~64바이트가 sweet spot이고, 현대 CPU는 보통 64바이트 캐시 라인을 씁니다.

읽기 미스 처리

캐시가 미스를 만나면 다음과 같은 일이 벌어집니다.

  1. 프로세서를 멈춥니다(stall).
  2. 주 메모리에 해당 블록을 요청합니다.
  3. 응답이 오면 그 블록을 캐시에 씁니다(태그·유효비트 갱신).
  4. 요청한 워드를 프로세서에 전달하고, 프로세서를 다시 굴립니다.

명령어 미스(I-cache miss)의 경우엔 PC = PC − 4로 되돌리고 PC가 가리키는 명령어를 다시 가져와 실행을 재개합니다.

쓰기 처리 — write-through vs write-back

읽기는 그래도 단순한데, 쓰기는 정책 선택이 필요합니다. 프로세서가 sw로 한 워드를 쓸 때, 캐시에만 쓸 것인가, 메모리에도 같이 쓸 것인가? 여기서 두 학파가 갈립니다.

1. Write-Through (즉시 반영파)

쓰기가 일어나면 캐시와 메모리에 동시에 씁니다. 메모리 일관성이 유지된다는 장점이 있지만, 매번 쓰기마다 메모리에 가야 하니 느려요. 그래서 쓰기 버퍼(write buffer)를 두어 쓰기 요청을 모아두고 메모리에 비동기로 흘려보냅니다. 프로세서는 버퍼에 쓰기만 던져 넣고 다음 명령으로 진행하죠. 버퍼가 꽉 차면 그제야 멈춥니다(stall).

2. Write-Back (게으름파)

쓰기가 일어나도 캐시에만 쓰고, 메모리에는 안 갑니다. 대신 그 캐시 라인이 변경됐다는 표시(dirty bit)를 켜둬요. 이 라인이 캐시에서 쫓겨날 때(교체될 때)에만 메모리에 한꺼번에 씁니다. 같은 라인에 여러 번 쓰면 메모리 트래픽이 한 번으로 줄어드는 게 큰 장점. 하지만 캐시와 메모리의 내용이 일시적으로 다르기 때문에 일관성 관리가 복잡해집니다(나중에 다중 코어에서 중요해짐).

하드웨어/소프트웨어 인터페이스

현대 데스크톱·서버 CPU는 거의 다 write-back을 씁니다. 대역폭이 귀하고 쓰기가 같은 라인에 몰리는 경우가 많기 때문이죠. 반면 임베디드/저전력 시스템에서는 단순함과 디버깅 용이성 때문에 write-through를 선택하기도 합니다.

쓰기 미스 — Write Allocate vs No-Write Allocate

쓰기를 했는데 그 블록이 캐시에 없는 경우(쓰기 미스)도 두 가지 선택이 있어요. Write allocate는 메모리에서 블록을 가져와 캐시에 채우고 그 안에 씁니다. No-write allocate는 캐시는 그냥 두고 메모리에만 씁니다. Write-back은 보통 write allocate와, write-through는 no-write allocate와 짝을 이룹니다.

실제 캐시 예제 — Intrinsity FastMATH

구체적인 예시로 Intrinsity FastMATH 프로세서의 캐시를 봅시다. 명령어 캐시·데이터 캐시 각각 16 KiB, 블록 크기 16워드(=64바이트), 직접 사상. 32비트 주소를 다음과 같이 쪼갭니다.

비트31..1413..65..21..0
의미태그(18)인덱스(8)블록 오프셋(4)바이트 오프셋(2)

인덱스 8비트는 블록 256개 = 캐시 16 KiB ÷ 64 B. 블록 오프셋 4비트는 16개 워드 중 어느 것인지 선택. 바이트 오프셋 2비트는 워드 안의 바이트(거의 무시). 각 블록마다 18비트 태그 + 1비트 유효 = 19비트 메타데이터가 추가로 필요합니다. 캐시의 진짜 크기는 데이터만이 아니라 이 오버헤드까지 포함해서 봐야 해요.

혼자 점검

32비트 주소, 64KiB 직접 사상 캐시, 블록 크기 16바이트라면 태그/인덱스/오프셋은 각각 몇 비트인가요? (힌트: 블록 수 = 4096, 인덱스 12비트, 블록 오프셋 4비트.)

5.4 캐시 성능 측정과 개선

CPU 시간을 다시 쓰는 공식

4장에서 우리는 CPU 시간이 명령어 수 × CPI × 클럭 주기라고 배웠습니다. 하지만 그 CPI에는 메모리 미스로 인한 stall이 포함돼 있지 않았어요. 이제 진짜로 써봅시다.

CPU 시간 = (실행 사이클 + 메모리 stall 사이클) × 클럭 주기

그리고 메모리 stall 사이클은 대략:

메모리 stall 사이클
  = (메모리 접근 횟수 / 명령어 수) × 미스율 × 미스 페널티 × 명령어 수
  = 명령어 수 × (메모리 접근/명령어) × 미스율 × 미스 페널티

명령어 캐시와 데이터 캐시를 따로 보면 더 정확합니다 — 명령어 미스 stall + 데이터 미스 stall. 어느 쪽이든 핵심 메시지는 분명해요: 미스율과 미스 페널티는 곱해지므로 둘 다 줄여야 한다는 것.

평균 메모리 접근 시간(AMAT)

캐시의 성능을 한 줄로 요약하는 자주 쓰는 지표가 AMAT(Average Memory Access Time)입니다.

AMAT = 적중 시간 + 미스율 × 미스 페널티

예를 들어 적중 시간 1 클럭, 미스율 5%, 미스 페널티 20 클럭이면 AMAT = 1 + 0.05 × 20 = 2 클럭. 즉 한 번 메모리에 접근할 때마다 평균 2 클럭이 소모된다는 뜻입니다. 캐시를 안 썼을 땐 매번 20 클럭이 걸렸을 테니 큰 절약이죠.

연관성을 늘려 충돌 미스 줄이기

직접 사상은 단순하지만 충돌에 약합니다. 인덱스가 같은 두 블록이 번갈아 접근되면 매번 서로를 쫓아내며 미스를 양산해요. 이 문제를 풀려면 한 인덱스에 여러 자리를 마련하면 됩니다 — 집합 연관(set-associative) 캐시.

직접 사상 (Direct-Mapped)
블록은 정해진 한 자리에만 들어갈 수 있음. 1-way set-associative와 같음.
N-way 집합 연관 (N-way Set-Associative)
한 인덱스(=집합, set)에 N개의 자리가 있음. 블록은 그 N자리 중 어느 곳이든 갈 수 있음. 찾을 때는 N개의 태그를 동시에 비교.
완전 연관 (Fully Associative)
모든 캐시 자리가 한 집합. 블록은 어디든 갈 수 있음. 인덱스 비트가 없음. 찾을 때는 모든 태그를 동시 비교(비싸다!).

일반적으로 연관성이 높아질수록 미스율은 낮아지지만, 적중 시간이 늘어나고 하드웨어 비용도 증가합니다(태그 비교기 N개 + 멀티플렉서). 연관성 4·8 정도면 완전 연관에 거의 근접하므로 산업계는 보통 그 정도에서 멈춥니다.

연관성장점단점
1-way (direct)가장 빠름, 하드웨어 단순충돌 미스 많음
2-way / 4-way충돌 미스 크게 감소비교기 늘어남, 약간 느려짐
N-way 큰 N충돌 거의 없음비싸고 느려짐
Fully assoc.충돌 미스 0모든 라인 동시 비교, 큰 캐시엔 비현실적

태그 비트의 변화

연관성이 올라가면 인덱스에 쓰는 비트가 줄고 태그 비트가 늘어납니다. 같은 캐시 크기라도 직접 사상보다 4-way 집합 연관이 태그 메모리를 더 많이 씁니다. 그래도 보통 미스율 감소가 그 비용을 보상해주죠.

교체 정책 — 누가 자리를 비울 것인가

집합 연관 캐시에서 미스가 났는데 그 집합이 꽉 차 있으면 누구를 쫓아낼까요? 가장 흔한 답은 LRU(Least Recently Used) — 가장 오래 안 쓴 놈을 내쫓는 것. 시간적 지역성을 고려한 합리적 선택이에요.

문제는 LRU 구현이 비쌉니다. 진짜 LRU를 4-way 이상에서 구현하려면 추가 비트와 로직이 많이 필요해요. 그래서 실제로는 유사 LRU(pseudo-LRU) 또는 단순한 무작위 교체(random)를 쓰기도 합니다. 무작위가 의외로 LRU에 가까운 성능을 낼 때가 많아요. 의외죠.

다단계 캐시(Multilevel Caches)

L1 하나만 두면 딜레마가 생깁니다. 빠르려면 작아야 하고, 미스율 줄이려면 커야 하고… 답은 둘 다 만들기. L1은 작고 빠르게 만들어 적중 시간을 최소화하고, 그 뒤에 L2(혹은 L3)를 크고 느리게 만들어 L1 미스를 받아주는 거예요.

이 경우 L1 미스 페널티는 메모리까지 갈 필요 없이 L2 적중 시간이 됩니다. L2까지 미스가 나야 진짜 메모리에 가는 거죠. 그래서 L2의 핵심 목표는 L2 적중 시간 최소화가 아니라 미스율 최소화입니다. 그래서 L2는 보통 더 크고, 더 연관도가 높고, 블록 크기가 크기도 합니다.

현대 CPU는 보통 다음과 같은 구성을 가집니다.

레벨크기연관성적중 시간공유?
L1 (I/D 분리)32–64 KiB 각각4–8 way1–4 사이클코어 전용
L2256 KiB – 1 MiB4–16 way10–20 사이클코어 전용 또는 공유
L3 (LLC)4–64 MiB16+ way30–60 사이클모든 코어 공유

소프트웨어 측 최적화 — 캐시 블로킹

하드웨어가 다 했다고 끝이 아닙니다. 프로그래머가 데이터 접근 패턴을 바꿔서 캐시 친화적인 코드를 짜면 미스를 더 줄일 수 있어요. 대표적인 예가 블로킹(blocking) 또는 타일링(tiling)입니다.

행렬 곱셈 C = A × B를 생각해봅시다. 단순한 3중 루프는 행렬이 캐시에 못 들어갈 정도로 크면 매번 캐시 미스를 양산합니다. 대신 행렬을 작은 블록(예: 32×32)으로 쪼개서 그 블록 단위로 곱셈을 끝낸 뒤 다음 블록으로 넘어가면, 한 번 캐시에 올린 데이터를 여러 번 재사용할 수 있어요.

// 단순 3중 루프 (캐시 비친화적)
for (i = 0; i < n; i++)
  for (j = 0; j < n; j++)
    for (k = 0; k < n; k++)
      C[i][j] += A[i][k] * B[k][j];

// 블로킹 버전 (BLOCK = 32 정도)
for (ii = 0; ii < n; ii += BLOCK)
  for (jj = 0; jj < n; jj += BLOCK)
    for (kk = 0; kk < n; kk += BLOCK)
      for (i = ii; i < min(ii+BLOCK,n); i++)
        for (j = jj; j < min(jj+BLOCK,n); j++)
          for (k = kk; k < min(kk+BLOCK,n); k++)
            C[i][j] += A[i][k] * B[k][j];

같은 알고리즘인데 데이터를 캐시에 머무르게 만들어 실행 시간이 몇 배씩 줄어듭니다. DGEMM(밀집 일반 행렬 곱셈)은 BLAS 라이브러리에서 이런 블로킹·타일링·SIMD를 극한까지 활용해 종이의 한계 성능에 가깝게 만들어둔 명물이에요.

5.5 의존성 있는 메모리 계층 — ECC와 친구들

DRAM은 사실 그렇게 안정적이지 않습니다. 우주에서 날아온 양자(cosmic ray)나 알파 입자가 셀의 전하를 바꿔서 비트가 뒤집히는 경우가 드물지 않아요. 한 번도 본 적 없을 수 있지만, 구글 데이터센터에 가면 매일 수만 번 일어납니다. 그래서 신뢰성이 중요한 시스템엔 오류 검출·정정 코드가 들어가요.

몇 개의 9가 붙느냐 — 가용성 지표

MTTF (Mean Time To Failure)
고장 사이의 평균 가동 시간. 길수록 좋다.
MTTR (Mean Time To Repair)
고장 후 복구에 걸리는 평균 시간. 짧을수록 좋다.
MTBF (Mean Time Between Failures)
고장 사이의 전체 평균 시간 = MTTF + MTTR.
가용성(Availability)
= MTTF / (MTTF + MTTR). 시스템이 살아 있는 비율. "five nines"는 99.999%.
AFR (Annualized Failure Rate)
1년 동안 고장 날 확률을 % 또는 분수로 표현. HDD 스펙에서 자주 등장.

"Five nines"(99.999% 가용성)는 1년에 약 5분 26초의 다운타임을 허용합니다. "Six nines"는 32초. 클라우드 서비스 SLA에서 자주 보는 숫자죠.

고장 회피·내성·예측

신뢰성을 높이는 접근은 세 갈래로 나뉩니다.

패리티 — 가장 단순한 오류 검출

8비트 데이터에 1비트를 더해, 1의 개수가 짝수(또는 홀수)가 되도록 맞추는 게 패리티(parity)입니다. 한 비트가 뒤집히면 1의 개수의 홀짝이 바뀌니까 감지할 수 있죠. 단점은 두 비트가 동시에 뒤집히면 못 잡고, 어디가 틀렸는지도 모릅니다.

해밍 SEC/DED 코드

Richard Hamming이 1950년에 발명한 코드는 단순한 패리티를 일반화해서 SEC(Single Error Correct) — 1비트 오류를 정정 — 이 가능하고, 여기에 한 비트만 더하면 SEC/DED(Single Error Correct, Double Error Detect) — 1비트 정정과 2비트 검출 — 까지 됩니다. 현대 ECC DRAM의 기본이죠.

해밍 코드를 만드는 단계는 대략 이렇습니다(데이터 비트 d개 + 패리티 비트 r개).

  1. 2의 제곱 위치(1, 2, 4, 8, 16, …)는 패리티 비트로 예약.
  2. 나머지 위치에 데이터 비트를 채움.
  3. 패리티 비트 pk는 자기 위치 비트 번호의 k번째 비트가 1인 모든 위치를 감시.
  4. 송신 시: 각 패리티 비트가 자기가 감시하는 비트들의 짝수 패리티를 만족하도록 설정.
  5. 수신 시: 각 패리티 그룹을 다시 계산. 모두 0이면 OK. 0이 아니면 그 비트 위치가 곧 오류 비트의 위치.
  6. SEC/DED 추가: 전체 패리티 한 비트를 더해서 두 비트 오류와 한 비트 오류를 구분.

예를 들어 위치 5(=0101)에서 비트가 뒤집혔다면 p1(자기 비트 0번 본다)과 p4(자기 비트 2번 본다)가 동시에 어긋나, 이 둘을 합쳐 5가 나옵니다. 문제 비트 정확히 찾았죠. 그냥 뒤집으면 끝.

Chipkill, CRC

DRAM 칩 하나가 통째로 죽는 사건은 일반 ECC로는 못 막습니다. 한 워드 안에서 수십 비트가 동시에 잘못되니까요. Chipkill은 데이터를 칩 사이로 분산시키고 더 강한 코드를 써서 칩 한 개가 죽어도 복구 가능하게 만든 IBM의 기술이에요. 구글 같은 데이터센터에서는 거의 표준.

한편 네트워크나 디스크처럼 더 큰 단위로 데이터가 오갈 때는 CRC(Cyclic Redundancy Check)를 씁니다. 다항식 나눗셈으로 체크섬을 만들어 버스트 오류(연속된 여러 비트 오류)를 잘 잡아내요. Ethernet, ZIP 파일, USB 등 거의 모든 곳에 들어갑니다.

5.6 가상 머신 — 컴퓨터 안의 컴퓨터

가상 메모리 얘기로 가기 전에, 한 단계 더 큰 그림인 가상 머신(Virtual Machine, VM)을 잠깐 다루겠습니다. 같은 "가상"이라는 단어를 쓰지만 다른 개념이에요. 가상 메모리는 프로세스에게 거대한 메모리의 환상을 주는 기술이고, 가상 머신은 프로세스(또는 OS)에게 거대한 컴퓨터 한 대의 환상을 주는 기술입니다.

VMM/Hypervisor

가상 머신을 관리하는 소프트웨어를 VMM(Virtual Machine Monitor) 또는 하이퍼바이저(hypervisor)라고 부릅니다. VMM은 진짜 하드웨어 위에서 돌아가면서, 그 위에 여러 개의 가상 컴퓨터를 띄우고 각각에게 진짜 컴퓨터인 것처럼 속여요. VMM 위에서 돌아가는 OS를 게스트(guest), 그 아래 실제 머신/OS를 호스트(host)라고 부릅니다.

왜 VM을 쓰는가?

AWS EC2 같은 클라우드 서비스가 바로 이 위에 세워졌습니다. 당신이 EC2 인스턴스를 띄우면 그건 어딘가의 거대한 서버에서 동작하는 가상 머신 한 대고, 같은 물리 서버에는 다른 사람의 가상 머신도 같이 돌고 있어요. VMM이 이들을 안전하게 분리해줍니다.

ISA가 가상화에 친화적이려면

어떤 ISA는 가상화하기 쉽고 어떤 건 안 쉽습니다. Popek과 Goldberg(1974)는 모든 권한 명령(privileged instruction)이 사용자 모드에서 실행될 때 트랩(trap)을 발생시켜야 가상화가 가능하다고 정리했어요. 이 조건을 만족하면 "고전적 가상화(classical virtualization)"가 가능합니다.

그런데 x86은 이 조건을 위반했어요. 대표적인 예가 POPF 명령. 사용자 모드에서 인터럽트 활성화 비트를 바꾸려 하면 그냥 무시하고 트랩도 안 냅니다 — VMM이 알아챌 수가 없죠. 이런 "비협조적" 명령이 17개나 있어서 초창기 VMware는 이진 변환(binary translation) 같은 곡예를 부려야 했습니다. 나중에 Intel VT-x, AMD-V 같은 가상화 보조 명령이 추가되면서 겨우 깔끔해졌어요. RISC-V는 처음부터 가상화 친화적으로 설계됐습니다.

5.7 가상 메모리 — 거대한 환상의 세계

이제 메모리 계층 이야기의 끝판왕입니다. 캐시가 SRAM-DRAM 사이의 마법이라면, 가상 메모리(virtual memory)는 DRAM-디스크 사이의 마법이에요. 거의 같은 원리지만 단위와 페널티가 너무 달라서 다른 이름을 갖게 됐습니다.

왜 필요한가?

옛날엔 프로그래머가 자기 프로그램이 메모리에 다 안 들어갈 것 같으면 직접 코드를 쪼개 필요한 부분만 메모리에 올렸다 내렸다 했어요. 오버레이(overlay)라는 끔찍한 짓을 했죠. 또 한 컴퓨터에서 여러 프로그램을 동시에 돌리려면 서로 메모리를 안 침범하도록 조심해야 했고, 한 프로그램이 다른 프로그램의 데이터를 수정해버리는 사고가 흔했습니다.

가상 메모리는 두 문제를 한 번에 해결합니다.

  1. 각 프로세스에게 거대한 가상 주소 공간의 환상을 줘서, 실제 DRAM이 작아도 신경 안 써도 되게.
  2. 가상 주소를 물리 주소로 변환하는 표를 운영체제가 관리해서, 프로세스 간 격리(isolation)와 보호(protection)를 자연스럽게 제공.

페이지 — 가상 메모리의 블록

캐시에서는 데이터의 덩어리를 "블록"이라 불렀죠. 가상 메모리에서는 페이지(page)라고 부릅니다. 보통 4 KiB 또는 그 배수예요. 그리고 미스가 나는 사건을 페이지 폴트(page fault)라고 부릅니다. 페이지 폴트가 나면 그 페이지를 디스크에서 가져와야 하므로 페널티가 수십만~수백만 클럭. 그래서 가상 메모리는 캐시와 다른 설계 균형을 가집니다.

특징캐시가상 메모리
블록/페이지 크기16–128 B4–64 KiB
적중 시간1–수십 사이클50–200 사이클(TLB 히트)
미스 페널티10–수백 사이클1–10 ms = 수백만 사이클
미스율0.1%–수%0.0001%대까지 낮춰야 함
위치SRAM ↔ DRAMDRAM ↔ 디스크/SSD
관리거의 하드웨어주로 OS, 일부 하드웨어
쓰기 정책write-through 가능write-back만 가능(디스크 쓰기 너무 느림)

가상 주소 → 물리 주소

프로세스가 메모리에 접근할 때 쓰는 주소는 가상 주소(virtual address)입니다. 이건 그 프로세스가 환상 속에서 보는 주소예요. 실제 DRAM의 위치는 물리 주소(physical address)고, 두 주소 사이의 변환표가 페이지 테이블(page table)입니다. 각 프로세스마다 자기만의 페이지 테이블을 가져요.

가상 주소는 두 부분으로 쪼개집니다.

가상 페이지 번호 (VPN)페이지 오프셋

페이지 오프셋은 페이지 안에서의 위치(4 KiB 페이지면 12비트)이고, 이 부분은 가상↔물리 변환에서 그대로 살아남습니다(같은 페이지면 안의 오프셋은 안 바뀌니까). 바꿔야 하는 건 VPN뿐. VPN을 페이지 테이블에 넣으면 물리 페이지 번호(PFN/PPN)가 나오고, 거기에 페이지 오프셋을 붙이면 물리 주소가 완성됩니다.

페이지 테이블 — 메모리 안의 거대 표

페이지 테이블은 어디에 두냐? 답은 메모리 안입니다. 그래서 매번 메모리 접근에 페이지 테이블도 메모리에서 읽어야 한다는 무서운 사실이 따라옵니다(곧 TLB로 해결). 각 페이지 테이블 엔트리(PTE)에는 보통 다음이 들어가요.

프로세스마다 페이지 테이블이 다르므로 OS는 컨텍스트 스위치 시 페이지 테이블 레지스터 (RISC-V에서는 satp)에 새 프로세스의 페이지 테이블 시작 주소를 넣어줍니다. 이 한 줄로 새 프로세스의 가상 메모리 세계가 활성화돼요.

페이지 폴트 처리

페이지 테이블 엔트리의 유효 비트가 0이라는 건 그 페이지가 DRAM에 없다는 뜻 — 페이지 폴트입니다. 이 경우 다음과 같은 일이 벌어져요.

  1. 하드웨어가 예외를 발생시켜 OS의 페이지 폴트 핸들러로 점프.
  2. OS는 디스크의 스왑 공간(swap space)에서 해당 페이지를 찾음.
  3. DRAM에 자리가 없으면 어떤 페이지를 쫓아낼지 결정(보통 LRU 근사).
  4. 쫓겨날 페이지가 dirty면 디스크에 먼저 씀.
  5. 요청된 페이지를 디스크에서 읽어와 DRAM에 적재.
  6. 페이지 테이블 갱신 후 폴트 명령부터 다시 실행.

페이지 폴트가 한 번 나면 디스크 접근이 일어나므로 수백만 사이클이 사라집니다. 그래서 OS는 페이지 폴트율을 0.0001% 수준 이하로 유지하려고 발버둥 칩니다. 하드웨어는 LRU에 가까운 정책을 거의 공짜로 만들어주는 참조 비트를 제공해서 OS를 돕죠. OS가 주기적으로 모든 참조 비트를 0으로 리셋하고, 다음 점검 때 0인 페이지가 "최근에 안 쓴 페이지"가 되니 그걸 쫓아내면 됩니다. 완벽한 LRU는 아니지만 충분히 합리적인 근사예요.

왜 가상 메모리는 항상 write-back인가?

답은 단순합니다. 디스크 쓰기가 너무 느려서요. 매 워드를 쓸 때마다 디스크에 가면 프로그램이 영원히 안 끝납니다. 그래서 페이지가 메모리에서 쫓겨날 때만 디스크에 쓰고, 이때 dirty 비트를 봐서 변경이 있었던 페이지만 쓰면 돼요. 깨끗한 페이지는 그냥 버려도 디스크에 똑같은 게 있으니 안전합니다.

다단계 페이지 테이블 — RISC-V Sv48

64비트 가상 주소 공간은 264바이트. 4 KiB 페이지로 나누면 페이지 수가 252개고, 한 PTE가 8바이트라면 페이지 테이블 하나가 무려 255바이트 = 32 PiB. 한 프로세스의 페이지 테이블이 우주를 통째로 사겠죠. 당연히 이건 못 씁니다.

해법은 다단계 페이지 테이블(multi-level page table)입니다. 가상 주소의 VPN을 여러 조각으로 쪼개서 트리처럼 쓰는 거예요. 비어 있는 가지는 아예 만들지 않으니 메모리를 크게 절약합니다.

RISC-V의 Sv48 모드를 봅시다. 48비트 가상 주소를 39비트 VPN + 9비트 + 9비트 + 9비트 + 12비트 오프셋으로 쪼개는 게 아니라, VPN을 9비트씩 4단계로 쪼갭니다. 한 단계의 페이지 테이블은 29=512 엔트리 = 4 KiB (한 페이지에 정확히 들어맞음). 결과는 40비트 물리 주소. 정리하면:

비트47..3938..3029..2120..1211..0
의미L3 인덱스 (9)L2 인덱스 (9)L1 인덱스 (9)L0 인덱스 (9)페이지 오프셋 (12)

한 번 변환할 때 메모리를 4번 읽어야 한다는 끔찍한 비용이 있지만, 다음 절의 TLB가 이걸 살려줍니다.

TLB — 변환의 캐시

매 메모리 접근마다 페이지 테이블을 4번 읽으면 메모리 접근이 5배 느려집니다(4번의 변환 + 실제 접근). 그래서 가상→물리 변환 결과를 캐싱하는 TLB(Translation Lookaside Buffer)를 둡니다. TLB는 보통 16~512엔트리로 매우 작고, 흔히 완전 연관 또는 매우 높은 연관도를 가지며, 핵심 클럭 안에 변환을 끝내야 하므로 SRAM으로 만들어요.

TLB가 적중하면 4번의 메모리 접근 없이 바로 물리 주소를 얻습니다. 미스가 나면 페이지 테이블을 진짜로 거슬러 올라가서(이걸 페이지 워크라고 부름) 변환을 찾고 TLB에 채워 넣습니다. 페이지 워크는 하드웨어가 자동으로 하기도 하고 (RISC-V, x86) 소프트웨어가 처리하기도 합니다(MIPS).

TLB 미스 vs 페이지 폴트 — 헷갈리지 말 것

학생들이 자주 혼동하는 부분이에요. 둘은 다릅니다.

TLB 미스 후 페이지 워크 도중에 PTE의 valid가 0이면 그제서야 페이지 폴트가 나는 거죠. 즉 페이지 폴트는 항상 TLB 미스를 동반하지만, TLB 미스가 페이지 폴트인 건 아닙니다.

TLB와 캐시의 통합

이제 진짜 머리 아픈 부분. 데이터 접근 한 번에 TLB와 L1 캐시 두 군데를 거칩니다. 각각 히트/미스가 가능하므로 조합이 여러 가지죠. 그리고 페이지 폴트까지 끼면 더 복잡해집니다. 가능한 조합을 표로 정리하면:

TLB페이지 테이블캐시가능?설명
히트히트가능가장 좋은 경우. 데이터 즉시 반환.
히트미스가능주소는 변환됐지만 캐시에 없음 → DRAM 접근.
미스히트히트가능TLB 미스 → 페이지 워크로 변환 찾음 → 캐시에 데이터 있음.
미스히트미스가능TLB 미스 + 캐시 미스 → DRAM에서 데이터 가져옴.
미스미스(페이지 폴트)미스가능TLB 미스 + 페이지 폴트 → 디스크에서 페이지를 읽어옴.
미스미스히트불가능페이지가 메모리에 없는데 캐시엔 있다? 모순.
히트—(페이지 폴트)불가능TLB가 hit인데 페이지가 없다? TLB는 valid한 PTE만 캐싱하므로 모순.

실제로 가능한 케이스는 5가지, 불가능한 게 2가지입니다. "TLB 미스 + 캐시 히트"가 얼핏 이상해 보일 수 있는데, 데이터가 다른 가상 주소(같은 물리 주소를 공유하는 alias)나 DMA로 캐시에 올라온 경우엔 충분히 가능해요.

가상 인덱스 캐시의 함정 — Aliasing

캐시 인덱싱과 변환의 순서를 어떻게 짜느냐로 또 학파가 갈립니다.

보호와 권한

가상 메모리의 또 하나의 큰 일은 보호(protection)입니다. 페이지 테이블 엔트리에 읽기/쓰기/실행 권한 비트와 사용자/슈퍼바이저 모드 비트를 두면, 한 프로세스가 다른 프로세스의 메모리를 건드릴 수 없고, 사용자 모드 코드가 커널 페이지에 손댈 수 없어요.

프로세서는 두(또는 셋 이상의) 모드를 가집니다. 사용자 모드에서는 권한 비트를 검사하고, 위반이 일어나면 트랩 → OS의 예외 핸들러가 받음. 슈퍼바이저(커널) 모드는 모든 페이지에 접근 가능. satp 같은 페이지 테이블 레지스터도 슈퍼바이저 모드에서만 바꿀 수 있어요. 그래서 사용자 프로그램이 "내 페이지 테이블 바꿀게요"라고 할 수 없습니다. 이게 OS의 권력 기반.

큰 그림

가상 메모리는 단순한 "메모리가 큰 척" 기술이 아닙니다. 현대 OS의 보안·격리· 가상화·동적 라이브러리·메모리 매핑 파일까지 거의 모든 마법이 페이지 테이블이라는 자료 구조 하나에서 파생돼요. 이 한 표가 OS 권력의 원천.

5.8 공통 프레임워크 — 네 가지 질문, 세 가지 미스

캐시, TLB, 가상 메모리 — 다 다른 이름이지만 같은 메모리 계층의 변형입니다. 이걸 한 번에 보려면 네 가지 질문을 하면 돼요.

  1. Q1: 블록은 어디에 둘 수 있는가? — 직접 사상, 집합 연관, 완전 연관.
  2. Q2: 블록을 어떻게 찾는가? — 인덱스 + 태그 비교(부분 또는 전체).
  3. Q3: 미스 시 어느 블록을 교체하는가? — LRU, FIFO, 무작위, 의사 LRU.
  4. Q4: 쓰기를 어떻게 처리하는가? — write-through(+ write buffer) vs write-back(+ dirty bit).

3 Cs — 미스의 세 가지 원인

미스는 왜 일어나는가? 세 가지 범주로 나누면 분석이 깔끔해집니다.

의무 미스(Compulsory miss / Cold miss)
그 블록에 처음 접근하는 경우. 캐시 크기를 키워도 못 막는다. 프로그램 시작 직후의 첫 인스턴스.
용량 미스(Capacity miss)
워킹 셋이 캐시보다 커서 발생. 더 큰 캐시를 만들면 줄어든다.
충돌 미스(Conflict miss)
같은 인덱스를 노리는 다른 블록이 들어와 쫓겨난 경우. 연관도를 높이면 줄어든다. 완전 연관 캐시에는 충돌 미스가 없다.

여기에 멀티프로세서에서는 4번째 C를 더 합니다 — 일관성 미스(Coherence miss). 다른 코어가 같은 라인을 수정해서 내 캐시 라인이 무효화된 경우. 5.10에서 만나요.

5.9 FSM으로 캐시 제어

캐시 컨트롤러는 결국 유한 상태 기계(FSM)로 구현됩니다. 가장 단순한 형태는 4상태 정도예요.

실제 캐시는 더 복잡한 FSM(논블로킹, MSHR, 프리페치, 스누프 응답 등 처리)을 갖지만, 개념의 출발점은 이 4상태입니다.

5.10 캐시 일관성 — 멀티코어의 골칫거리

혼자 살 땐 캐시가 평화롭습니다. 그런데 같은 메모리를 보는 코어가 둘, 넷, 여덟, 열여섯 개 있으면 갑자기 지옥이 됩니다. 코어 0이 자기 L1에 있는 변수 X를 1로 바꿨는데, 코어 1의 L1에는 여전히 옛날 값 0이 들어 있다면? 코어 1은 거짓말을 보는 거죠.

캐시 일관성(cache coherence)은 모든 코어의 캐시가 같은 메모리에 대해 "올바른" 값을 보도록 유지하는 문제입니다. 두 가지 핵심 성질이 있어요.

스누핑 프로토콜

가장 흔한 해법이 스누핑(snooping)입니다. 모든 코어의 캐시 컨트롤러가 공통 버스(또는 그에 준하는 구조)를 엿듣고 있다가, 누가 어떤 라인을 쓰면 자기 캐시에 그 라인이 있으면 무효화(invalidate)하거나 갱신(update)하는 방식이에요.

MESI 프로토콜

스누핑의 표준 변형이 MESI입니다. 각 캐시 라인이 4가지 상태 중 하나를 가져요.

M (Modified)
이 캐시만 가진 dirty 사본. 메모리에는 옛날 값. 쫓겨날 때 메모리에 써야 함.
E (Exclusive)
이 캐시만 가진 깨끗한 사본. 메모리와 일치. 다른 캐시에는 없음.
S (Shared)
여러 캐시가 가진 깨끗한 사본. 메모리와 일치.
I (Invalid)
유효하지 않은 라인(없는 거나 마찬가지).

한 코어가 Shared 상태의 라인에 쓰려고 하면, 버스에 "invalidate 요청"을 보내서 다른 모든 사본을 I로 만든 뒤 자기는 M으로 전환합니다. 그래야 쓰기가 안전하니까요. Read는 다른 캐시가 가지고 있다면 Shared로, 자기만 가져오면 Exclusive로 시작. 이런 식으로 상태 전이가 정의되는 게 MESI예요.

MESI의 변종으로 MOESI(O = Owned, dirty이지만 다른 캐시들과 공유), MESIF(F = Forwarder) 등이 있어요. CPU 벤더마다 약간씩 다릅니다.

False Sharing — 보이지 않는 적

멀티스레딩 코드에서 자주 만나는 함정. 두 스레드가 다른 변수를 쓰는데 그 변수들이 같은 캐시 라인 안에 있다면, 매 쓰기마다 invalidate가 날아가서 두 스레드의 캐시가 핑퐁을 칩니다. 변수는 안 겹치는데 라인은 겹쳐서 일관성 트래픽만 폭발하는 현상 — 이게 false sharing입니다.

해결책은 자주 쓰이는 변수를 다른 캐시 라인에 떼어놓는 것. C/C++에서는 alignas(64) 같은 힌트로 64바이트 정렬을 강제해서 이걸 막아요.

위트 한 스푼

false sharing은 룸메이트 두 명이 각자 자기 신발만 신는데, 두 사람의 신발장이 너무 좁아서 한 사람이 자기 신발 꺼낼 때마다 다른 사람 신발이 와르르 쏟아져 다시 정리하는 상황과 같습니다. 신발은 안 섞었는데 시간이 다 빠져나가요.

5.13 ARM Cortex-A53 / Intel Core i7 메모리 계층

이론을 봤으니 실물을 보러 갑시다. 저전력 모바일의 강자 ARM Cortex-A53과, 데스크톱·서버를 휘어잡은 Intel Core i7의 메모리 계층은 비슷하면서도 살짝 다릅니다.

ARM Cortex-A53

Intel Core i7 (예: Skylake/Coffee Lake 세대)

공통점은 둘 다 다단계 캐시 + 스누핑 일관성 + 64B 라인이라는 점. 차이점은 i7의 L3가 거대한 inclusive cache를 두어 코어 간 통신을 매개하는 반면, A53은 클러스터 단위로 더 단순하게 구성됐다는 점.

5.15 행렬 곱셈 가속 — 캐시 블로킹의 위력

5.4에서 잠깐 본 블로킹의 효과를 좀 더 정량적으로 보겠습니다. 1024×1024 double 행렬은 8 MiB. 일반적인 L1(32 KiB)에 절대 안 들어갑니다. 단순 3중 루프로 곱하면 내부 루프에서 B의 한 열을 따라 내려가며 매 접근마다 새 캐시 라인을 건드려요. 각 곱셈마다 새 미스가 나는 셈.

32×32 블록으로 타일링하면 한 블록은 32×32×8 = 8 KiB. 세 블록(A·B·C 일부)이 L1에 동시에 들어가요. 한 번 로드된 블록 안에서 32³ = 32768번의 곱셈이 일어나는 동안 캐시는 거의 100% 적중. 결과적으로 미스율이 한 자릿수 % → 0.1% 수준으로 떨어집니다.

여기에 SIMD(AVX-512)로 벡터화하고, 멀티스레딩(OpenMP)으로 코어를 동시에 굴리고, 캐시 라인 정렬과 prefetch까지 더하면 단순 코드 대비 100배 이상 빨라지는 일도 흔해요. OpenBLAS, MKL 같은 라이브러리는 이걸 평생 다듬는 데 인력을 쏟아붓습니다.

5.16 오류와 함정

메모리 계층은 워낙 복잡해서 사람들이 자주 헛다리를 짚습니다. 자주 나오는 함정을 정리해두면 시험에서도 실무에서도 덕을 봐요.

5.17 결론

메모리 계층은 컴퓨터 구조에서 가장 우아한 사기입니다. "빠르고 크고 싼 메모리는 없다"는 물리적 진리에, "하지만 그렇게 보이게 만들 순 있다"는 공학적 답을 한 거예요. 그 답의 뼈대는 단순합니다. 지역성을 활용해 자주 쓰는 데이터를 가까이 둔다. 이 한 줄이 캐시·TLB·페이지 테이블·prefetcher·가상 머신·분산 캐시까지 모든 것을 만들어냈습니다.

한 발짝 떨어져 보면 메모리 계층의 모든 층이 같은 패턴을 따라요.

프로그래머의 입장에서도 이 계층을 의식하면 코드가 빨라집니다. 배열 순회 방향, 데이터 구조의 라인 정렬, false sharing 회피, 블로킹 — 모두 하드웨어가 이미 만들어둔 빠른 길을 활용하는 방법이에요. 책상 위에 자주 쓰는 물건을 두는 학생의 지혜를, 코드에도 적용하면 그게 바로 캐시 친화적 프로그래밍.

한 줄 요약

메모리 계층은 지역성을 믿고 만든 다층 캐시 시스템이다. 캐시·TLB·가상 메모리는 서로 다른 이름이지만 같은 발상의 변주이며, 네 가지 질문(어디에·어떻게 찾고·누구를 쫓아내고·쓰기를 어떻게)으로 모두 분석할 수 있다.

혼자 점검