동일한 단어가 여러 계층에서 등장하는 것을 깨달았을 것이다.
이 문서는 전체 그림을 그려서 각 개념이 어디에 위치하고, 왜 존재하는지 설명한다.
| 계층 | 구성 요소 | 역할 | 관리 단위 | 속도 |
|---|---|---|---|---|
| 5. 애플리케이션 | new, malloc(), std::vector | 객체 생성/소멸 | 객체 | - |
| ↓ | ||||
| 4. 메모리 할당자 | jemalloc, tcmalloc | 효율적 메모리 분배 | 바이트~MB | ~50ns |
| Thread Cache, Arena, Span | ||||
| ↓ | ||||
| 3. OS 커널 | Virtual Memory | 가상→물리 매핑 | 4KB 페이지 | ~1μs |
mmap(), Page Table, TLB | ||||
| ↓ | ||||
| 2. 물리 메모리 | DRAM | 실제 데이터 저장 | 바이트 | ~100ns |
| ↓ | ||||
| 1. CPU 캐시 | L1/L2/L3 SRAM | 빠른 접근 제공 | 64B 캐시라인 | ~1-10ns |
int* p = new int;를 호출하면:
| 단계 | 계층 | 동작 |
|---|---|---|
| 1 | 애플리케이션 | new int 호출 |
| 2 | 할당자 | Thread Cache에서 16바이트 블록 반환 |
| 3 | 할당자 | (캐시 미스 시) Arena에서 Span 할당 |
| 4 | 할당자 | (메모리 부족 시) OS에 mmap() 요청 |
| 5 | OS | 가상 주소 공간 예약 (아직 물리 메모리 할당 안 함!) |
| 6 | 애플리케이션 | *p = 42; (첫 접근) |
| 7 | CPU/MMU | 가상→물리 변환 시도 → Page Fault 발생! |
| 8 | OS | 물리 페이지 할당, Page Table 업데이트 |
| 9 | CPU | DRAM에서 읽어 캐시에 로드, 연산 수행 |
핵심 인사이트: malloc()은 물리 메모리를 할당하지 않는다. 첫 접근 시 OS가 할당한다.
"캐시"라는 단어가 혼란스러운 이유: 서로 다른 세 가지 의미로 사용된다.
구조:
| 위치 | 캐시 | 크기 | 특징 |
|---|---|---|---|
| 코어별 | L1 | 32KB/core | 가장 빠름 (~1ns) |
| 코어별 | L2 | 256KB/core | |
| 공유 | L3 | 8-32MB | 전체 코어 공유 |
| 외부 | DRAM | 8-128GB | 가장 느림 (~100ns) |
특징:
왜 중요한가:
// 나쁜 예: 캐시 라인 낭비
struct Bad {
int id; // 4 bytes
char padding[60]; // 60 bytes (의도치 않은 패딩)
int value; // 4 bytes ← 다른 캐시 라인!
};
// 좋은 예: 캐시 라인 효율적 사용
struct Good {
int id; // 4 bytes
int value; // 4 bytes ← 같은 캐시 라인
};
// TLS 변수 선언
thread_local int counter = 0; // 각 스레드가 자신만의 복사본 가짐
void increment() {
counter++; // 다른 스레드와 충돌 없음
}
메모리 구조:
| Thread 0 | Thread 1 | Thread 2 |
|---|---|---|
| TLS 영역 | TLS 영역 | TLS 영역 |
counter: 5 | counter: 3 | counter: 8 |
errno: 0 | errno: 2 | errno: 0 |
특징:
jemalloc의 Thread Cache, tcmalloc의 ThreadCache:
Thread Cache 구조 (TLS에 저장):
| Size Class | 상태 | 설명 |
|---|---|---|
| 8B | [■][■][□][□][□] | 2개 할당됨, 3개 여유 |
| 16B | [■][□][□][□][□] | 1개 할당됨 |
| 32B | [■][■][■][□][□] | 3개 할당됨 |
| 64B | [□][□][□][□][□] | 모두 여유 |
특징:
| 구분 | CPU 캐시 | TLS | 할당자 캐시 |
|---|---|---|---|
| 관리 주체 | 하드웨어 | OS/런타임 | 할당자 라이브러리 |
| 목적 | DRAM 지연 숨기기 | 스레드별 데이터 | 락 없는 할당 |
| 단위 | 64B 캐시라인 | 변수 단위 | 객체 단위 |
| 제어 가능 | 불가 (힌트만) | 가능 | 가능 |
| 위치 | CPU 칩 내부 | 각 스레드 스택 근처 | 힙 |
1980년대 초기 시스템:
프로그램 A: "나는 주소 0x1000에 데이터를 저장할 거야"
프로그램 B: "나도 0x1000에 저장해야 하는데..."
결과: 충돌! 데이터 덮어쓰기!
문제점:
1. 프로그램들이 물리 주소 직접 사용 → 충돌
2. 프로그램 크기 > 물리 메모리 → 실행 불가
3. 메모리 보호 없음 → 버그가 전체 시스템 크래시
| 프로그램 A | 프로그램 B | 물리 메모리 |
|---|---|---|
| 가상 0x1000 → | 물리 0x5000: A의 데이터 | |
| 가상 0x2000 → | ||
| 가상 0x1000 → | 물리 0x8000: B의 데이터 |
핵심 개념:
주소 변환 과정:
| 단계 | 동작 |
|---|---|
| 1 | 가상 주소 0x12345678 |
| 2 | Page Number: 0x12345, Offset: 0x678 |
| 3 | Page Table 조회: 0x12345 → 0x7ABCD |
| 4 | 물리 주소: 0x7ABCD678 |
문제: Page Table 조회가 느리다
메모리 접근 시마다 Page Table 조회 → 2배 느려짐!
해결: TLB (Translation Lookaside Buffer)
| 구성 요소 | 역할 | 속도 |
|---|---|---|
| TLB (CPU 내부 캐시) | 가상→물리 매핑 캐시 | 1 사이클 |
| Page Table (메모리) | 전체 매핑 저장 | 100+ 사이클 |
할당자와의 연결:
// 이 시점에는 물리 메모리 할당 안 됨!
void* p = mmap(NULL, 1GB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
// 첫 접근 시 Page Fault → 그때 물리 페이지 할당
p[0] = 42; // Page Fault! → OS가 물리 페이지 할당
왜 이렇게 하는가:
| 항목 | 내용 |
|---|---|
| CPU | 단일 코어 |
| 메모리 | 수십 MB |
| malloc 구현 | 단순 free list |
| 락 경합 | 없음 (코어가 하나) |
| 주요 문제 | 단편화 |
당시 malloc:
static struct block* free_list;
static pthread_mutex_t lock; // 하나의 전역 락
void* malloc(size_t size) {
pthread_mutex_lock(&lock); // 단일 코어라 거의 경합 없음
// free_list에서 맞는 블록 찾기
pthread_mutex_unlock(&lock);
}
| 항목 | 내용 |
|---|---|
| CPU | 2-4 코어 |
| 메모리 | 수 GB |
| 문제 | 전역 락 = 병목! |
| 4개 코어가 malloc() 호출 → 3개는 대기 |
문제 발생:
Thread 0: malloc() → lock() → [작업] → unlock()
Thread 1: malloc() → lock() → [대기...대기...] → [작업]
Thread 2: malloc() → lock() → [대기...대기...대기...] → [작업]
해결: tcmalloc 등장 (2005, Google)
| 구조 | 역할 |
|---|---|
| Thread Cache (코어별) | 락 없이 빠른 할당 |
| Central Heap (공유) | 가끔만 접근 (락 필요) |
성능 향상:
| 항목 | 내용 |
|---|---|
| CPU | 8-16 코어, NUMA 아키텍처 |
| 메모리 | 64-128GB, 노드별 분리 |
| 새로운 문제 | Node 0 스레드 → Node 1 메모리 = 느림 |
| 메모리 128GB+ → Page Table 거대화 → TLB 미스 |
NUMA 구조:
| NUMA Node 0 | NUMA Node 1 |
|---|---|
| Core 0-7 | Core 8-15 |
| Local DRAM 64GB | Local DRAM 64GB |
| ↔ 느린 연결 ↔ |
jemalloc의 대응:
| 항목 | 내용 |
|---|---|
| CPU | 64+ 코어 |
| 새로운 문제 | 64개 스레드 × Thread Cache = 메모리 낭비 |
| 스레드가 코어 간 이동 → 이전 Thread Cache 접근 = 원격 |
Per-CPU Cache의 장점:
| 방식 | 동작 |
|---|---|
| Per-Thread (기존) | Thread 0 → Core 0 → Core 3 이동 시, Cache는 Core 0 근처 (원격!) |
| Per-CPU (현재) | Thread 0 → Core 0 → Core 3 이동 시, Core 3 Cache 사용 (로컬!) |
tcmalloc의 대응:
| 시대 | 하드웨어 변화 | 문제 | 해결책 |
|---|---|---|---|
| 1990s | 단일 코어 | 단편화 | Best-fit, Buddy system |
| 2000s | 멀티코어 | 락 경합 | Thread Cache (tcmalloc) |
| 2010s | NUMA, 대용량 | 원격 접근, TLB 미스 | Arena per CPU, Extent |
| 2020s | 수십 코어 | 캐시 낭비, 스레드 이동 | Per-CPU Cache, HugePage |
결론: 할당자 진화는 하드웨어 변화에 대한 대응이다.
| 단계 | 계층 | 동작 | 조건 |
|---|---|---|---|
| 1 | 애플리케이션 | malloc(64) 호출 | |
| 2 | jemalloc | Thread Cache 확인 | |
| Size class: 64B | |||
| → 여유 있음: 즉시 반환 (~20ns) | 95% 여기서 끝 | ||
| → 없음: 다음 단계 | |||
| 3 | jemalloc | Arena에서 Slab 할당 | 캐시 미스 |
| Bitmap에서 빈 슬롯 찾기 (O(1)) | |||
| → 반환 (~100ns) | ~4% | ||
| 4 | jemalloc | Extent 할당 요청 | Slab 고갈 |
| Retained extents 확인 | |||
| → 재사용 또는 OS 요청 | ~0.9% | ||
| 5 | OS | mmap() 호출 | extent 없음 |
| 가상 주소 예약 (물리 할당 X) | ~0.1% | ||
| 6 | 애플리케이션 | *ptr = 42; (첫 쓰기) | |
| 7 | MMU | Page Fault 발생! | |
| 8 | OS 커널 | 물리 페이지 할당 | |
| Page Table, TLB 업데이트 | |||
| 9 | CPU | DRAM → L1 캐시 로드 → 쓰기 완료 |
| 단계 | 소요 시간 | 빈도 |
|---|---|---|
| Thread Cache 히트 | ~20ns | 95%+ |
| Arena 할당 | ~100ns | ~4% |
| Extent 할당 (캐시됨) | ~500ns | ~0.9% |
| mmap() + Page Fault | ~10,000ns | ~0.1% |
핵심: 대부분의 할당은 Thread Cache에서 끝난다.
| 용어 | 의미 | 위치 |
|---|---|---|
| CPU 캐시 | L1/L2/L3 SRAM | CPU 칩 내부 (하드웨어) |
| TLS | Thread Local Storage | 각 스레드의 스택 근처 (OS 관리) |
| Thread Cache | 할당자의 스레드별 객체 풀 | TLS에 저장된 소프트웨어 구조 |
| Arena | 할당자의 메모리 풀 | 힙 (여러 스레드 공유 가능) |
| Extent/Span | OS에서 받은 큰 메모리 덩어리 | 힙 (2MB 단위) |
| 가상 메모리 | 프로세스별 주소 공간 | OS가 관리하는 추상화 |
| 물리 메모리 | 실제 DRAM | 하드웨어 |
| Page Table | 가상→물리 매핑 테이블 | OS 커널 내 자료구조 |
| TLB | Page Table 캐시 | CPU 내부 (하드웨어) |
| HugePage | 2MB 크기 페이지 | OS/하드웨어 기능 |
Part 2가 다루는 내용은 소프트웨어 계층(할당자)이지만, 이 계층은 하드웨어 변화에 대응하며 진화해왔다:
"메모리"라는 단어가 혼란스러웠다면:
각각 다른 계층에서 다른 목적으로 사용되며, 현대 할당자는 이 모든 계층을 이해하고 최적화한다.