CSAPP Chapter 6 · 2026-07-05 통합 강의노트

Memory Hierarchy
& Matrix Locality

CPU가 빠르다는 말은 절반만 맞다. 데이터가 가까운 캐시에 있고, cache line을 낭비하지 않고, 안쪽 루프가 연속 주소를 읽을 때만 그 속도가 나온다. Chapter 6의 핵심은 계산량보다 데이터 접근 패턴을 보는 눈이다.

계층register → L1 → L2/L3 → memory
지역성temporal locality와 spatial locality
측정size × stride로 보는 memory mountain
행렬row-major와 loop order가 성능을 바꿈

1. 한 장 요약

size

반복해서 만지는 데이터 묶음, 즉 working set의 크기다. L1, L2, L3, main memory 중 어디에 들어가는지가 성능 계단을 만든다.

stride

배열을 몇 칸씩 건너뛰며 읽는지다. stride-1은 cache line을 알뜰하게 쓰고, 큰 stride는 한 줄을 가져와도 일부만 쓰고 버린다.

loop order

행렬 곱셈에서 안쪽 루프 변수가 어떤 배열의 두 번째 인덱스를 움직이는지가 핵심이다. C의 row-major에서는 두 번째 인덱스가 변해야 연속 접근이다.

암기 문장: 작은 working set은 temporal locality를 살리고, stride-1은 spatial locality를 살린다. 행렬 곱셈에서는 안쪽 루프가 j이면 보통 빠르고, 안쪽 루프가 i이면 보통 느리다.

2. 핵심 직관

프로그램은 CPU 명령만 실행하지 않는다. 대부분의 시간은 데이터를 가져오고, 기다리고, 다시 가져오는 데 쓰일 수 있다. 같은 O(n^3) 계산도 배열을 어떤 순서로 읽는지에 따라 전혀 다른 속도가 나온다.

빠른 코드

방금 쓴 데이터나 그 근처 데이터를 곧 다시 쓴다. 작은 데이터 묶음을 캐시에 올려두고 여러 번 재사용한다.

보통 코드

일부 배열은 연속 접근이지만, 다른 배열은 열 방향으로 점프한다. 계산은 맞지만 cache line을 완전히 활용하지 못한다.

느린 코드

가져온 cache line을 거의 쓰지 못하고 버린다. working set이 캐시보다 커서 아래 계층으로 자주 내려간다.

3. Memory hierarchy

Memory hierarchy는 빠르지만 작고 비싼 저장소부터, 느리지만 크고 싼 저장소까지 층층이 둔 구조다. CPU는 보통 register, L1, L2, L3, main memory, storage 쪽으로 내려가며 데이터를 찾는다.

REMIND위로 갈수록 빠름 / 작음 / 비쌈. 아래로 갈수록 느림 / 큼 / 쌈.
Memory hierarchy Registers L1 / L2 / L3 Cache Main memory SSD / Disk / Network 아래로 갈수록 용량 증가 latency 증가 가격/byte 감소

locality가 좋으면 위쪽 계층에서 hit가 많이 난다. locality가 나쁘면 아래 계층으로 자주 내려간다.

계층역할성능 감각코드에서 보이는 현상
RegisterCPU 내부의 임시 값가장 빠름컴파일러가 loop 변수, 누산값, scalar 재사용 값을 여기에 두려 한다.
L1 cache코어에 가장 가까운 작은 캐시매우 빠르지만 작음working set이 L1에 들어가면 throughput이 높다.
L2 / L3 cacheL1보다 큰 중간 캐시점점 커지고 느려짐size가 커질수록 L1 ridge에서 L2/L3 ridge로 내려간다.
Main memoryDRAM캐시보다 훨씬 느림miss penalty가 커지고, memory bandwidth가 병목이 된다.
StorageSSD, disk 등메모리보다 훨씬 느림Chapter 6의 cache locality보다 더 큰 I/O 계층 문제로 이어진다.

캐시는 main memory의 모든 값을 들고 있지 않는다. 자주 쓰거나 곧 쓸 가능성이 높은 block 일부를 가까운 곳에 복사해 둔다. 이때 “무엇을 가까이 둘 것인가”는 프로그램의 locality가 결정한다.

계층 감각: 위로 갈수록 빠르고 작다

Registerfastest
L1 cachesmall
L2 / L3middle
Main memorylarge
Storageslowest

막대 길이는 용량 감각이다. 실제 용량 비율이 아니라 “작고 빠른 계층에서 크고 느린 계층으로 내려간다”는 구조를 보여준다.

4. Cache organization: S, E, B, tag

Cache는 set, line, block으로 조직된다. 메모리 주소는 보통 [ tag | set index | block offset ]으로 쪼개어 해석한다.

주소 bit가 하는 일

tag이 block이 맞는지 확인
set index어느 set을 볼지 선택
block offsetblock 안의 byte 위치

hit은 tag만 맞는다고 끝나지 않는다. valid bit가 켜져 있고, 선택된 set 안에서 tag가 일치해야 한다.

