좋아. 네가 만든(제미나이) 자료는 흐름/구성은 거의 완성형인데, “진짜 완벽”으로 만들려면 아래 5가지를 보강해야 해:

  1. 캐시 라인 / stride(보폭) / TLB까지 연결 (왜 ‘10000칸 점프’가 치명적인지)
  1. 2D 배열의 실제 배치(row-major)를 주소 관점으로 더 명확히
  1. 측정 실험의 함정(최적화/Debug/페이지폴트/메모리 크기) 정리
  1. 실무 적용 패턴(게임 서버/엔진 코드에서 어떻게 고치는지) 제시
  1. 다음 강의(파이프라인/메모리모델)로 논리적 다리를 더 단단히

아래는 그 보강을 반영한 “최종 완전판 학습자료”야. (강의 흐름 유지 + 깊이 추가)


[최종 완전판 심화 학습] 컴퓨터 구조 원리: 캐시(Cache)와 지역성(Locality)

— 메모리 계층, 캐시 라인, stride, TLB까지 한 번에


  1. 개요: 왜 지금 ‘컴퓨터 구조’인가?

멀티스레드/게임 서버를 공부하다가 갑자기 캐시·파이프라인을 배우는 이유는 단순한 “교양”이 아니라 필수 선수지식이기 때문이야.

다음 장: 메모리 모델(Memory Model), atomic, 동기화

이 내용들은 결국
“CPU가 메모리를 어떻게 읽고 쓰는가(캐시/재배치/가시성)”를 기반으로 성립한다.

즉,

캐시 = 왜 같은 코드가 어떤 접근 방식에선 3~4배 빨라지는지

파이프라인/재배치 = 왜 CPU는 순서대로만 안 움직이는지

메모리 모델 = 그 현실 위에서 멀티스레드가 안전해지는 규칙

이번 파트는 그 중 캐시와 지역성을 실습으로 “체감”하는 수업.


  1. 하드웨어 관점: CPU와 RAM의 거리(병목의 본질)

2.1 CPU vs RAM 역할

CPU: 연산 장치(덧셈/비교/분기 등)

RAM: 프로그램 데이터가 올라가는 주 메모리
(우리가 말하는 스택/힙/전역 메모리의 실체)

2.2 핵심 병목: 연산보다 “가져오는 것”이 훨씬 비싸다

CPU는 매우 빠르지만, 대부분의 프로그램은:

데이터를 RAM에서 읽고

그 데이터를 기반으로 연산한다

즉 “연산이 느려서”가 아니라
메모리 접근(Load/Store) 지연이 병목이 되는 경우가 많다.


  1. 해결책: 캐시(Cache) — CPU 옆의 초고속 임시 저장소

3.1 캐시의 정의

캐시는 CPU 코어 근처에 있는 고속 임시 저장소다.

RAM에서 한 번 가져온 데이터는

“다음에 또 쓸 수도 있으니” 캐시에 복사해두고

이후엔 RAM 대신 캐시에서 빨리 읽는다

3.2 캐시 계층 구조(피라미드)

CPU는 보통 아래 순서로 데이터 존재 여부를 확인한다.

레지스터 (최상위, 초고속, 초소용량)

L1 캐시 (가장 가까움, 가장 빠름)

L2 캐시

L3 캐시 (종종 코어 공유)

RAM (가장 느림)

용어:

Cache Hit: 캐시에 데이터가 있어 RAM까지 안 감

Cache Miss: 캐시에 없어 하위 계층(결국 RAM)까지 감


  1. 캐시가 “맞출 수 있는” 이유: Locality(지역성) 2가지

캐시는 용량이 작으니 “무엇을 남길지”를 예측해야 한다.
그 예측의 근거가 지역성(Locality)이다.

4.1 Temporal Locality (시간적 지역성)

“방금 쓴 데이터는 곧 또 쓸 확률이 높다.”

예시:

루프에서 sum += ... 할 때 sum은 계속 재사용됨

게임 서버에서 같은 유저/몬스터 상태를 짧은 시간 안에 반복 조회

4.2 Spatial Locality (공간적 지역성)

“한 번 접근한 주소 주변 데이터도 곧 쓸 확률이 높다.”

가장 중요한 포인트:

CPU는 RAM에서 딱 한 값만 가져오지 않는다.

보통 캐시 라인(Cache Line) 단위(예: 64바이트 같은 “블록”)로 가져온다.

그래서 “옆 데이터”가 같이 캐시에 올라온다.

비유(네 자료의 택배 비유가 아주 좋음):

택배 1개 필요해도, “동 전체 박스를 같이 들고 오는” 느낌


  1. 실습: 2차원 배열 순회로 캐시를 “눈으로 확인하기”

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;
}

}


  1. 상세 분석: 왜 “연산 횟수 동일”인데 성능이 갈리는가?

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는 캐시가 “인접 블록을 가져오겠다”는 공간적 지역성 전략을

프로그램이 일부러 깨버리는 패턴이다.


  1. (심화 추가) 여기서 진짜 중요한 “숨은 2번째 요인”: TLB

네 자료가 이미 충분히 좋지만, 완벽을 위해 한 단계 더 붙이면 이거야:

메모리 접근은 “주소 변환”도 필요하다.

가상주소 → 물리주소 변환에 쓰이는 캐시가 TLB(Translation Lookaside Buffer)

Column-major처럼 큰 stride로 점프하면

서로 다른 페이지(보통 4KB 단위)를 계속 밟게 되고

TLB miss가 늘어날 수 있다

즉 Column-major는:

캐시 미스 + TLB 미스가 동시에 터질 수 있어서 더 느려진다.

(강의에서 “왜 3~4배나?”를 더 설득력 있게 설명하는 핵심 보강 포인트)


  1. 실험 측정 시 주의사항(완성도를 올려주는 체크리스트)

실제로 “측정”을 할 때 흔한 함정들:

  1. Debug 빌드 vs Release 빌드

Debug는 최적화가 거의 없어서 결과가 왜곡될 수 있음
→ 성능 비교는 Release 권장

  1. 메모리 크기(400MB)로 인한 OS 영향

페이지 폴트, 메모리 압박 등 외부 요인이 개입 가능
→ 원리만 확인하면 크기 줄여도 OK

  1. 측정 도구 정확도

GetTickCount64는 간단하지만 정밀 측정엔 부족할 수 있음
→ 더 정확히는 QueryPerformanceCounter


  1. 실무 적용: “캐시 친화적(Cache Friendly)” 코딩이란?

이 강의가 게임 서버/엔진에서 가치가 큰 이유는 실무에서 바로 튜닝 포인트가 나오기 때문이야.

9.1 대표 원칙

연속된 메모리를 연속적으로 접근하라 (row-major처럼)

구조체/배열 배치를 연속으로 만들라 (vector, SoA 고려)

포인터로 흩어진 객체를 쫓아다니는 접근을 줄여라

9.2 게임 서버 예시

몬스터 리스트를 매 틱 순회할 때

vector(연속) 순회가

list<Monster*>(흩어짐)보다 캐시 친화적인 경우가 많다
(상황에 따라 다르지만 “데이터 지역성” 관점의 1차 결론은 이쪽)


  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가 순서를 바꿔도 성능이 좋아지는지 → 근데 멀티스레드에선 왜 위험한지”를 캐시와 연결해서 설명하면 진짜 깔끔하게 이어진다.)

profile
李家네_공부방

0개의 댓글