CS:APP 한글 노트
제6장 · 메모리 계층
CHAPTER 06

메모리 계층

The Memory Hierarchy

CPU는 1나노초마다 명령을 처리하는데, 메인 메모리에 한 번 다녀오는 데 100나노초가 걸린다. 이 100배 차이를 어떻게 견딜까? 답은 계층(hierarchy)이다. 작고 빠른 캐시(cache)와 크고 느린 메모리를 층층이 쌓아두고, 프로그램이 참조하는 데이터의 지역성(locality)에 베팅한다. 이 장은 디스크와 SSD의 물리적 동작부터 캐시 라인 단위 인덱싱, 그리고 같은 알고리즘이 100배 차이로 갈리는 행렬 곱셈 사례까지 따라간다.

6.1저장 기술 (Storage Technologies)

모든 메모리 계층의 시작은 물리적 저장 장치다. 트랜지스터, 캐패시터, 자기 디스크의 표면, NAND 플래시 셀 — 각자 속도, 용량, 가격, 휘발성에서 서로 다른 성격을 가진다. 결국 “무엇이 무엇보다 100배 빠르고 1000배 비싼가”가 계층 전체의 모양을 결정한다.

6.1.1RAM: SRAM 대 DRAM

RAM(Random-Access Memory)은 두 종류로 갈린다. SRAM(Static RAM)과 DRAM(Dynamic RAM). 둘 다 “주소만 주면 임의 위치에 빠르게 접근”한다는 공통점이 있지만, 셀 구조와 비용이 완전히 다르다.

  • SRAM: 한 비트를 6개의 트랜지스터로 만든 쌍안정(bistable) 회로에 저장한다. 전원만 들어오면 값이 유지된다. 빠르고 안정적이지만 면적이 크고 비싸다. → CPU 내부 캐시(L1/L2/L3)에 사용.
  • DRAM: 한 비트를 트랜지스터 1개 + 캐패시터 1개로 저장한다. 캐패시터는 전하를 들고 있을 뿐이라 누설이 일어나고, 그래서 주기적으로 리프레시(refresh)해야 한다. 밀도가 높고 싸지만 느리다. → 메인 메모리에 사용.
특성SRAMDRAM
비트당 트랜지스터 수6개1개 + 캐패시터
접근 시간(상대)약 10×
리프레시 필요?아니오예 (수 ms 주기)
전원 끄면?휘발휘발
비트당 가격비쌈 (약 100×)저렴
주된 용도캐시 메모리메인 메모리, 프레임 버퍼

DRAM 칩의 행/열 접근

DRAM 칩은 내부적으로 슈퍼셀(supercell)을 2차원 격자로 배치한다(예: 16×16 = 256개의 슈퍼셀, 각 슈퍼셀이 8비트). 주소 한 번에 다 보내지 않고 두 단계로 나누어 보낸다.

  1. RAS(Row Access Strobe): 행(row) 주소를 보내면, 그 행 전체를 내부 행 버퍼로 읽어온다.
  2. CAS(Column Access Strobe): 열(column) 주소를 보내면, 행 버퍼에서 해당 슈퍼셀만 골라 데이터 핀으로 내보낸다.

같은 행에 연속해 접근하면 첫 RAS만 비싸고 그 뒤 CAS는 매우 빠르다. 이 점이 후술할 버스트 모드와 캐시 라인이 “연속한 바이트 묶음”인 이유와 직결된다.

DDR SDRAM: 동기 + 양 에지

오늘날의 메인 메모리는 DDR(Double Data Rate) SDRAM이다. SDRAM은 메모리 컨트롤러의 클럭에 동기화되어 동작하고, DDR은 클럭의 상승 에지하강 에지 양쪽에서 데이터를 보낸다(같은 주파수에 2배의 처리량). DDR3, DDR4, DDR5로 갈수록 프리페치 버퍼와 채널 폭이 늘어나며 대역폭이 커졌다.

메모

“메모리 모듈”에서 자주 보는 PC4-25600 같은 표기는 DDR4의 데이터 전송률(MT/s × 8B/채널)을 가리킨다. 25600 MB/s는 한 채널 기준 — 듀얼 채널이면 두 배가 된다.