기호정확한 역할
S = 2^sset 개수주소의 set index가 어느 set을 볼지 고른다.
Eset당 line 개수, associativityE = 1이면 direct-mapped cache, E > 1이면 set-associative cache다.
B = 2^bblock size, cache line 크기한 번 miss가 나면 메모리에서 가져오는 연속 byte 수다.
C = S * E * Bcache data capacityvalid bit, tag 같은 metadata를 제외한 데이터 저장 용량이다.
t = m - s - btag bit 수m비트 주소에서 set index와 block offset을 뺀 나머지가 tag다.

주소 분해

address = [ tag | set index | block offset ]

set index는 보통 block offset 바로 위의 middle bits이며, 볼 set을 고른다. tag는 그 set 안의 line이 원하는 block인지 확인한다. block offset은 block 안에서 어느 byte부터 읽을지 고른다.

hit 판정

선택된 set 안에서 valid bit가 켜져 있고 tag가 일치하는 line이 있으면 cache hit이다. tag가 맞지 않거나 line이 비어 있으면 miss이고, replacement policy가 어떤 line을 밀어낼지 정한다.

주소 분해 예시

16-bit 주소, B = 64 = 2^6, S = 16 = 2^4라면 b = 6, s = 4, t = 16 - 4 - 6 = 6이다. 주소는 [ tag 6bit | set 4bit | offset 6bit ]로 읽는다. E = 2면 data capacity는 16 * 2 * 64 = 2048B다.

store와 write policy

쓰기에는 policy가 붙는다. write-through는 cache와 아래 계층을 함께 갱신하고, write-back은 cache line에 dirty bit를 두었다가 밀려날 때 쓴다. write-allocate는 write miss 때 block을 cache로 가져오고, no-write-allocate는 아래 계층에 바로 쓴다.

E = 1이면 direct-mapped cache이고, S = 1이면 fully associative cache다. 일반적인 형태는 set을 먼저 고르고 그 set 안의 E개 line을 비교하는 E-way set associative cache다.

5. Hit, miss, AMAT

Cache hit

필요한 데이터가 현재 캐시에 있다. CPU는 가까운 계층에서 빠르게 값을 받는다.

Cache miss

현재 캐시에 없다. 아래 계층에서 block을 가져와야 한다.

Miss penalty

miss 때문에 추가로 드는 시간이다. main memory까지 내려가면 비용이 크게 오른다.

Miss의 종류

종류쉬운 뜻예시대응 감각
Compulsory miss
cold miss
처음 보는 block이라 캐시에 없다.배열을 처음 순회할 때 첫 cache line 접근완전히 없애기는 어렵다. prefetch나 warming으로 일부 완화할 수 있다.
Capacity missworking set이 캐시보다 커서 이전 block이 밀려난다.큰 행렬 전체를 계속 건드리는 경우blocking, tiling, hot data 분리로 working set을 줄인다.
Conflict miss캐시 전체 공간은 있어도 같은 set에 너무 많이 몰린다.특정 stride가 같은 set만 반복해서 때리는 경우padding, layout 변경, associativity 증가가 도움이 된다.

AMAT

AMAT, Average Memory Access Time은 평균 메모리 접근 비용을 보는 식이다.

AMAT = hit time + miss rate * miss penalty

miss rate가 조금만 줄어도 체감 성능이 크게 바뀐다. hit time이 1 cycle이고 miss penalty가 100 cycles라면, 97% hit는 평균 4 cycles이고 99% hit는 평균 2 cycles이다. 이 차이가 cache-friendly code가 중요한 이유다.

6. Locality

Locality는 프로그램이 “방금 쓴 것” 또는 “방금 쓴 것 근처”를 다시 쓸 가능성이 높다는 성질이다. 캐시는 이 습관을 믿고 설계된다.

개념쉬운 뜻좋은 예나쁜 예
Temporal locality
시간 지역성
최근에 쓴 데이터를 곧 다시 쓴다.작은 working set을 반복해서 사용한다.한 번 읽고 오래 돌아오지 않는다.
Spatial locality
공간 지역성
방금 쓴 주소 근처를 곧 쓴다.a[i], a[i+1], a[i+2]처럼 연속 접근한다.a[i], a[i+n]처럼 멀리 점프한다.

연결: memory mountain에서 size는 주로 temporal locality를 흔든다. stride는 주로 spatial locality를 흔든다.

7. Cache line, stride, working set

Cache line 또는 cache block은 캐시가 메모리에서 한 번에 가져오는 데이터 덩어리다. 보통 64B 크기를 자주 본다. double이 8B라면 cache line 하나에 8개가 들어간다.

64B cache line 안의 8개 double

stride-1
0123 4567
stride-8
0123 4567

같은 cache line을 가져와도 stride가 커지면 실제로 쓰는 칸이 줄어든다. 이게 spatial locality 손실이다.

stride-1 접근

for (int i = 0; i < n; i++)
    sum += a[i];

한 cache line을 가져왔을 때 그 안의 값들을 거의 다 쓴다. spatial locality가 좋다.

stride-n 접근

for (int i = 0; i < n; i++)
    sum += a[i * n];

매번 행 하나 크기만큼 점프한다. cache line을 가져와도 필요한 값 하나만 쓰고 버릴 가능성이 크다.

Working set은 현재 루프나 계산 구간에서 반복적으로 만지는 데이터 묶음이다. working set이 L1에 들어가면 매우 빠르고, L2/L3로 밀리면 느려지고, main memory까지 내려가면 더 느려진다.

