[Daily morning study] 캐시 메모리 계층 구조 (L1/L2/L3)
#daily morning study
왜 캐시 메모리가 필요한가
CPU와 메인 메모리(RAM) 사이에는 속도 차이가 수백 배 이상 존재한다. CPU가 연산을 마친 뒤 RAM에서 다음 데이터를 가져올 때까지 기다리는 시간을 메모리 레이턴시라고 하는데, 이 병목이 전체 성능을 크게 제한한다.
이 격차를 줄이기 위해 CPU와 RAM 사이에 작고 빠른 메모리를 여러 단계로 배치한 것이 캐시 메모리 계층 구조(Cache Memory Hierarchy)다.
메모리 계층 구조 전체 그림
CPU 레지스터 → 가장 빠름, 수 바이트 ~ 수십 바이트
L1 캐시 → ~수십 KB, ~1~4 사이클
L2 캐시 → ~수백 KB, ~10~20 사이클
L3 캐시 → ~수 MB ~ 수십 MB, ~30~50 사이클
RAM (DRAM) → ~수십 GB, ~100~200 사이클
SSD / HDD → 수백 GB ~ 수 TB, 수백만 사이클
각 단계는 위로 갈수록 빠르고 작으며, 아래로 갈수록 크고 느리다.
L1 / L2 / L3 캐시 비교
| 항목 | L1 캐시 | L2 캐시 | L3 캐시 |
|---|---|---|---|
| 위치 | 코어 내부 | 코어 내부 or 근접 | 여러 코어가 공유 |
| 크기 | 32KB ~ 64KB | 256KB ~ 1MB | 4MB ~ 64MB |
| 레이턴시 | 1~4 사이클 | 10~20 사이클 | 30~50 사이클 |
| 역할 | 가장 자주 쓰는 데이터 | L1 miss 시 검색 | 멀티코어 공유 데이터 |
- L1은 보통 명령어 캐시(I-cache)와 데이터 캐시(D-cache)로 분리되어 있다.
- L2는 L1 미스 시 먼저 탐색되는 “중간 버퍼” 역할을 한다.
- L3는 여러 코어가 공유하기 때문에 코어 간 데이터 공유와 일관성 유지에도 중요하다.
캐시 히트(Hit)와 미스(Miss)
CPU가 데이터를 요청할 때:
- L1 캐시 확인 → 있으면 캐시 히트(L1 Hit), 없으면 L1 미스
- L2 캐시 확인 → 있으면 L2 Hit, 없으면 L2 미스
- L3 캐시 확인 → 있으면 L3 Hit, 없으면 L3 미스
- RAM 접근 → 느리지만 데이터를 가져온 뒤 캐시에 적재
캐시 미스가 많아질수록 CPU가 데이터를 기다리는 스톨(stall) 상태가 늘어나 성능이 저하된다.
지역성(Locality) 원리
캐시가 효과적으로 동작하는 이유는 프로그램이 지역성이라는 패턴을 따르기 때문이다.
시간적 지역성 (Temporal Locality)
한 번 접근한 데이터는 가까운 미래에 다시 접근될 가능성이 높다.
// i 변수가 루프 내에서 반복적으로 사용 → 시간적 지역성
for (int i = 0; i < n; i++) {
sum += arr[i];
}
공간적 지역성 (Spatial Locality)
접근한 데이터 주변 주소의 데이터도 곧 접근될 가능성이 높다.
// arr[0], arr[1], arr[2]... 순서대로 접근 → 공간적 지역성
for (int i = 0; i < n; i++) {
sum += arr[i];
}
캐시는 이 원리를 이용해 데이터를 캐시 라인(Cache Line) 단위(보통 64바이트)로 통째로 가져온다. 한 요소를 가져올 때 인접 요소도 함께 캐시에 올리는 것이다.
캐시 교체 정책 (Eviction Policy)
캐시가 가득 찼을 때 어느 데이터를 버릴지 결정하는 정책이다.
| 정책 | 설명 |
|---|---|
| LRU (Least Recently Used) | 가장 오래 전에 사용된 데이터 제거 (가장 많이 쓰임) |
| LFU (Least Frequently Used) | 사용 빈도가 가장 낮은 데이터 제거 |
| FIFO | 가장 먼저 들어온 데이터 제거 |
| Random | 무작위 제거 (구현 단순) |
실제 CPU 캐시는 완전한 LRU보다 구현이 단순한 Pseudo-LRU 등의 변형을 쓴다.
쓰기 정책 (Write Policy)
CPU가 캐시에 데이터를 쓸 때 RAM과 어떻게 동기화할지 결정하는 방식이다.
Write-Through
- 캐시에 쓰는 동시에 RAM에도 즉시 반영한다.
- 데이터 일관성이 보장되지만 쓰기마다 RAM 접근이 발생해 느리다.
Write-Back
- 캐시에만 쓰고, 해당 캐시 라인이 교체될 때 RAM에 반영한다.
- 쓰기 성능이 좋지만 캐시와 RAM이 일시적으로 불일치할 수 있다.
- 이 불일치 상태의 라인을 더티(dirty) 라인이라고 한다.
현대 CPU는 대부분 Write-Back 방식을 사용한다.
캐시 일관성 (Cache Coherence)
멀티코어 CPU에서는 각 코어가 독립적인 L1/L2 캐시를 가진다. 같은 메모리 주소를 여러 코어가 각자의 캐시에 올렸을 때 한 코어가 값을 수정하면 다른 코어의 캐시가 오래된 값(stale data)을 갖게 된다.
이를 해결하기 위한 프로토콜이 MESI 프로토콜이다.
M (Modified) : 이 코어 캐시만 최신 값, RAM과 불일치
E (Exclusive) : 이 코어 캐시만 보유, RAM과 일치
S (Shared) : 여러 코어가 같은 값을 캐시에 보유
I (Invalid) : 캐시 라인이 유효하지 않음
한 코어가 데이터를 수정하면 다른 코어의 해당 캐시 라인은 Invalid 상태로 전환된다. 다음에 그 코어가 데이터를 읽으려 하면 캐시 미스가 발생하고, 최신 값을 다시 가져온다.
캐시를 고려한 코드 작성
2D 배열 순회 — 행 우선 vs 열 우선
int arr[1000][1000];
// 좋은 예: 행 우선 순회 (공간적 지역성 활용)
for (int i = 0; i < 1000; i++)
for (int j = 0; j < 1000; j++)
sum += arr[i][j];
// 나쁜 예: 열 우선 순회 (캐시 미스 빈발)
for (int j = 0; j < 1000; j++)
for (int i = 0; i < 1000; i++)
sum += arr[i][j];
C/C++에서 2D 배열은 행(row) 단위로 메모리에 저장되므로, 열 우선 순회는 캐시 라인을 넘나들며 미스를 반복적으로 유발한다.
False Sharing
멀티스레드 환경에서 서로 다른 스레드가 다른 변수를 수정하더라도 그 변수들이 같은 캐시 라인 안에 있으면 캐시 무효화가 반복 발생한다.
// 문제: counter[0]과 counter[1]이 같은 캐시 라인에 위치할 수 있음
int counter[2];
// Thread 1: counter[0]++
// Thread 2: counter[1]++
// → 서로 관련 없지만 같은 캐시 라인이라 계속 무효화됨
이를 피하려면 패딩(padding)을 추가해 변수들을 서로 다른 캐시 라인에 배치한다.
정리
- 캐시 메모리는 CPU와 RAM의 속도 차이를 줄이기 위한 계층적 구조다.
- L1 < L2 < L3 순서로 크기가 커지고 속도가 느려진다.
- 시간적 지역성, 공간적 지역성을 활용해 캐시 히트율을 높인다.
- 캐시 라인 단위로 데이터를 가져오기 때문에, 메모리 접근 패턴이 성능에 직접 영향을 준다.
- 멀티코어 환경에서는 MESI 같은 캐시 일관성 프로토콜로 코어 간 데이터 동기화를 유지한다.