비휘발성 메모리

ROM(Read-Only Memory)은 전원이 꺼져도 값이 남는 비휘발성 메모리다. 이름과 달리 PROM, EPROM, EEPROM처럼 다시 쓸 수 있는 종류도 있다. 플래시(flash) 메모리는 EEPROM의 진화형으로, SSD, USB 드라이브, 휴대폰 저장소의 기반 기술이다. 펌웨어 — 부팅 시 가장 먼저 실행되는 코드 — 도 보통 ROM/플래시에 산다.

6.1.2디스크 저장 (Magnetic Disks)

회전 디스크(HDD)는 한물갔다는 평이 많지만, 비트당 가격이 여전히 SSD보다 싸서 백업/대용량 보관에선 살아남았다. 구조도 직관적이라 “기계 시간과 컴퓨터 시간의 격차”를 가르치기에 좋다.

물리적 구조

  • 플래터(platter): 양면 자성 코팅된 원반. 보통 여러 장이 한 축(spindle)에 끼워진다.
  • 트랙(track): 플래터 표면의 동심원. 트랙들이 모인 같은 반지름의 묶음을 실린더(cylinder)라 한다.
  • 섹터(sector): 트랙을 따라 잘게 쪼갠 조각(보통 512B 또는 4KB). 디스크의 최소 전송 단위.
  • 헤드(head): 자성 표면에서 1나노미터 단위로 떠 있는 작은 판독기. 액추에이터 암(actuator arm)이 헤드를 안팎으로 움직인다.

접근 시간 = seek + rotational + transfer

한 섹터를 읽는 데 걸리는 시간은 세 부분으로 나뉜다.

  1. 탐색 시간(seek time): 헤드를 목표 트랙 위로 옮기는 시간. 보통 평균 3~9ms.
  2. 회전 지연(rotational latency): 목표 섹터가 헤드 아래로 돌아올 때까지 기다리는 시간. 평균은 한 바퀴 도는 시간의 절반이다.
    T_rot_avg = (1/2) × (60 / RPM) seconds
    7200 RPM이면 한 바퀴 8.33ms, 평균 회전 지연 약 4.17ms.
  3. 전송 시간(transfer time): 헤드 아래로 섹터가 지나가는 동안 비트를 읽는 시간. 한 바퀴를 N개 섹터로 나눴으면 한 섹터당 (60/RPM)/N 초.
연습 6.1

7200 RPM, 평균 seek 9ms, 트랙당 400 섹터인 디스크에서 임의의 섹터 하나를 읽는 평균 시간은?

풀이. 한 바퀴 = 60/7200 = 8.33ms. 평균 회전 지연 = 4.17ms. 한 섹터 전송 = 8.33/400 ≈ 0.02ms. 합계 ≈ 9 + 4.17 + 0.02 ≈ 13.19ms. seek와 회전 지연이 거의 전부를 차지한다.

주의

13ms ≈ 13,000,000 ns. CPU 사이클 0.3ns짜리 머신에서 약 4천만 사이클이다. 디스크 한 번 다녀오는 동안 CPU가 4천만 개의 명령을 실행할 수 있다. 디스크 접근이 “재앙급 비용”인 이유.

논리 블록 추상화

OS는 디스크의 (실린더, 표면, 섹터) 3축 좌표를 그대로 다루지 않는다. 디스크 컨트롤러가 이를 0부터 N−1까지 번호를 매긴 논리 블록(logical block)의 1차원 배열로 추상화해 보여준다. 이 매핑은 컨트롤러의 작은 펌웨어가 관리하며, 배드 섹터를 슬쩍 다른 곳으로 돌리는 일까지 한다.

6.1.3SSD (Solid State Disks)

SSD는 회전 부품이 없는 “디스크”다. NAND 플래시 메모리 칩과, 호스트에는 디스크처럼 보이게 해주는 플래시 변환 계층(FTL, Flash Translation Layer)으로 구성된다.

페이지와 블록

  • 페이지(page): 보통 4KB. 읽기와 쓰기의 단위지만, 쓰기에 제약이 있다.
  • 블록(block): 보통 32~128 페이지(즉 128KB~512KB). 지우기(erase)의 단위.