8. Memory mountain

Memory mountain은 read throughput을 working set size와 stride의 함수로 그린 성능 지형이다. 높은 곳은 빠른 접근 패턴이고, 낮은 곳은 느린 접근 패턴이다.

CS:APP 원본 memory mountain 차트

아래 이미지는 CS:APP 블로그에 공개된 memory mountain 차트 이미지 URL을 직접 참조한다. 여러 세대의 CPU에서 size, stride, throughput 지형이 어떻게 달라지는지 비교할 수 있다.

Memory mountain: size × stride × read throughput Read throughput Size 증가: L1 → L2 → L3 → Memory L1L2L3Mem stride 큼stride 중간stride 작음

높은 능선은 작은 working set과 작은 stride가 만나는 구간이다. size가 커지면 cache capacity 경계를 넘고, stride가 커지면 cache line 활용률이 떨어진다.

size 축

읽는 데이터 묶음이 커질수록 L1, L2, L3, main memory 경계를 넘는다. 경계를 넘을 때 throughput이 계단처럼 떨어진다.

stride 축

stride가 커질수록 cache line 안에서 실제로 쓰는 byte 비율이 낮아진다. 같은 유효 데이터를 읽기 위해 더 많은 cache line이 필요하다.

측정 코드의 의미

NOTECSAPP의 mountain.c에서 size는 byte 단위 working set이고, stride는 byte가 아니라 배열 원소 개수 단위다. 반환값은 대략 “읽은 byte 수 / 걸린 시간”으로 만든 read throughput, 즉 MB/s 감각이다. 아래 코드는 공식 구조를 설명하기 위해 loop unrolling을 섞은 변형 예다.
CSAPP memory mountain 변형 예C
double data[MAXELEMS];  /* 전역 배열. 이 배열을 여러 size/stride로 순회한다. */

double test(int elems, int stride)
{
    long i, sx2 = stride*2, sx3 = stride*3, sx4 = stride*4;
    double acc0 = 0, acc1 = 0, acc2 = 0, acc3 = 0;
    long length = elems;
    long limit = length - sx4;

    /* 한 번에 4개씩 읽는 loop unrolling. */
    for (i = 0; i < limit; i += sx4) {
        acc0 += data[i];
        acc1 += data[i + stride];
        acc2 += data[i + sx2];
        acc3 += data[i + sx3];
    }

    /* 남은 꼬리 원소 처리. */
    for (; i < length; i++)
        acc0 += data[i];

    return ((acc0 + acc1) + (acc2 + acc3));
}

double run(int size, int stride, double Mhz)
{
    double cycles;
    int elems = size / sizeof(double);

    test(elems, stride);                  /* cache warm-up */
    cycles = fcyc2(test, elems, stride, 0); /* cycle 측정 */
    return (size / stride) / (cycles / Mhz);
}
코드 요소의미성능과의 연결
sizebyte 단위 working set 크기L1/L2/L3/main memory 경계를 결정한다.
elems접근할 원소 개수size / sizeof(element)로 계산된다.
stridebyte가 아니라 원소 단위 접근 간격double 배열에서 stride 8은 8B가 아니라 64B 간격이다. cache line 활용률을 결정한다.
acc0부터 acc3읽은 값을 더하는 누산 변수컴파일러가 load를 제거하지 못하게 하고, 메모리 읽기 비용을 드러낸다.
i += sx44개씩 묶어 처리하는 loop unrolling루프 오버헤드를 줄여 메모리 접근 패턴의 영향을 더 잘 보이게 한다.
run(size, stride, Mhz)특정 size와 stride 조합의 처리량을 계산하는 wrappertest(elems, stride)를 warm-up한 뒤 cycle을 재서 MB/s 또는 유사한 throughput 지표로 바꾼다.

현대 CPU는 prefetching을 하므로 stride-1 같은 예측 가능한 접근은 더 이득을 볼 수 있다. 하지만 큰 stride나 큰 working set은 여전히 cache line 낭비와 낮은 hit rate로 이어진다.

9. Matrix multiplication과 loop order

행렬 곱셈의 수학은 같다.

C[i][j] = sum over k of A[i][k] * B[k][j]

하지만 C의 2차원 배열은 row-major order다. 한 행의 원소들이 메모리에 연속으로 붙어 있다. 그래서 두 번째 인덱스가 변하면 연속 접근이고, 첫 번째 인덱스가 변하면 행 크기만큼 점프한다.

row-major에서 안쪽 루프 변수가 만드는 접근 방향 도형의 크기가 핵심이 아니다. 어떤 인덱스가 바뀔 때 메모리 주소가 옆으로 가는지, 멀리 점프하는지가 핵심이다. A[i][k] k가 변하면 행 방향, 연속 접근 A[i][0] A[i][1] A[i][2] ... i가 변하면 다음 행으로 점프 row strip을 그대로 쓰면 cache line 활용 × B[k][j] j가 변하면 행 방향, k가 변하면 열 방향 B[k][0] B[k][1] B[k][2] ... k가 안쪽이면 행 크기만큼 점프 inner j는 좋고, inner k는 B에 불리 = C[i][j] j가 변하면 연속 저장 C[i][0] C[i][1] ... i가 변하면 stride-n inner i는 C에 매우 불리 판정법: 안쪽 루프에서 바뀌는 변수가 두 번째 인덱스면 row-wise, 첫 번째 인덱스면 column-wise 점프다.

