🧠 메모리 교재 · 04_캐시

캐시와 캐시 친화적 코드

목표 학습 시간: 105분 + 복습 15분 · 전공 수준 정독용

이 챕터를 마치면: (1) 주소가 캐시의 어디로 매핑되는지(태그/세트/오프셋) 손으로 쪼개고, (2) 3종 미스를 구분해 각각 줄이는 법을 알고, (3) 행/열 순회의 미스율을 정량적으로 계산하고, (4) 블로킹(타일링)이 왜 작동하는지 재사용 관점에서 설명할 수 있다.


0. 학습 지도 (105분)

구간 내용
1 12 캐시 라인 — 전송의 단위
2 28 캐시 구조: 매핑·연관도·주소 분해
3 15 3종 미스와 쓰기 정책
4 30 캐시 친화적 코드: 순회·AoS/SoA·블로킹
5 5 프리페치와 측정
6 흔한 오해·정리
복습 15 인출 + 정량 문제

1. 캐시 라인 — 전송의 단위

세션 1에서 "데이터는 블록 단위로 층 사이를 오간다"고 했다. 캐시에서 그 블록이 캐시 라인(cache line, 보통 64바이트) 이다. 이 한 가지가 캐시 성능의 절반을 설명한다.

CPU가 메모리에서 int 하나(4B)를 읽어도, 하드웨어는 그 주소가 속한 64B 라인 전체를 캐시에 올린다. 즉 int 16개가 한 번에 따라온다.

a[0] 하나 요청 → 메모리에서 [ a[0] a[1] ... a[15] ] 64B 한 라인을 캐시로 적재
                 이후 a[1]..a[15] 접근은 전부 캐시 히트 (거의 공짜)

이것이 공간적 지역성이 공짜 성능을 주는 물리적 메커니즘이다. 반대로, 라인에서 단 1개만 쓰고 버리면(예: 큰 stride 접근) 64B를 옮기고 4B만 쓰는 셈 — 대역폭의 15/16을 낭비한다. "라인을 알뜰히 쓰는가"가 캐시 최적화의 본질이다.


2. 캐시 구조 — 주소는 캐시의 어디로 가는가

2.1 세 가지 사상(mapping) 방식

캐시는 라인을 담는 칸들의 모음이다. "어떤 메모리 주소가 어느 칸에 들어갈 수 있는가"로 셋이 나뉜다.

연관도(way 수)가 높을수록 충돌 미스가 줄지만 하드웨어가 복잡해진다.

2.2 주소 분해 — 태그 / 세트 인덱스 / 블록 오프셋

물리(또는 가상) 주소를 세 부분으로 쪼개 캐시를 찾는다.

 [        태그 (tag)        |  세트 인덱스  |  블록 오프셋  ]
                                            └ 라인 안에서 몇 번째 바이트
                              └ 어느 세트로 갈지
  └ 그 세트의 어느 way가 진짜 이 주소인지 식별

2.3 손으로 쪼개기 — 워크드 예제

예제. 32KB, 8-way 집합 연관, 라인 64B 캐시(흔한 L1 구성)라고 하자. 주소는 48비트로 본다.

총 라인 수    = 32KB / 64B               = 512 라인
세트 수       = 512 / 8(way)             = 64 세트
오프셋 비트   = log2(64)                 = 6 비트
세트 인덱스   = log2(64)                 = 6 비트
태그 비트     = 48 - 6 - 6               = 36 비트

이제 어떤 주소가 주어지면, 하위 6비트는 라인 안 위치, 그다음 6비트는 세트 번호, 나머지 36비트는 그 세트에서 비교할 태그다. 이 분해를 직접 할 수 있으면 캐시 구조를 안다고 말할 수 있다.

32KB · 8-way · 라인 64B → 태그 36 / 세트 6 / 오프셋 6 비트 태그 (36b) 세트 (6b) 오프셋 (6b) → 라인 내 바이트 세트 선택 세트 인덱스가 고른 한 세트 (8-way) — 태그가 맞는 way가 히트 w0 w1 w2 ✓ w3 w4 w5 w6 w7 오프셋 = log₂(64) = 6b · 세트 = log₂(64세트) = 6b · 태그 = 48 − 6 − 6 = 36b
주소 분해 — 세트 인덱스로 세트를 고르고, 그 세트의 8개 way 중 태그가 맞는 라인이 히트, 오프셋이 라인 내 위치.

통찰: 세트 인덱스가 주소의 중간 비트라는 점이 중요하다. 그래서 정확히 캐시 크기만큼 떨어진(또는 그 배수) 두 주소는 같은 세트로 가 충돌하기 쉽다 — 이것이 2의 거듭제곱 stride가 종종 성능 함정인 이유(3.1의 conflict 미스).

2.4 교체 정책 — 세트가 꽉 차면 누구를 쫓아내나

집합 연관 캐시에서 한 세트의 way가 모두 차 있는데 새 라인을 넣어야 하면, 그 세트 안에서 하나를 골라 내보내야(eviction) 한다. 이 선택이 캐시판 교체 정책이다(세션 6의 페이지 교체와 같은 문제의 더 작은 버전).