핵심 제약: 이미 쓰여진 페이지에 덮어쓰기를 할 수 없다. 새로운 값을 쓰려면 그 페이지가 속한 블록 전체를 먼저 지운 뒤에야 다시 쓸 수 있다. 이 비대칭이 SSD의 모든 동작을 흥미롭게 만든다.

연산대상 단위대략 시간
순차 읽기페이지~25 µs
순차 쓰기페이지(빈 페이지)~250 µs
지우기블록~2 ms

마모와 wear leveling

플래시 셀은 지우기/쓰기 사이클에 한도가 있다(예: 셀당 약 10만 회). 같은 블록만 반복해서 쓰면 그 블록만 먼저 죽는다. FTL은 마모 평준화(wear leveling)를 수행해, 쓰기 부하를 칩 전반에 골고루 분산한다. 또한 호스트가 “페이지 X를 덮어쓴다”고 명령하면 실제로는 새로운 빈 페이지에 새 값을 쓰고, 기존 페이지를 무효(stale)로 표시한 뒤 나중에 가비지 컬렉션으로 회수한다.

OS가 지원하는 TRIM 명령은 “이 페이지는 더 이상 안 쓴다”를 SSD에 알려서, FTL이 미리 회수할 수 있게 해 준다. 가비지 컬렉션 부담을 줄이고 일관된 성능을 유지하는 비결.

6.1.4저장 기술 동향

지난 수십 년의 추세는 한 줄로 요약된다: 밀도와 용량은 빠르게 늘었고, 접근 시간(특히 디스크의 기계적 부분)은 별로 줄지 않았다. DRAM 용량은 폭발적으로 늘었지만 latency는 더디게 개선됐고, CPU 사이클 시간은 그동안 100배 이상 짧아졌다. 그 결과가 바로 “CPU와 메모리 사이의 격차(memory wall)”다 — 캐시와 계층 설계가 점점 더 중요해진 이유.

[메모리 계층 피라미드]
                 ┌────────────┐
              L0 │   레지스터   │  < 1ns,  KB
                 ├────────────┤
              L1 │ L1 캐시(SRAM)│  ~1ns,  수십 KB
                 ├────────────┤
              L2 │   L2 캐시   │  ~5ns,  수백 KB
                 ├────────────┤
              L3 │   L3 캐시   │  ~15ns, 수 MB ~ 수십 MB
                 ├────────────┤
              L4 │ 메인 메모리(DRAM) │  ~100ns, 수 GB
                 ├────────────┤
              L5 │ 로컬 디스크(SSD) │  ~100µs, 수백 GB
                 ├────────────┤
              L6 │ 회전 디스크/원격│  ~ms ~ s, TB+
                 └────────────┘
       작고 빠르고 비쌈 ──────► 크고 느리고 쌈

6.2지역성 (Locality)

계층 구조가 통하는 이유는 단 하나, 잘 짠 프로그램은 지역성을 가진다는 경험칙 덕이다. 여기서 지역성은 두 가지로 나뉜다.

  • 시간적 지역성(temporal locality): 한 번 참조된 항목이 가까운 미래에 다시 참조될 가능성이 크다. 루프의 인덱스 변수, 자주 호출되는 함수가 대표적.
  • 공간적 지역성(spatial locality): 한 항목이 참조되면, 그 근처의 메모리 위치가 곧 참조될 가능성이 크다. 배열 순회, 명령어 페치가 대표적.

6.2.1데이터 참조의 지역성

int sumvec(int v[N]) {
    int i, sum = 0;
    for (i = 0; i < N; i++)
        sum += v[i];
    return sum;
}

이 짧은 함수에 두 종류의 지역성이 다 들어있다.

  • sum은 매 반복마다 읽히고 쓰인다 → 강한 시간적 지역성.
  • v[i]는 인덱스 0, 1, 2, …로 연속해서 접근된다 → 강한 공간적 지역성. 한 번 캐시에 올라온 블록 안에서 여러 번 히트한다.

이번엔 같은 함수를 step만큼 건너뛰며 읽도록 바꿔 보자.