행 방향 접근

A[i][0], A[i][1], A[i][2], ...

두 번째 인덱스가 변한다. 메모리에서 옆 칸으로 이동한다. stride-1 접근이다.

열 방향 접근

A[0][j], A[1][j], A[2][j], ...

첫 번째 인덱스가 변한다. 행 하나 크기만큼 이동한다. stride-n 접근이다.

배열별 판정법

배열안쪽에서 이 변수가 변하면 좋음안쪽에서 이 변수가 변하면 나쁨이유
AA[i][k]ki두 번째 인덱스 k가 변해야 행 방향이다.
BB[k][j]jk두 번째 인덱스 j가 변해야 행 방향이다.
CC[i][j]ji두 번째 인덱스 j가 변해야 행 방향이다.
PRACTICALB[k][j]의 열 방향 접근이 문제라면 B를 전치(transpose)해서 BT[j][k] = B[k][j] 형태로 저장하는 방법도 있다. 그러면 ijk처럼 안쪽 루프가 k일 때도 BT[j][k]의 두 번째 인덱스가 변하므로 B 쪽 접근이 row-wise가 된다. 대신 전치 배열을 만드는 비용과 추가 메모리를 감수해야 한다.

10. 여섯 loop order 비교표

세 글자 ijk는 바깥 루프부터 안쪽 루프까지의 순서를 뜻한다. 마지막 글자가 가장 안쪽 루프다. 성능은 주로 이 마지막 글자가 A, B, C를 어떻게 움직이는지로 결정된다.

REMIND먼저 마지막 글자만 본다. 마지막 글자가 j면 대체로 빠르고, i면 대체로 느리며, k면 중간이다. 그다음 A, B, C에서 그 글자가 첫 번째 인덱스인지 두 번째 인덱스인지 확인한다.

ijk를 읽는 법

루프 이름은 바깥에서 안쪽 순서다

i가장 바깥 루프. A와 C의 행을 크게 바꾼다.
j중간 루프. C의 한 칸 C[i][j]를 고른다.
k가장 안쪽 루프. 한 칸을 계산하려고 k = 0..n-1을 빠르게 돈다.

ijk 한 장면

ij가 고정된 상태에서 k만 변한다. 즉 C[i][j] 한 칸을 만들기 위해 A의 한 행과 B의 한 열을 곱해 더한다.

A
A[i][0]A[i][1]A[i][2]
B
B[0][j]B[1][j]B[2][j]
C
C[i][j] 누적값

A는 행 방향으로 좋지만, B는 열 방향으로 점프한다. 그래서 ijk는 빠른 쪽도 느린 쪽도 아닌 중간이다.

마지막 글자로 먼저 분류하기

inner j: 빠름

ikj, kij. B[k][j]C[i][j]에서 두 번째 인덱스 j가 변한다. B와 C를 행 방향으로 훑는다.

inner k: 중간

ijk, jik. A[i][k]는 행 방향이라 좋지만, B[k][j]는 열 방향이라 나쁘다. C는 한 칸을 누적한 뒤 저장한다.

inner i: 느림

jki, kji. A[i][k]C[i][j]에서 첫 번째 인덱스 i가 변한다. A와 C가 둘 다 세로로 점프한다.

CSAPP 공식 자료와 연결

CSAPP 공식 figure/source 자료에는 matrix multiply 성능 데이터와 여섯 루프 순열 코드가 따로 공개되어 있다. 아래 차트는 공식 corei7mm.xlscorei7mmdata 값을 바탕으로 다시 그린 것이다.

CSAPP Core i7 matrix multiply performance 공식 corei7mm.xls 데이터 재구성. y축은 cycles per inner-loop iteration, log scale이다. 1 3 10 30 100 50 200 350 500 650 700 Array size (n) Cycles per inner-loop iteration jki kji ijk jik kij ikj

이 차트는 원문 그림을 캡처한 이미지가 아니라, CSAPP 공식 XLS의 수치를 문서 스타일에 맞게 다시 그린 것이다. 아래 단순 bar chart는 같은 결론을 외우기 위한 요약이다.

inner-loop miss 근사 차트

ikj/kij0.5
ijk/jik1.25
jki/kji2.0

값은 CSAPP식 단순 근사다. 그래프에서 짧을수록 miss가 적다.

순서안쪽 루프A 접근B 접근C 접근판정
ijkk행 방향 A[i][k]열 방향 B[k][j]sum 후 1회 store중간
jikk행 방향 A[i][k]열 방향 B[k][j]sum 후 1회 store중간
jkii열 방향 A[i][k]scalar r = B[k][j]열 방향 C[i][j]느림
kjii열 방향 A[i][k]scalar r = B[k][j]열 방향 C[i][j]느림
kijjscalar r = A[i][k]행 방향 B[k][j]행 방향 C[i][j]빠름
ikjjscalar r = A[i][k]행 방향 B[k][j]행 방향 C[i][j]빠름
STOREijk, jiksum을 register에 누적한 뒤 C[i][j]를 한 번 저장한다. 반면 ikj, kij는 안쪽 j 루프에서 C[i][j]를 반복 갱신하므로 load/store가 더 많을 수 있다. 그래도 B와 C가 row-wise로 움직여 cache line을 잘 쓰기 때문에 CSAPP의 큰 분류에서는 빠른 쪽으로 묶인다.

