좋아. 네가 만든(제미나이) 자료는 흐름/구성은 거의 완성형인데, “진짜 완벽”으로 만들려면 아래 5가지를 보강해야 해:
아래는 그 보강을 반영한 “최종 완전판 학습자료”야. (강의 흐름 유지 + 깊이 추가)
[최종 완전판 심화 학습] 컴퓨터 구조 원리: 캐시(Cache)와 지역성(Locality)
— 메모리 계층, 캐시 라인, stride, TLB까지 한 번에
멀티스레드/게임 서버를 공부하다가 갑자기 캐시·파이프라인을 배우는 이유는 단순한 “교양”이 아니라 필수 선수지식이기 때문이야.
다음 장: 메모리 모델(Memory Model), atomic, 동기화
이 내용들은 결국
“CPU가 메모리를 어떻게 읽고 쓰는가(캐시/재배치/가시성)”를 기반으로 성립한다.
즉,
캐시 = 왜 같은 코드가 어떤 접근 방식에선 3~4배 빨라지는지
파이프라인/재배치 = 왜 CPU는 순서대로만 안 움직이는지
메모리 모델 = 그 현실 위에서 멀티스레드가 안전해지는 규칙
이번 파트는 그 중 캐시와 지역성을 실습으로 “체감”하는 수업.
2.1 CPU vs RAM 역할
CPU: 연산 장치(덧셈/비교/분기 등)
RAM: 프로그램 데이터가 올라가는 주 메모리
(우리가 말하는 스택/힙/전역 메모리의 실체)
2.2 핵심 병목: 연산보다 “가져오는 것”이 훨씬 비싸다
CPU는 매우 빠르지만, 대부분의 프로그램은:
데이터를 RAM에서 읽고
그 데이터를 기반으로 연산한다
즉 “연산이 느려서”가 아니라
메모리 접근(Load/Store) 지연이 병목이 되는 경우가 많다.
3.1 캐시의 정의
캐시는 CPU 코어 근처에 있는 고속 임시 저장소다.
RAM에서 한 번 가져온 데이터는
“다음에 또 쓸 수도 있으니” 캐시에 복사해두고
이후엔 RAM 대신 캐시에서 빨리 읽는다
3.2 캐시 계층 구조(피라미드)
CPU는 보통 아래 순서로 데이터 존재 여부를 확인한다.
레지스터 (최상위, 초고속, 초소용량)
L1 캐시 (가장 가까움, 가장 빠름)
L2 캐시
L3 캐시 (종종 코어 공유)
RAM (가장 느림)
용어:
Cache Hit: 캐시에 데이터가 있어 RAM까지 안 감
Cache Miss: 캐시에 없어 하위 계층(결국 RAM)까지 감
캐시는 용량이 작으니 “무엇을 남길지”를 예측해야 한다.
그 예측의 근거가 지역성(Locality)이다.
4.1 Temporal Locality (시간적 지역성)
“방금 쓴 데이터는 곧 또 쓸 확률이 높다.”
예시:
루프에서 sum += ... 할 때 sum은 계속 재사용됨
게임 서버에서 같은 유저/몬스터 상태를 짧은 시간 안에 반복 조회
4.2 Spatial Locality (공간적 지역성)
“한 번 접근한 주소 주변 데이터도 곧 쓸 확률이 높다.”
가장 중요한 포인트:
CPU는 RAM에서 딱 한 값만 가져오지 않는다.
보통 캐시 라인(Cache Line) 단위(예: 64바이트 같은 “블록”)로 가져온다.
그래서 “옆 데이터”가 같이 캐시에 올라온다.
비유(네 자료의 택배 비유가 아주 좋음):
택배 1개 필요해도, “동 전체 박스를 같이 들고 오는” 느낌
5.1 실습 목적
연산량이 같은데도
접근 패턴만 바꿨을 뿐인데
시간이 3~4배 차이나는 이유를 캐시로 설명한다
5.2 실습 코드(원형 유지 + 실전 주의사항 반영)
⚠️ 10000×10000 int(4바이트) = 약 400MB
PC 메모리/환경에 따라 느리거나 실패할 수 있음.
원리가 목적이면 5000으로 줄여도 결과는 동일하게 관측됨.
#include
#include <windows.h>
#include
using namespace std;
static int buffer[10000][10000];
int main() {
memset(buffer, 0, sizeof(buffer));
// Case 1: Row-major (행 우선)
{
ULONGLONG start = GetTickCount64();
long long sum = 0;
for (int i = 0; i < 10000; i++)
for (int j = 0; j < 10000; j++)
sum += buffer[i][j];
ULONGLONG end = GetTickCount64();
cout << "Row-major: " << (end - start) << " ms, sum=" << sum << endl;
}
// Case 2: Column-major (열 우선)
{
ULONGLONG start = GetTickCount64();
long long sum = 0;
for (int i = 0; i < 10000; i++)
for (int j = 0; j < 10000; j++)
sum += buffer[j][i];
ULONGLONG end = GetTickCount64();
cout << "Column-major: " << (end - start) << " ms, sum=" << sum << endl;
}
}
6.1 전제: C/C++ 2D 배열은 Row-major로 메모리에 저장된다
buffer[i][j]는 실제 메모리에서 이렇게 “줄줄이” 배치된다:
buffer[0][0], buffer[0][1], ..., buffer[0][9999],
buffer[1][0], buffer[1][1], ...
즉 행 하나가 연속이다.
6.2 Case 1 (Row-major): Cache Hit 연속 발생
접근 순서:
(0,0) → (0,1) → (0,2) → … → (0,9999) → (1,0) …
이때 CPU가 (0,0)을 읽을 때:
캐시 라인 단위로 주변 데이터를 같이 당겨온다
(예: 64바이트 캐시 라인이면 int 4바이트 기준 대략 16개가 한 번에 들어온다)
그래서 곧바로 다음 접근:
(0,1), (0,2), … 는 이미 캐시에 있을 확률이 높다
→ RAM 접근이 거의 필요 없어짐
→ 매우 빠름
6.3 Case 2 (Column-major): Cache Miss + “stride(보폭)” 문제
접근 순서:
(0,0) → (1,0) → (2,0) → … → (9999,0) → (0,1) …
이게 왜 치명적이냐?
buffer[1][0]은 buffer[0][0] 바로 옆이 아니라
“한 행(10000개 int)”만큼 떨어져 있다.
stride(보폭) 관점:
한 번 점프할 때마다 대략 10000 * 4 bytes = 40000 bytes(약 40KB)씩 이동
그런데 캐시 라인은 보통 수십 바이트 단위
→ 매번 완전히 다른 캐시 라인을 가져오게 됨
→ 캐시 히트가 거의 발생하지 않음
→ RAM까지 내려가는 비율이 크게 증가
결론:
Case 2는 캐시가 “인접 블록을 가져오겠다”는 공간적 지역성 전략을
프로그램이 일부러 깨버리는 패턴이다.
네 자료가 이미 충분히 좋지만, 완벽을 위해 한 단계 더 붙이면 이거야:
메모리 접근은 “주소 변환”도 필요하다.
가상주소 → 물리주소 변환에 쓰이는 캐시가 TLB(Translation Lookaside Buffer)
Column-major처럼 큰 stride로 점프하면
서로 다른 페이지(보통 4KB 단위)를 계속 밟게 되고
TLB miss가 늘어날 수 있다
즉 Column-major는:
캐시 미스 + TLB 미스가 동시에 터질 수 있어서 더 느려진다.
(강의에서 “왜 3~4배나?”를 더 설득력 있게 설명하는 핵심 보강 포인트)
실제로 “측정”을 할 때 흔한 함정들:
Debug는 최적화가 거의 없어서 결과가 왜곡될 수 있음
→ 성능 비교는 Release 권장
페이지 폴트, 메모리 압박 등 외부 요인이 개입 가능
→ 원리만 확인하면 크기 줄여도 OK
GetTickCount64는 간단하지만 정밀 측정엔 부족할 수 있음
→ 더 정확히는 QueryPerformanceCounter
이 강의가 게임 서버/엔진에서 가치가 큰 이유는 실무에서 바로 튜닝 포인트가 나오기 때문이야.
9.1 대표 원칙
연속된 메모리를 연속적으로 접근하라 (row-major처럼)
구조체/배열 배치를 연속으로 만들라 (vector, SoA 고려)
포인터로 흩어진 객체를 쫓아다니는 접근을 줄여라
9.2 게임 서버 예시
몬스터 리스트를 매 틱 순회할 때
vector(연속) 순회가
list<Monster*>(흩어짐)보다 캐시 친화적인 경우가 많다
(상황에 따라 다르지만 “데이터 지역성” 관점의 1차 결론은 이쪽)
캐시는 CPU 옆에 있는 고속 임시 저장소이며, L1/L2/L3 계층이 있다.
CPU는 RAM에서 값을 가져올 때 캐시 라인 단위로 “블록”을 가져온다.
캐시는 지역성(Locality) 가정으로 성능을 낸다.
Temporal: 방금 쓴 건 또 쓴다
Spatial: 옆에 있는 것도 곧 쓴다
2D 배열은 row-major로 저장되므로
buffer[i][j]는 연속 접근 → 캐시 히트 ↑ → 빠름
buffer[j][i]는 큰 stride 점프 → 캐시/TLB 미스 ↑ → 느림(3~4배)
이 이해는 다음 주제(파이프라인/재배치/메모리 모델)의 기반이다.
원하면 내가 이 “최종 완전판”을 기반으로 같은 포맷 그대로
다음 강의 CPU 파이프라인(코드 재배치)도 만들어줄게.
(특히 “왜 컴파일러/CPU가 순서를 바꿔도 성능이 좋아지는지 → 근데 멀티스레드에선 왜 위험한지”를 캐시와 연결해서 설명하면 진짜 깔끔하게 이어진다.)