int sumvec_stride(int v[N], int stride) {
    int i, sum = 0;
    for (i = 0; i < N; i += stride)
        sum += v[i];
    return sum;
}

stride가 1이면 좋은 공간적 지역성, stride가 늘어나면 점점 나빠진다 — 같은 캐시 블록 안에서 거의 한 번씩만 쓰고 다음 블록으로 건너뛴다. stride는 캐시 분석의 단골 주연이다.

6.2.2명령 페치의 지역성

프로그램의 명령(instruction)도 메모리에 사는 데이터다. 그래서 명령 페치도 지역성의 영향을 받는다. for 루프의 본문은 같은 명령들을 N번 페치한다 — 시간적 지역성. 분기(branch)가 없는 직선 코드는 메모리에서 연속한 주소를 페치한다 — 공간적 지역성.

그래서 현대 CPU는 명령 캐시(I-cache)데이터 캐시(D-cache)를 분리해 둔다(L1에서 분리, L2/L3에서는 통합). 명령 페치는 거의 100% 읽기 전용에다 패턴이 매우 규칙적이라 따로 다루는 편이 유리하다.

6.2.3지역성 요약

지역성이 좋은 코드의 특징을 거칠게 정리하면:

  • 같은 변수를 반복 사용하는 코드 → 시간적 지역성
  • 작은 stride로 연속 접근하는 코드 → 공간적 지역성
  • 큰 stride 또는 무작위 접근 코드 → 지역성 나쁨

“이 코드는 지역성이 좋은가?”라는 질문은 거의 항상 “이 코드는 캐시에 친화적인가?”로 환원된다.

6.3메모리 계층 (The Memory Hierarchy)

계층의 각 레벨은 자기 바로 아래 레벨의 일부를 캐시(cache)한다. 레지스터는 L1을 캐시하고, L1은 L2를, L2는 L3을, L3은 메인 메모리를, 메인 메모리는 디스크의 일부를 캐시한다. 가상 메모리(9장)에서 또 보겠지만, 메인 메모리도 결국 디스크의 거대한 가상 주소 공간을 부분적으로 캐시하는 캐시다.

6.3.1메모리 계층에서의 캐싱

레벨 k가 레벨 k+1을 캐시한다고 생각해 보자. 데이터는 블록(block) 단위로 두 레벨 사이를 오간다. 프로그램이 어떤 데이터 항목 d를 요청하면 두 가지 경우가 생긴다.

  • 캐시 히트(cache hit): d를 담은 블록이 이미 레벨 k에 있다. 그대로 돌려준다. 빠름.
  • 캐시 미스(cache miss): 레벨 k에 없다. 레벨 k+1에서 그 블록을 가져와 레벨 k에 채워 넣는다. 그 과정에서 기존 블록 하나를 쫓아내야(evict) 할 수 있다 — 희생자(victim) 블록.

미스의 종류

종류원인예방법
강제(compulsory) / cold처음 참조하는 블록 — 캐시에 있을 리 없음.피할 수 없음 (프리페치로 일부 가능)
충돌(conflict)여러 블록이 같은 셋(set)에 매핑되어 서로 쫓아냄.연관도(associativity)를 늘리거나 stride 변경
용량(capacity)활성 작업 집합(working set)이 캐시보다 큼.알고리즘 자체에서 작업 집합을 줄임 (블록킹)

6.3.2메모리 계층 개념 요약

모든 캐싱 시스템에는 같은 질문이 반복된다: 블록 크기는?, 어디에 둘 것인가(매핑)?, 누구를 쫓아낼 것인가(교체 정책)?, 쓰기는 어떻게? 하드웨어 캐시는 이 결정을 회로로 굳혔고, 소프트웨어 캐시(파일 시스템 페이지 캐시, 브라우저 캐시 등)는 알고리즘으로 구현한다. 대상은 다르지만 풀어야 하는 문제는 똑같다.

6.4캐시 메모리 (Cache Memories)

이제 하드웨어 캐시 자체를 들여다보자. 모든 SRAM 캐시는 본질적으로 같은 구조를 가진다 — S개의 셋(set), 셋마다 E개의 라인(line), 라인마다 한 개의 블록(block)(크기 B바이트)과 메타데이터.