CSAPP식 세 묶음

묶음해당 순서CSAPP식 inner-loop miss 근사핵심 이유
AB classijk, jik약 1.25 misses/iterationA는 행 방향이지만 B가 열 방향이다. C는 안쪽 루프 밖에서 한 번 저장된다.
AC classjki, kji약 2.0 misses/iteration안쪽 루프가 i라서 A와 C가 모두 열 방향으로 움직인다.
BC classkij, ikj약 0.5 misses/iteration안쪽 루프가 j라서 B와 C를 행 방향으로 연속 접근한다.

이 miss 수치는 32B block, 8B double, 충분히 큰 n 같은 분석 가정 아래의 근사다. 열 방향 접근이 언제나 100% miss라는 뜻은 아니다. cache size, associativity, prefetching, TLB, compiler optimization에 따라 실제 측정값은 달라진다.

첫 두 루프를 바꾸면 왜 pair 성능이 비슷한가

ijkjik, jkikji, kijikj는 각각 가장 안쪽 루프가 같다. 바깥 두 루프의 순서가 바뀌어도 안쪽 루프에서 A, B, C를 움직이는 핵심 access pattern은 거의 같다. 그래서 큰 성능 분류도 pair 단위로 묶인다.

실제 기계에서는 prefetching, TLB, write policy, compiler optimization 때문에 완전히 같지는 않다. 그래도 Chapter 6의 큰 결론은 “안쪽 루프의 locality가 전체 성능을 크게 지배한다”이다.

대표 코드

ikj: 빠른 쪽

빠른 쪽: inner jC
for (i = 0; i < n; i++)
  for (k = 0; k < n; k++) {
    double r = A[i][k]; // A는 scalar처럼 재사용
    for (j = 0; j < n; j++)
      C[i][j] += r * B[k][j]; // B, C row-wise
  }

A는 scalar처럼 재사용되고, B와 C는 row-wise로 움직인다.

jki: 느린 쪽

느린 쪽: inner iC
for (j = 0; j < n; j++)
  for (k = 0; k < n; k++) {
    double r = B[k][j]; // B는 scalar처럼 재사용
    for (i = 0; i < n; i++)
      C[i][j] += A[i][k] * r; // A, C column-wise
  }

B는 scalar처럼 재사용되지만, A와 C가 column-wise로 움직인다.

11. Blocking / tiling

Blocking 또는 tiling은 큰 행렬을 캐시에 들어갈 만한 작은 타일로 나누어 계산하는 기법이다. 목적은 작은 A/B/C 블록을 캐시에 올려두고 가능한 많이 재사용하는 것이다.

Blocking: 큰 행렬을 작은 tile로 나눠 cache에서 재사용 L 행렬을 L × L tile 단위로 나눔 Cache A/B/C tile 재사용 capacity miss 감소

큰 행렬 전체가 아니라 작은 A/B/C tile을 cache에 올리고, 그 안에서 재사용을 최대화한다.

blocking / tiling 기본 모양C
for (i = 0; i < n; i += L)
  for (j = 0; j < n; j += L)
    for (k = 0; k < n; k += L) {
      // A, B, C의 작은 tile을 cache 안에서 반복 재사용한다.
      for (i1 = i; i1 < i + L; i1++)
        for (j1 = j; j1 < j + L; j1++)
          for (k1 = k; k1 < k + L; k1++)
            C[i1*n + j1] += A[i1*n + k1] * B[k1*n + j1];
    }

왜 빠른가

행렬 곱셈은 같은 원소를 여러 번 쓸 수 있는 구조다. 하지만 큰 행렬을 그대로 돌면 재사용하기 전에 cache에서 밀려난다. blocking은 작은 타일을 cache 안에 남겨두고 여러 번 쓰게 만든다.

tile size 감각

tile dimension L은 클수록 재사용 기회가 늘지만, 너무 크면 A, B, C 세 타일이 cache에 함께 들어가지 않는다. double 기준 단순 모델에서는 3 * L^2 * sizeof(double) < usable cache bytes 조건을 본다.

EDGE실전 코드는 nL로 나누어떨어진다고 가정하면 안 된다. 마지막 tile은 min(i + L, n), min(j + L, n), min(k + L, n)처럼 경계를 잘라서 처리한다.

여기서 L은 matrix tile의 한 변 길이다. cache organization에서 쓴 B = 2^b bytes, 즉 cache block size와 다른 기호다. CSAPP식 단순 miss model에서는 double 원소, cache line당 8 doubles(64B line — 앞의 loop order 표는 32B line = double 4개 가정이라 이 blocking 예시와는 cache line 가정이 다르다), 충분히 큰 n, A/B/C tile이 usable cache에 들어간다는 가정 아래 blocking 없이 약 (9/8)n^3 misses가 난다. blocking을 하면 tile L이 cache에 남아 반복 재사용되는 만큼 miss가 크게 줄어든다(tile이 클수록 유리). 숫자 자체보다 중요한 건 “큰 문제를 cache에 들어가는 작은 문제로 쪼갠다”는 생각이다.