핵심: CPU 캐시 교체는 하드웨어가 자동으로 하며 개발자가 직접 못 고른다 — 세션 6의 페이지 교체(OS가 소프트웨어로 수행)와 "누가 하느냐"가 다르다. 다만 둘 다 지역성에 베팅한다는 원리는 같다.

2.5 포함 관계 — inclusive · exclusive · NINE

여러 층 캐시(L1/L2/L3) 사이의 데이터 중복 정책도 설계 선택이다(세션 1의 "위층은 아래층의 부분집합" 보강).

"L1에 있는데 L3에서 쫓겨나면?"(inclusive면 L1도 무효화당함) 같은 성능 질문이 여기서 나온다.


3. 3종 미스(3 C's)와 쓰기 정책

3.1 미스의 세 원인 — 그리고 각각의 처방

미스 종류 원인 줄이는 법
Compulsory(강제) 그 라인을 난생처음 접근 프리페치, 라인을 알뜰히 쓰기(공간적 지역성)
Capacity(용량) 작업 집합이 캐시보다 큼 작업 집합 축소 — 블로킹, 데이터 줄이기
Conflict(충돌) 서로 다른 주소가 같은 세트로 매핑돼 쫓아냄 연관도↑, 데이터 배치 조정(2의 거듭제곱 stride 회피)

이 분류가 중요한 이유: 미스를 봤을 때 어떤 처방을 쓸지가 종류에 따라 다르기 때문이다. 작업 집합이 캐시를 넘쳐서 나는 capacity 미스는 연관도를 올려도 안 풀리고, 데이터를 줄이거나 블로킹해야 한다.

3.2 쓰기 정책 (간단히, 하지만 알아둘 것)

읽기만 캐시되는 게 아니다. 쓰기도 정책이 있다.

실무 함의: 쓰기도 라인 단위로 일어난다. 그래서 라인의 일부만 쓰는 패턴, 그리고 세션 10의 false sharing(다른 코어가 같은 라인을 번갈아 쓰는 것)이 성능 문제가 된다.


4. 캐시 친화적 코드 — 정량적으로

여기가 이 세션의 실전 핵심이다. 빅오가 같아도 상수가 캐시에서 갈린다(세션 1)는 명제를 코드로 증명한다.

4.1 행 우선 vs 열 우선 — 미스율 계산

C는 2차원 배열을 행 우선(row-major) 으로 저장한다: m[0][0], m[0][1], ..., m[0][N-1], m[1][0], .... 즉 같은 행의 원소가 메모리에 연속한다.

// (A) 행 우선 순회
for (int i = 0; i < N; i++)
    for (int j = 0; j < N; j++)
        sum += m[i][j];

// (B) 열 우선 순회
for (int j = 0; j < N; j++)
    for (int i = 0; i < N; i++)
        sum += m[i][j];

미스율을 계산해 보자. 라인 64B, int 4B → 라인당 int 16개.

(B)는 (A)보다 약 16배 많은 미스를 낸다. 시간복잡도는 둘 다 O(N²)인데, 실측 속도는 수 배~십수 배 차이 난다(미스 페널티가 크니까 — 세션 1의 AMAT). 이것이 루프 교환(loop interchange) 최적화의 근거다: 가장 안쪽 루프가 메모리 연속 방향을 훑도록 순서를 맞춰라.

행 우선 (A) · 안쪽 루프 j

열 우선 (B) · 안쪽 루프 i

행이 메모리에 연속 → 라인 1개로 16개 히트 미스율 ≈ 1/16 (6.25%) 행 폭만큼 점프 → 매 접근 새 라인 미스율 ≈ 100% (약 16배)

C는 행 우선 저장 — 안쪽 루프가 행(j)을 훑으면 라인을 알뜰히 쓰고(좌), 열(i)을 훑으면 매번 새 라인이라 ~16배 미스(우).

4.2 AoS vs SoA — 라인 활용률

// AoS (Array of Structs): 객체 단위로 뭉침
struct P { float x, y, z; int id; };   // 16B
struct P arr[N];
// x들의 합만 구한다면? 각 라인(64B=4객체)에서 x는 4개뿐,
// 나머지 y,z,id 12개는 끌려왔다 버려진다 → 라인 활용률 4/16 = 25%

// SoA (Struct of Arrays): 필드별로 따로
struct Ps { float x[N], y[N], z[N]; int id[N]; };
// x[]만 순회 → x들이 라인에 빽빽(16개/라인) → 활용률 100%

"여러 객체에서 특정 필드만 쭉 훑는" 접근 패턴이면 SoA가 라인을 4배 알뜰히 써 캐시 미스를 크게 줄인다. 반대로 "한 객체의 모든 필드를 함께 쓰는" 패턴이면 AoS가 낫다. 접근 패턴이 레이아웃을 결정한다.

4.3 블로킹(타일링) — capacity 미스를 죽이는 법

행렬 곱 C = A × B를 보자(N×N).

// 단순(naive) 곱
for (int i = 0; i < N; i++)
  for (int j = 0; j < N; j++)
    for (int k = 0; k < N; k++)
      C[i][j] += A[i][k] * B[k][j];

문제: 가장 안쪽에서 A[i][k]는 행 방향(좋음)이지만 B[k][j]열 방향(stride N, 나쁨). 게다가 i가 한 칸 갈 때마다 B 전체를 다시 훑는다. B가 캐시보다 크면(N이 조금만 커도) B의 라인들은 재사용되기 전에 쫓겨나 매번 다시 적재 — 전형적 capacity 미스다.

블로킹의 아이디어: 행렬을 B×B 타일로 쪼개, 타일들을 캐시에 올려둔 동안 그 안에서 가능한 모든 곱셈-덧셈을 끝내 재사용을 극대화한다.

for (int ii = 0; ii < N; ii += BS)
 for (int jj = 0; jj < N; jj += BS)
  for (int kk = 0; kk < N; kk += BS)
   for (int i = ii; i < ii+BS; i++)
    for (int j = jj; j < jj+BS; j++)
     for (int k = kk; k < kk+BS; k++)
      C[i][j] += A[i][k] * B[k][j];

왜 빨라지나(재사용 분석): 한 번 캐시에 올린 A 타일·B 타일·C 타일을, 쫓겨나기 전에 그 블록의 모든 연산에 재사용한다. 대략 블록 크기 BS에 비례해 데이터 재사용 횟수가 늘고, 메모리에서 새로 끌어오는 횟수가 준다. 타일 크기는 세 타일(A,B,C)이 캐시에 동시에 들어가도록 정한다:

3 × BS² × sizeof(요소) ≤ 캐시 크기   →   BS를 이 조건에서 최대로

블로킹은 작업 집합을 "캐시 안에 욱여넣어" capacity 미스를 구조적으로 없애는, 세션 1 "작업 집합" 개념의 직접적 응용이다.

4.4 데이터 구조 선택의 캐시 비용


5. 프리페치와 측정


6. 흔한 오해 바로잡기


7. 한 장 정리


8. 복습 (15분) — 답을 가리고

Q1. 라인이 64B인데 int 하나를 읽으면 실제로 무엇이, 왜 올라오나?

그 주소가 속한 64B 라인 전체(=int 16개). 캐시는 라인 단위로 전송하며, 이는 공간적 지역성에 대한 베팅이다 — 곧 이어질 인접 접근을 히트로 만든다.

Q2. 64KB, 4-way, 라인 64B 캐시의 오프셋·세트 인덱스·태그 비트를 구하라(48비트 주소).

라인 수 = 64KB/64 = 1024. 세트 수 = 1024/4 = 256. 오프셋 = log2(64) = 6비트. 세트 인덱스 = log2(256) = 8비트. 태그 = 48−6−8 = 34비트.

Q3. N=1024 int 행렬에서 행 우선과 열 우선 순회의 미스율을 추정하고 배율을 말하라.

행 우선 ≈ 1/16 ≈ 6.25%(라인당 16개 히트). 열 우선 ≈ ~100%(매 접근 새 라인). 약 16배 차이.

Q4. capacity 미스와 conflict 미스를 구분하고, 각각의 처방을 말하라.

capacity: 작업 집합이 캐시보다 커서 발생 → 작업 집합 축소(블로킹·데이터 다이어트). conflict: 다른 주소가 같은 세트로 매핑돼 발생 → 연관도↑ 또는 2의 거듭제곱 stride 회피·배치 조정.

Q5. x들의 합만 반복해서 구하는 코드에서 AoS와 SoA의 라인 활용률을 계산하라(struct P{float x,y,z;int id;}).

객체 16B, 라인 64B → 라인당 4객체. AoS: x는 라인당 4개뿐 → 활용률 4/16 = 25%. SoA: x[]만 빽빽 → 라인당 16개 → 100%. SoA가 4배 알뜰.

Q6. 블로킹이 단순 행렬 곱보다 빠른 이유를 "재사용"과 "미스 종류"로 설명하라.

단순 곱은 B를 i마다 다시 훑어 B가 캐시보다 크면 재사용 전에 쫓겨나 capacity 미스가 폭증한다. 블로킹은 B×B 타일을 캐시에 올린 동안 그 안의 모든 곱셈-덧셈에 재사용해, 메모리 재적재 횟수를 BS에 비례해 줄인다. 타일은 3·BS²·요소 ≤ 캐시가 되도록 정한다.

Q7. 세션 1·2와의 연결을 한 문장씩.

세션 1: 라인 단위 전송·미스 페널티는 AMAT로 정량화된다(미스율↓이 곧 속도). 세션 2: 구조체 패딩으로 객체가 커지면 라인당 담기는 수가 줄어 공간적 지역성이 나빠진다(레이아웃이 캐시를 좌우).


다음: 세션 5 — 가상 메모리 ① 페이징 (이어지는 파일)