6.4.1일반적 캐시 구성

총 데이터 용량은 단순히 곱셈이다.

C = S × E × B  bytes

m비트 주소는 캐시 인덱싱을 위해 세 부분으로 쪼개진다.

 ┌──────────────────┬──────────────┬──────────────┐
 │   tag (t bits)   │ set idx (s)  │ block off(b) │
 └──────────────────┴──────────────┴──────────────┘
   t = m − s − b      s = log2(S)    b = log2(B)
  1. set index로 어떤 셋을 볼지 선택.
  2. 그 셋 안의 E개 라인을 보면서 tag가 같은 라인이 있는지 검사. valid 비트도 같이 본다.
  3. 일치하는 라인이 있으면 block offset으로 블록 안의 정확한 바이트를 꺼낸다 — 히트.
  4. 아무도 일치하지 않으면 미스 → 블록을 메모리에서 가져와 채운다.
연습 6.2

m=64비트, S=64셋, E=8라인, B=64바이트인 캐시에서 t, s, b는 각각 몇 비트인가? 총 데이터 용량은?

풀이. s = log2(64) = 6, b = log2(64) = 6, t = 64 − 6 − 6 = 52. 데이터 용량 = 64 × 8 × 64 = 32,768 B = 32 KB. (현대 CPU의 L1d와 비슷한 수치다.)

6.4.2직접 매핑 캐시 (E = 1)

가장 단순한 형태. 셋마다 라인이 단 하나라, set index가 정해지면 후보 라인도 단 하나다. 하드웨어가 단순하고 빠르지만, 충돌 미스(conflict miss)에 약하다.

// 충돌 미스를 만드는 고전 예
float dotprod(float x[N], float y[N]) {
    float sum = 0.0;
    for (int i = 0; i < N; i++)
        sum += x[i] * y[i];
    return sum;
}

만약 xy가 메모리에서 정확히 캐시 크기만큼 떨어져 배치되면, 두 배열의 i번째 원소가 같은 셋으로 매핑되어 서로를 끊임없이 쫓아낸다 — thrashing. 매 반복마다 두 번씩 미스가 난다.

해결책: 배열 사이에 패딩(padding)을 끼워 정렬을 깨거나, 더 큰 연관도(higher associativity)의 캐시를 쓴다. 실무에서는 후자가 일반적.

6.4.3셋 연관 캐시 (1 < E < C/B)

셋마다 E개의 라인을 두고, set index로 셋을 고른 뒤 그 안에서 E개를 병렬로 tag 비교한다. E를 늘리면 충돌 미스는 줄지만 회로가 복잡해지고 hit time이 약간 느려진다. L1은 보통 8-way, L2는 16-way 정도가 흔한 타협점.

E-way 셋 연관에서 미스가 났을 때 어느 라인을 쫓아내느냐가 중요해진다. 흔히 LRU(Least-Recently Used) 또는 그 근사를 쓴다 — 가장 오랫동안 안 쓴 라인을 희생.

6.4.4완전 연관 캐시 (S = 1)

셋이 단 하나, 즉 모든 라인이 한 풀에 모여 있다. 어떤 블록도 어디든 갈 수 있다. 충돌 미스는 0이지만, 모든 라인의 tag를 동시에 비교해야 해서 회로가 매우 비싸다. 그래서 전체 캐시를 완전 연관으로 만드는 일은 거의 없고, 대신 작은 TLB(Translation Lookaside Buffer)에서 흔히 쓴다.

연관도충돌 미스하드웨어 비용주된 용도
직접 매핑 (E=1)많음저렴예전 L1, 일부 임베디드
셋 연관 (1<E<전체)중간중간현대 L1/L2/L3
완전 연관 (S=1)없음매우 비쌈작은 TLB, 일부 빅터림 캐시

6.4.5쓰기 이슈 (Write Issues)

읽기는 쉽다 — 히트면 돌려주고 미스면 가져온다. 쓰기는 두 가지 직교하는 결정이 있다.