12. 언어별 메모리 레이아웃 감각

REMINDCSAPP의 기본 무대는 C다. C/C++/Rust처럼 값 배치를 직접 설계할 수 있는 언어와, Java/Python/Ruby/JavaScript처럼 객체와 런타임 표현을 거치는 언어는 cache locality를 만드는 방법이 다르다. 핵심 질문은 항상 “반복문이 실제로 연속된 값을 읽는가, 아니면 reference/pointer를 따라가며 흩어진 객체를 읽는가”이다.
언어메모리 레이아웃 제어cache-friendly 감각
C / C++ / Rust배열, struct layout, pointer/reference 접근을 명시적으로 다룰 수 있다.flat array, row-major 순회, padding, SoA/AoS 선택, blocking을 직접 설계한다.
Go[]T는 값들이 연속된 slice 감각을 줄 수 있고, []*T는 pointer 배열이다.작은 struct 값 slice는 cache-friendly하게 돌 수 있다. pointer slice는 Java class array처럼 한 번 더 따라간다.
Java / C#GC가 객체를 관리한다. primitive/value array는 비교적 연속 저장 감각을 준다.객체 배열보다 primitive array/value type array를 고려한다. 다차원 jagged array는 연속 2D 배열과 다르다는 점을 조심한다.
JavaScript (V8: Node/Chrome)일반 object/array는 hidden class/map, elements kind, tagged value, boxing/unboxing 최적화에 영향을 받는다.숫자 계산 커널에서는 Float64Array, Int32Array 같은 typed array와 flat 1D layout을 우선 고려한다.
JavaScript (Bun: JavaScriptCore)Bun은 V8이 아니라 JavaScriptCore 기반이다. 객체 모양과 배열 저장을 최적화하지만 V8 용어를 그대로 쓰면 안 된다.엔진이 달라도 locality 관점의 안전한 선택은 typed array, dense data, flat index다.
Python (CPython)일반 list는 객체 본체를 값으로 박아 넣는 C 배열이 아니라 PyObject* reference들을 담는 컨테이너 감각이다.숫자 대량 처리에서는 list[int]보다 NumPy ndarray, Polars/Arrow 계열 columnar layout을 고려한다.
Ruby (MRI)일반 객체 중심 모델이라 값 배열처럼 보여도 내부적으로 object/reference 표현을 거친다.CSAPP식 cache locality를 직접 얻기 어렵다. 대량 숫자/컬럼 계산은 네이티브 확장이나 columnar 라이브러리의 도움을 받는다.

값 배열 감각

C의 double a[n], Java의 double[], JavaScript의 Float64Array, NumPy의 numeric ndarray는 “같은 크기의 숫자들이 한 줄로 놓인다”는 감각에 가깝다. 이때 stride-1 순회가 cache line을 가장 잘 쓴다.

reference 배열 감각

Java의 Player[], Python의 list, Ruby의 object 배열, JavaScript의 object array는 원소 칸에 객체 본체가 아니라 reference/tagged value가 들어가는 쪽에 가깝다. 실제 필드 값은 한 번 더 따라가야 할 수 있다.

JavaScript 예시

flat 1D typed array matrixJavaScript
// 2D 배열 감각을 flat 1D typed array로 표현
const n = 1024;
const a = new Float64Array(n * n);

function get(i, j) {
  return a[i * n + j]; // row-major index
}

일반 객체를 숫자 커널에 쓰면 속성 모양 변화, sparse array, boxing 등으로 엔진 최적화가 흔들릴 수 있다. 숫자 밀집 데이터는 typed array가 더 예측 가능하다.

JavaScript runtime 감각: V8과 Bun을 분리해서 기억하기

Node / Chrome / V8

Node와 Chrome 계열은 V8 엔진을 쓴다. V8은 객체의 property layout을 hidden class/map 계열 구조로 추적하고, 배열 원소 저장 방식도 elements kind로 구분해 최적화한다. 작은 정수, double, object가 섞이는지에 따라 표현이 달라질 수 있으므로 배열을 dense하고 타입이 흔들리지 않게 유지하는 편이 좋다.

Bun / JavaScriptCore

Bun은 V8이 아니라 Safari 계열 JavaScriptCore 기반이다. JavaScriptCore도 객체 모양과 property access를 최적화하지만 내부 이름과 레이아웃은 V8과 다르다. V8의 hidden class나 elements kind 설명을 Bun에 그대로 적용하면 부정확하다.

NOTECSAPP 관점에서 안전한 결론은 엔진 이름보다 데이터 표현이다. 일반 JS Array를 C 배열처럼 단정하지 말고, 고정 폭 숫자 데이터를 반복 순회할 때는 ArrayBuffer 위의 typed array와 i * n + j flat index가 locality 설명에 가장 잘 맞는다고 기억한다. Bun이 항상 V8보다 빠르다고 말하는 것도 피한다. workload와 엔진 버전에 따라 달라진다.

Python / Ruby 감각: 기본 컨테이너는 pointer chasing에 가깝다

CPython list와 NumPy ndarray의 차이Python
# Python list: 칸마다 객체 reference를 들고 있다고 생각한다.
heights = [170, 181, 176, 190]