(a) 히트 시: write-through 대 write-back

  • Write-through: 쓰기마다 캐시와 다음 레벨에 동시에 기록. 단순, 일관성 좋음, 그러나 버스 트래픽이 많다.
  • Write-back: 캐시에만 기록하고 라인에 dirty 비트를 표시. 라인이 쫓겨날 때 dirty면 그제서야 메모리에 쓴다. 트래픽은 줄지만 회로/일관성이 복잡.

(b) 미스 시: write-allocate 대 no-write-allocate

  • Write-allocate: 쓰기 미스가 나면 그 블록을 캐시로 가져온 뒤 캐시에 쓴다. 같은 블록을 곧 다시 건드릴 거라 가정.
  • No-write-allocate: 캐시는 건드리지 않고 그냥 다음 레벨에 직접 쓴다.

실제 조합은 보통 두 가지다.

  • Write-back + write-allocate: 쓰기 지역성이 좋다고 가정 → 현대 CPU 캐시의 표준.
  • Write-through + no-write-allocate: 단순함이 우선 → 일부 임베디드/단순 캐시.
메모

현대 CPU가 write-back + write-allocate를 쓰는 이유는 단순하다 — 쓰기는 보통 곧이어 같은 위치를 한 번 더 쓰거나 읽는다. 그러면 첫 쓰기 미스 한 번으로 블록을 끌어온 뒤, 그 다음 모든 접근이 히트가 된다.

6.4.6실제 캐시 계층의 구조

현대 CPU의 캐시는 다단계로 짜여 있다. 인텔 Core i7(Haswell 세대)을 예로:

레벨크기연관도블록접근 시간비고
L1d (데이터)32 KB8-way64 B~4 cy코어 전용
L1i (명령)32 KB8-way64 B~4 cy코어 전용
L2 (통합)256 KB8-way64 B~10 cy코어 전용
L3 (통합)8 MB16-way64 B~40 cy모든 코어 공유
메인 메모리GB대~200 cyDRAM

L1d/L1i는 분리, L2/L3은 통합. L1과 L2는 코어마다 따로 있고, L3은 멀티코어가 공유한다. 대부분의 CPU는 inclusive(상위 레벨이 하위 레벨의 부분집합)나 exclusive 정책을 골라 일관성을 단순화한다.

6.4.7캐시 파라미터의 성능 영향

핵심 지표 두 개:

  • 미스율(miss rate): 전체 참조 중 미스의 비율.
  • 적중 시간(hit time), 미스 패널티(miss penalty): 히트할 때 걸리는 시간, 미스 났을 때 추가로 드는 시간.

이 둘로 평균 메모리 접근 시간(AMAT, Average Memory Access Time)을 계산할 수 있다.

AMAT = hit_time + miss_rate × miss_penalty

예: hit time 1 cy, 미스율 3%, 미스 패널티 100 cy → AMAT = 1 + 0.03×100 = 4 cy. 미스율 1% 차이가 평균 접근 시간을 크게 흔든다.

파라미터늘리면…주의점
캐시 크기미스율 ↓hit time ↑, 비용 ↑
블록 크기공간적 지역성 ↑, 미스율 ↓ (어느 지점까지)너무 크면 작업 집합 대비 라인 수 ↓ → 충돌/용량 미스 ↑, 미스 패널티 ↑
연관도(E)충돌 미스 ↓hit time ↑ (병렬 비교 회로), 비용 ↑
쓰기 정책write-back으로 가면 트래픽 ↓일관성 회로 복잡, 코히어런시 비용

6.5캐시 친화적 코드 작성

몇 가지 단순한 원칙만 지키면 같은 알고리즘으로 큰 폭의 성능 차이를 낼 수 있다.

  1. 가장 자주 도는 안쪽 루프(inner loop)에 집중하라. 거기가 시간의 90%를 잡아먹는다.
  2. 안쪽 루프에서 작은 stride로 메모리를 훑어라. stride 1이 최고. 이게 곧 공간적 지역성.
  3. 한 번 가져온 데이터는 그 자리에서 최대한 재사용하라. 시간적 지역성.

예: 행렬 합산 — 행 우선 vs 열 우선

C 언어는 2차원 배열을 row-major(행 우선)로 저장한다. a[i][j]a[i][j+1]은 메모리에서 인접하다.

// 좋은 순서: stride 1
int sum_row(int a[M][N]) {
    int sum = 0;
    for (int i = 0; i < M; i++)
        for (int j = 0; j < N; j++)
            sum += a[i][j];           // 한 행 안에서 연속 접근
    return sum;
}

// 나쁜 순서: stride N (보통 N×4 바이트)
int sum_col(int a[M][N]) {
    int sum = 0;
    for (int j = 0; j < N; j++)
        for (int i = 0; i < M; i++)
            sum += a[i][j];           // 매번 다음 행 — stride N
    return sum;
}

N이 충분히 커서 한 행이 캐시 한 줄을 넘기 시작하면, sum_col은 매 접근마다 새 캐시 라인을 가져와야 한다. sum_row는 64바이트짜리 라인 하나에서 16개의 int를 다 처리한 뒤 다음 라인으로 간다 — 16:1의 미스율 차가 그대로 성능 차로 나타난다.

“행 우선 언어에선 행을 따라 읽어라.” Fortran은 column-major니까 그쪽에선 반대. 항상 언어가 약속한 저장 순서와 같은 방향으로 안쪽 루프를 돌리는 게 원칙이다.

6.6정리: 캐시가 프로그램 성능에 미치는 영향

6.6.1메모리 마운틴 (Memory Mountain)

같은 머신, 같은 함수에서 읽기 처리량(read throughput)을 두 축의 함수로 그려보면 마치 산맥처럼 보인다.

  • x축: stride (1, 2, 4, ..., 16) — 공간적 지역성을 결정
  • y축: 작업 집합 크기(working set size) (수 KB ~ 수 MB) — 어느 캐시 레벨에 들어가는지를 결정
  • z축: 초당 MB로 측정한 읽기 처리량
처리량 (MB/s)
   ▲
   │      ┌─ L1 영역: 작업 집합 32KB 이하, stride 1 → 정상
   │     ╱│
   │    ╱ │      ╲ stride가 커질수록 처리량 ↓
   │   ╱  │        (공간적 지역성 손실)
   │  ╱ L2└─ L3 ─┐
   │ ╱            ╲
   │╱              ╲___ 메인 메모리: 작업 집합이 L3 초과
   │                    (시간적 지역성 손실, 모든 stride에서 평탄)
   └────────────────────────►
             stride →
   ↑
 작업 집합 →

해석은 두 축으로 나뉜다. 작업 집합을 키우면(y축) 점점 더 느린 캐시 레벨로 떨어지면서 “계단식”으로 처리량이 줄고, stride를 키우면(x축) 같은 레벨 안에서도 공간적 지역성을 잃어 곡선이 휘어진다. 이 산맥을 한 번 그려 보면 “내 알고리즘은 어디쯤 있나”를 직관적으로 가늠할 수 있다.

6.6.2공간 지역성을 위한 루프 재배치 (Matrix Multiply)

세 중첩 루프 i, j, k로 이뤄진 행렬 곱셈 C = A × B는 안쪽 두 루프의 순서를 바꾸기만 해도 성능이 5~10배까지 갈린다.

// 표준 ijk 형식
for (i = 0; i < n; i++)
    for (j = 0; j < n; j++) {
        sum = 0;
        for (k = 0; k < n; k++)
            sum += A[i][k] * B[k][j];   // B는 j 고정, k 변화 → stride n
        C[i][j] = sum;
    }

ijk의 안쪽 루프에서 A[i][k]는 stride 1(좋다)이지만 B[k][j]는 stride n(나쁘다 — j 고정한 채 k가 변하면 매번 다음 행으로 점프).

// ikj 형식 — 안쪽 루프에서 j가 변한다
for (i = 0; i < n; i++)
    for (k = 0; k < n; k++) {
        r = A[i][k];                    // 한 번만 로드
        for (j = 0; j < n; j++)
            C[i][j] += r * B[k][j];     // B와 C 모두 stride 1 ✔
    }

ikj는 안쪽 루프에서 B[k][j]C[i][j]를 모두 stride 1로 훑는다. A[i][k]는 안쪽 루프 바깥에서 한 번만 로드되어 r로 들고 있는다. 대부분의 머신에서 ikj가 가장 빠른 이유다.