# 숫자 커널: 같은 dtype의 값을 조밀하게 담는 배열이 locality에 유리하다.
import numpy as np
height_arr = np.array([170, 181, 176, 190], dtype=np.int32)
CPython list 감각

list object
  |
  v
[ptr][ptr][ptr][ptr]  // PyObject* reference 배열
  |    |    |    |
  v    v    v    v
 int  int  int  int   // 객체 본체는 별도 위치일 수 있음

NumPy numeric ndarray 감각

[170][181][176][190]  // 같은 dtype 값 buffer

Python과 Ruby에서 “배열처럼 생겼다”는 말이 곧 C 배열처럼 값이 연속이라는 뜻은 아니다. CPython의 일반 list는 객체 reference 컨테이너로 보는 편이 안전하다. 큰 숫자 배열, 행렬, 컬럼 데이터를 빠르게 돌려야 하면 NumPy의 homogeneous ndarray, Polars처럼 Arrow columnar memory model을 쓰는 도구가 CSAPP의 locality 감각에 더 가깝다.

13. Java class array cache locality

REMINDJava의 Player[]는 C의 struct Player players[N]처럼 객체 본체가 연속으로 들어가는 배열이 아니다. 배열 안에는 객체 reference가 연속으로 들어간다.

Java에서 int[], double[] 같은 primitive array는 값들이 배열 내부에 연속 배치되는 감각을 준다. 반면 Player[] 같은 class array는 객체 자체가 아니라 객체 참조들이 배열 안에 들어간다. 실제 객체들은 heap 곳곳에 따로 있을 수 있다.

primitive 배열: 값 자체가 한 줄로 놓인다

primitive 값 배열Java
int[] hp = new int[5];

hp[0] = 100;
hp[1] = 80;
hp[2] = 60;
hp[3] = 40;
hp[4] = 20;
100
hp[0]
80
hp[1]
60
hp[2]
40
hp[3]
20
hp[4]
hp 배열의 메모리 감각

[100][ 80][ 60][ 40][ 20]
  0    1    2    3    4

이 구조에서는 hp[0], hp[1], hp[2]가 연속된 위치에 놓인다. 반복문으로 순회하면 CPU가 연속된 값을 읽고, cache line 하나를 가져왔을 때 여러 값을 함께 활용할 수 있다.

순차 접근이 cache-friendly한 경우Java
for (int i = 0; i < size; i++) {
    if (hp[i] <= 0) {
        // 죽은 플레이어 처리
    }
}

class 배열: reference 표가 먼저 있고 객체는 따로 있다

객체 배열 선언Java
final class Player {
    int id;
    int hp;
    float x;
    float y;
}

Player[] players = new Player[5];
ref[0]ref[1]ref[2]ref[3]ref[4]
PlayerPlayerPlayerPlayerPlayer
Java class 배열의 메모리 감각

players:
[ ref ][ ref ][ ref ][ ref ][ ref ]
   |     |     |     |     |
   v     v     v     v     v
 Player Player Player Player Player

배열은 reference를 연속으로 담는다.
객체 본체는 heap의 다른 위치에 있을 수 있다.

primitive 배열 접근

hp[i]를 읽는다.

hp[i]
  ↓
값

class 배열 접근

players[i].hp를 읽는다.

players[i]
  ↓
Player 객체 reference
  ↓
Player 객체
  ↓
hp 값
TERMreference chasing은 reference를 읽고, 그 reference가 가리키는 객체로 이동해서, 다시 그 객체 안의 데이터를 읽는 과정이다. 대량 반복 순회에서는 이 과정이 locality를 나쁘게 만들 수 있다.
reference를 따라가는 순회Java
for (int i = 0; i < size; i++) {
    Player p = players[i];  // reference 읽기

    if (p.hp <= 0) {        // 객체 안의 필드 읽기
        // 죽은 플레이어 처리
    }
}

해결 방향: 객체를 필드별 primitive 배열로 풀기

ELI5Player[]는 “플레이어 서류 위치 목록”이다. int[] hp, float[] x, float[] y는 “체력표, x좌표표, y좌표표”를 각각 한 장씩 만든 것이다. 체력만 검사할 때는 체력표만 위에서 아래로 읽으면 된다.

객체 중심 구조

객체 배열 방식Java
final class Player {
    int id;
    int hp;
    float x;
    float y;
    boolean alive;
}

Player[] players = new Player[capacity];

필드별 배열 구조

SoA: hot field를 primitive 배열로 분리Java
final class PlayerTable {
    int size;
    int[] ids;
    int[] hps;
    float[] xs;
    float[] ys;
    boolean[] alive;
}
객체 배열 방식

Player 0 = { id, hp, x, y, alive }
Player 1 = { id, hp, x, y, alive }
Player 2 = { id, hp, x, y, alive }

필드별 primitive 배열 방식

ids:   [  1][  2][  3]
hps:   [100][ 80][ 60]
xs:    [1.0][2.0][3.0]
ys:    [5.0][6.0][7.0]
alive: [true][true][true]
ids123
hps1008060
xs1.02.03.0
ys5.06.07.0
alivetruetruetrue
hot field만 연속 순회Java
for (int i = 0; i < size; i++) {
    if (alive[i]) {
        xs[i] += dx;
        ys[i] += dy;
    }
}