형식안쪽 루프 변수A 접근B 접근C 접근대략 미스율
ijkkrow (stride 1)col (stride n)고정중간
jikkrow (stride 1)col (stride n)고정중간
kij / ikjj고정row (stride 1)row (stride 1)낮음
jki / kjiicol (stride n)고정col (stride n)높음
주의

이게 끝이 아니다. n이 충분히 크면 어떤 순서를 써도 작업 집합이 캐시를 넘쳐서 용량 미스가 나온다. 그래서 “블록킹(tiling)” — 행렬을 캐시 크기에 맞는 작은 부분 행렬로 쪼개 처리하는 기법 — 이 다음 단계로 등장한다.

6.6.3프로그램에서 지역성 활용

일반 원칙들을 다시 정리하자.

  • 인기 변수는 레지스터로: 컴파일러가 충분히 똑똑하지만, 안쪽 루프에서 명시적인 임시 변수를 두면 도움이 된다 (예: 위 r = A[i][k]).
  • 안쪽 루프는 stride 1 접근: 배열을 가로로 훑어라. 가능하면 SIMD 친화적인 폭으로 정렬하라.
  • 핫 데이터는 한 곳에 모아라(SoA vs AoS): 함께 접근되는 필드는 같은 구조체에, 따로 접근되는 필드는 분리.
  • 블록킹: 큰 데이터를 캐시에 들어갈 만한 크기의 “블록”으로 쪼개어 그 블록 안에서 작업을 다 끝낸 뒤 다음 블록으로 넘어가라.
  • 프리페치(prefetch): 컴파일러나 CPU의 하드웨어 프리페처가 패턴을 인식할 수 있게 단순한 stride 패턴을 유지하라. 필요하면 __builtin_prefetch를 직접 박는 것도 방법이다.
연습 6.3

다음 코드의 inner loop에서 stride는 얼마인가? 캐시 친화적인가? 어떻게 고치겠는가?

struct point { double x, y, z, w; };  // 32B
struct point pts[N];
double sum = 0.0;
for (int i = 0; i < N; i++)
    sum += pts[i].x;

풀이. stride는 sizeof(struct point) = 32B로 한 캐시 라인(64B)에 점이 두 개 들어간다. x만 쓸 거면 8B 중 24B는 낭비. 만약 x만 다루는 게 핫한 작업 집합이라면 SoA(Structure of Arrays)로 바꿔 double xs[N]; double ys[N]; ... 같은 형태로 만들면 stride 1로 떨어져 라인 활용률이 100%가 된다.

6.7요약

이 장에서 다룬 내용을 한 페이지로 압축하면:

  • 저장 기술은 속도-용량-가격의 삼각관계 위에 놓여 있다. SRAM은 빠르고 비싸고, DRAM은 느리고 싸고, 디스크/SSD는 더 싸고 더 느리다.
  • 그래서 메모리 계층을 만든다. 각 레벨이 바로 아래를 캐시한다.
  • 이 구조가 통하는 이유는 프로그램이 지역성을 가지기 때문이다 — 시간적 + 공간적.
  • 하드웨어 캐시는 S × E × B의 구성으로 추상화된다. 주소를 t/s/b로 쪼개 인덱싱한다.
  • 미스에는 cold/conflict/capacity 세 종류가 있고, 각각 대응 방법이 다르다.
  • 코드 차원에선: 안쪽 루프를 stride 1로, 데이터를 캐시에 맞게 쪼개고(블록킹), 같은 데이터를 그 자리에서 재사용하라.
  • 같은 알고리즘에서 루프 순서만 바꿔도 10배의 차이가 나는 게 행렬 곱셈의 교훈이다 — 캐시는 추상이 아니라 측정 가능한 자원이다.

다음 장에선 시점이 바뀐다. 지금까지는 “한 프로세스의 메모리가 어떻게 빠르게 굴러가는가”였다면, 제7장에선 “여러 오브젝트 파일이 어떻게 모여 하나의 실행 파일이 되는가” — 즉 링킹(linking)의 세계로 넘어간다.

한 줄 요약

속도와 용량은 양립할 수 없으므로 층층이 쌓고, 코드는 그 층을 지역성으로 달랜다.