AoS와 SoA

형태이름장점단점
Player[]AoS, Array of Structures객체 단위 모델링이 쉽다. 코드가 자연스럽다.특정 필드만 대량 처리할 때 cache line 안에 필요 없는 cold data가 섞인다.
int[] hp, float[] x, boolean[] aliveSoA, Structure of Arrays같은 필드끼리 연속 배치되어 대량 계산에 유리하다.객체지향적 표현이 약해지고, index 관리가 필요하다.
NOTEJava의 Player[]를 엄밀히 말하면 C의 AoS와 같다고 보기 어렵다. C의 struct Player players[N]는 struct 값이 연속 배치될 수 있지만, Java의 Player[]는 reference 배열이다. 그래도 “객체 중심 배열”과 “필드별 배열”의 대비로 AoS / SoA 용어를 쓴다.
체력 검사: 객체 배열 vs field arrayJava
// 객체 배열: reference를 따라간 뒤 hp 필드 접근
for (int i = 0; i < size; i++)
    if (players[i].hp <= 0) deadCount++;

// field array: hps 배열만 연속으로 순회
for (int i = 0; i < size; i++)
    if (hps[i] <= 0) deadCount++;

Hot / cold data 분리

모든 필드를 SoA로 바꿀 필요는 없다. 자주 읽고 대량으로 처리하는 hot data만 primitive array로 분리하고, 이름, 스킨, 설명문처럼 드물게 쓰는 cold data는 객체에 남기는 편이 현실적이다.

runtime hot data와 profile cold data 분리Java
final class PlayerRuntimeTable {
    long[] playerIds;
    int[] hps;
    float[] xs;
    float[] ys;
    boolean[] alive;
}

final class PlayerProfile {
    long playerId;
    String nickname;
    String skinName;
    String guildName;
    long createdAt;
}
상황권장 구조이유
DB entity, API DTO, 설정 객체일반 class성능보다 의미 표현, 유지보수성, 프레임워크 호환성이 중요하다.
수십만 개 이상의 객체를 반복 순회primitive 배열 기반 SoA 검토reference chasing과 cache miss가 병목이 될 수 있다.
게임 tick, 시뮬레이션, 물리 계산, 매칭 점수 계산hot data를 primitive 배열로 분리같은 필드를 대량으로 훑는 패턴이 많다.
객체 수가 적거나 병목이 아님일반 class 유지복잡한 layout 최적화보다 단순한 코드가 낫다.

boxing 피하기

boxed number와 primitive array 차이Java
Integer[] scores1;      // Integer 객체 reference 배열
List<Integer> scores2;  // 내부적으로 boxing 비용이 생길 수 있음
int[] scores3;          // primitive 값 배열

성능이 중요한 숫자 루프에서는 boxed type보다 primitive array가 예측 가능하다. 단, 이런 최적화는 코드 구조를 복잡하게 만들 수 있으므로 병목이 확인된 루프부터 적용한다.

14. 실전 체크리스트

15. 연습 문제

  1. 문제: 64B cache line에 8B double은 몇 개 들어가는가?
    답: 8개.
  2. 문제: C 배열 A[i][j]에서 j가 안쪽 루프에서 변하면 왜 좋은가?
    답: row-major에서 같은 행의 원소들이 연속 배치되어 stride-1 접근이 되기 때문이다.
  3. 문제: ijk에서 A와 B 중 어느 쪽이 cache-friendly하지 않은가?
    답: B. 안쪽 루프 k가 변하면 B[k][j]는 열 방향으로 움직인다.
  4. 문제: ikjkij가 빠른 공통 이유는?
    답: 안쪽 루프가 j라서 B와 C를 행 방향으로 연속 접근하고, A는 scalar처럼 재사용된다.
  5. 문제: blocking의 핵심 목적은 spatial locality인가 temporal locality인가?
    답: 주 목적은 temporal locality 강화다. 작은 타일을 cache에 넣고 여러 번 재사용한다.
  6. 문제: Java에서 Player[]int[] hp보다 대량 체력 검사에 불리할 수 있는 이유는?
    답: Player[]는 객체 참조 배열이고 실제 객체는 흩어져 있을 수 있다. hp만 연속으로 읽기 어렵다.

최종 암기 카드

working set

현재 반복 구간이 계속 만지는 데이터 묶음이다. 이 크기가 L1, L2, L3 경계를 넘으면 성능 계단이 생긴다.

stride

연속 원소를 읽으면 cache line을 거의 다 쓰고, 큰 stride는 가져온 line 일부만 쓰고 버린다.

row-major

C의 2차원 배열은 두 번째 인덱스가 변할 때 연속 접근이다. 첫 번째 인덱스가 변하면 행 크기만큼 점프한다.

loop order

ikj, kij는 inner j라서 B와 C가 row-wise다. jki, kji는 inner i라서 A와 C가 column-wise다.

blocking

큰 문제를 cache에 들어가는 작은 tile로 쪼개서 temporal locality를 살린다.

Java 배열

int[]는 값 배열 감각이고, Player[]는 reference 배열이다. hot data는 primitive SoA로 분리할 수 있다.

참고 기준

아래 공개 자료의 개념 흐름과 수식 기준으로 대조했다.