1. 한 장 요약
반복해서 만지는 데이터 묶음, 즉 working set의 크기다. L1, L2, L3, main memory 중 어디에 들어가는지가 성능 계단을 만든다.
배열을 몇 칸씩 건너뛰며 읽는지다. stride-1은 cache line을 알뜰하게 쓰고, 큰 stride는 한 줄을 가져와도 일부만 쓰고 버린다.
행렬 곱셈에서 안쪽 루프 변수가 어떤 배열의 두 번째 인덱스를 움직이는지가 핵심이다. 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 쪽으로 내려가며 데이터를 찾는다.
locality가 좋으면 위쪽 계층에서 hit가 많이 난다. locality가 나쁘면 아래 계층으로 자주 내려간다.
| 계층 | 역할 | 성능 감각 | 코드에서 보이는 현상 |
|---|---|---|---|
| Register | CPU 내부의 임시 값 | 가장 빠름 | 컴파일러가 loop 변수, 누산값, scalar 재사용 값을 여기에 두려 한다. |
| L1 cache | 코어에 가장 가까운 작은 캐시 | 매우 빠르지만 작음 | working set이 L1에 들어가면 throughput이 높다. |
| L2 / L3 cache | L1보다 큰 중간 캐시 | 점점 커지고 느려짐 | size가 커질수록 L1 ridge에서 L2/L3 ridge로 내려간다. |
| Main memory | DRAM | 캐시보다 훨씬 느림 | miss penalty가 커지고, memory bandwidth가 병목이 된다. |
| Storage | SSD, disk 등 | 메모리보다 훨씬 느림 | Chapter 6의 cache locality보다 더 큰 I/O 계층 문제로 이어진다. |
캐시는 main memory의 모든 값을 들고 있지 않는다. 자주 쓰거나 곧 쓸 가능성이 높은 block 일부를 가까운 곳에 복사해 둔다. 이때 “무엇을 가까이 둘 것인가”는 프로그램의 locality가 결정한다.
계층 감각: 위로 갈수록 빠르고 작다
막대 길이는 용량 감각이다. 실제 용량 비율이 아니라 “작고 빠른 계층에서 크고 느린 계층으로 내려간다”는 구조를 보여준다.
4. Cache organization: S, E, B, tag
Cache는 set, line, block으로 조직된다. 메모리 주소는 보통 [ tag | set index | block offset ]으로 쪼개어 해석한다.
주소 bit가 하는 일
hit은 tag만 맞는다고 끝나지 않는다. valid bit가 켜져 있고, 선택된 set 안에서 tag가 일치해야 한다.
| 기호 | 뜻 | 정확한 역할 |
|---|---|---|
S = 2^s | set 개수 | 주소의 set index가 어느 set을 볼지 고른다. |
E | set당 line 개수, associativity | E = 1이면 direct-mapped cache, E > 1이면 set-associative cache다. |
B = 2^b | block size, cache line 크기 | 한 번 miss가 나면 메모리에서 가져오는 연속 byte 수다. |
C = S * E * B | cache data capacity | valid bit, tag 같은 metadata를 제외한 데이터 저장 용량이다. |
t = m - s - b | tag 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 miss | working 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
같은 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 지형이 어떻게 달라지는지 비교할 수 있다.
높은 능선은 작은 working set과 작은 stride가 만나는 구간이다. size가 커지면 cache capacity 경계를 넘고, stride가 커지면 cache line 활용률이 떨어진다.
size 축
읽는 데이터 묶음이 커질수록 L1, L2, L3, main memory 경계를 넘는다. 경계를 넘을 때 throughput이 계단처럼 떨어진다.
stride 축
stride가 커질수록 cache line 안에서 실제로 쓰는 byte 비율이 낮아진다. 같은 유효 데이터를 읽기 위해 더 많은 cache line이 필요하다.
측정 코드의 의미
mountain.c에서 size는 byte 단위 working set이고, stride는 byte가 아니라 배열 원소 개수 단위다. 반환값은 대략 “읽은 byte 수 / 걸린 시간”으로 만든 read throughput, 즉 MB/s 감각이다. 아래 코드는 공식 구조를 설명하기 위해 loop unrolling을 섞은 변형 예다.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);
}
| 코드 요소 | 의미 | 성능과의 연결 |
|---|---|---|
size | byte 단위 working set 크기 | L1/L2/L3/main memory 경계를 결정한다. |
elems | 접근할 원소 개수 | size / sizeof(element)로 계산된다. |
stride | byte가 아니라 원소 단위 접근 간격 | double 배열에서 stride 8은 8B가 아니라 64B 간격이다. cache line 활용률을 결정한다. |
acc0부터 acc3 | 읽은 값을 더하는 누산 변수 | 컴파일러가 load를 제거하지 못하게 하고, 메모리 읽기 비용을 드러낸다. |
i += sx4 | 4개씩 묶어 처리하는 loop unrolling | 루프 오버헤드를 줄여 메모리 접근 패턴의 영향을 더 잘 보이게 한다. |
run(size, stride, Mhz) | 특정 size와 stride 조합의 처리량을 계산하는 wrapper | test(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다. 한 행의 원소들이 메모리에 연속으로 붙어 있다. 그래서 두 번째 인덱스가 변하면 연속 접근이고, 첫 번째 인덱스가 변하면 행 크기만큼 점프한다.
행 방향 접근
A[i][0], A[i][1], A[i][2], ...두 번째 인덱스가 변한다. 메모리에서 옆 칸으로 이동한다. stride-1 접근이다.
열 방향 접근
A[0][j], A[1][j], A[2][j], ...첫 번째 인덱스가 변한다. 행 하나 크기만큼 이동한다. stride-n 접근이다.
배열별 판정법
| 배열 | 식 | 안쪽에서 이 변수가 변하면 좋음 | 안쪽에서 이 변수가 변하면 나쁨 | 이유 |
|---|---|---|---|---|
| A | A[i][k] | k | i | 두 번째 인덱스 k가 변해야 행 방향이다. |
| B | B[k][j] | j | k | 두 번째 인덱스 j가 변해야 행 방향이다. |
| C | C[i][j] | j | i | 두 번째 인덱스 j가 변해야 행 방향이다. |
B[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를 어떻게 움직이는지로 결정된다.
j면 대체로 빠르고, i면 대체로 느리며, k면 중간이다. 그다음 A, B, C에서 그 글자가 첫 번째 인덱스인지 두 번째 인덱스인지 확인한다.ijk를 읽는 법
루프 이름은 바깥에서 안쪽 순서다
C[i][j]를 고른다.k = 0..n-1을 빠르게 돈다.ijk 한 장면
i와 j가 고정된 상태에서 k만 변한다. 즉 C[i][j] 한 칸을 만들기 위해 A의 한 행과 B의 한 열을 곱해 더한다.
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.xls의 corei7mmdata 값을 바탕으로 다시 그린 것이다.
kij, ikj, ijk, jik, jki, kji 값을 포함한다.
matmult/mm.c
여섯 loop order 구현과 측정 driver가 들어 있는 공식 C 코드.
이 차트는 원문 그림을 캡처한 이미지가 아니라, CSAPP 공식 XLS의 수치를 문서 스타일에 맞게 다시 그린 것이다. 아래 단순 bar chart는 같은 결론을 외우기 위한 요약이다.
inner-loop miss 근사 차트
값은 CSAPP식 단순 근사다. 그래프에서 짧을수록 miss가 적다.
| 순서 | 안쪽 루프 | A 접근 | B 접근 | C 접근 | 판정 |
|---|---|---|---|---|---|
ijk | k | 행 방향 A[i][k] | 열 방향 B[k][j] | sum 후 1회 store | 중간 |
jik | k | 행 방향 A[i][k] | 열 방향 B[k][j] | sum 후 1회 store | 중간 |
jki | i | 열 방향 A[i][k] | scalar r = B[k][j] | 열 방향 C[i][j] | 느림 |
kji | i | 열 방향 A[i][k] | scalar r = B[k][j] | 열 방향 C[i][j] | 느림 |
kij | j | scalar r = A[i][k] | 행 방향 B[k][j] | 행 방향 C[i][j] | 빠름 |
ikj | j | scalar r = A[i][k] | 행 방향 B[k][j] | 행 방향 C[i][j] | 빠름 |
ijk, jik는 sum을 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 class | ijk, jik | 약 1.25 misses/iteration | A는 행 방향이지만 B가 열 방향이다. C는 안쪽 루프 밖에서 한 번 저장된다. |
| AC class | jki, kji | 약 2.0 misses/iteration | 안쪽 루프가 i라서 A와 C가 모두 열 방향으로 움직인다. |
| BC class | kij, ikj | 약 0.5 misses/iteration | 안쪽 루프가 j라서 B와 C를 행 방향으로 연속 접근한다. |
이 miss 수치는 32B block, 8B double, 충분히 큰 n 같은 분석 가정 아래의 근사다. 열 방향 접근이 언제나 100% miss라는 뜻은 아니다. cache size, associativity, prefetching, TLB, compiler optimization에 따라 실제 측정값은 달라진다.
첫 두 루프를 바꾸면 왜 pair 성능이 비슷한가
ijk와 jik, jki와 kji, kij와 ikj는 각각 가장 안쪽 루프가 같다. 바깥 두 루프의 순서가 바뀌어도 안쪽 루프에서 A, B, C를 움직이는 핵심 access pattern은 거의 같다. 그래서 큰 성능 분류도 pair 단위로 묶인다.
실제 기계에서는 prefetching, TLB, write policy, compiler optimization 때문에 완전히 같지는 않다. 그래도 Chapter 6의 큰 결론은 “안쪽 루프의 locality가 전체 성능을 크게 지배한다”이다.
대표 코드
ikj: 빠른 쪽
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: 느린 쪽
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 블록을 캐시에 올려두고 가능한 많이 재사용하는 것이다.
큰 행렬 전체가 아니라 작은 A/B/C tile을 cache에 올리고, 그 안에서 재사용을 최대화한다.
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 조건을 본다.
n이 L로 나누어떨어진다고 가정하면 안 된다. 마지막 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. 언어별 메모리 레이아웃 감각
| 언어 | 메모리 레이아웃 제어 | 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 예시
// 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에 그대로 적용하면 부정확하다.
Array를 C 배열처럼 단정하지 말고, 고정 폭 숫자 데이터를 반복 순회할 때는 ArrayBuffer 위의 typed array와 i * n + j flat index가 locality 설명에 가장 잘 맞는다고 기억한다. Bun이 항상 V8보다 빠르다고 말하는 것도 피한다. workload와 엔진 버전에 따라 달라진다.Python / Ruby 감각: 기본 컨테이너는 pointer chasing에 가깝다
# 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
Player[]는 C의 struct Player players[N]처럼 객체 본체가 연속으로 들어가는 배열이 아니다. 배열 안에는 객체 reference가 연속으로 들어간다.Java에서 int[], double[] 같은 primitive array는 값들이 배열 내부에 연속 배치되는 감각을 준다. 반면 Player[] 같은 class array는 객체 자체가 아니라 객체 참조들이 배열 안에 들어간다. 실제 객체들은 heap 곳곳에 따로 있을 수 있다.
primitive 배열: 값 자체가 한 줄로 놓인다
int[] hp = new int[5];
hp[0] = 100;
hp[1] = 80;
hp[2] = 60;
hp[3] = 40;
hp[4] = 20;
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 하나를 가져왔을 때 여러 값을 함께 활용할 수 있다.
for (int i = 0; i < size; i++) {
if (hp[i] <= 0) {
// 죽은 플레이어 처리
}
}
class 배열: reference 표가 먼저 있고 객체는 따로 있다
final class Player {
int id;
int hp;
float x;
float y;
}
Player[] players = new Player[5];
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 값
for (int i = 0; i < size; i++) {
Player p = players[i]; // reference 읽기
if (p.hp <= 0) { // 객체 안의 필드 읽기
// 죽은 플레이어 처리
}
}
해결 방향: 객체를 필드별 primitive 배열로 풀기
Player[]는 “플레이어 서류 위치 목록”이다. int[] hp, float[] x, float[] y는 “체력표, x좌표표, y좌표표”를 각각 한 장씩 만든 것이다. 체력만 검사할 때는 체력표만 위에서 아래로 읽으면 된다.객체 중심 구조
final class Player {
int id;
int hp;
float x;
float y;
boolean alive;
}
Player[] players = new Player[capacity];
필드별 배열 구조
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]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[] alive | SoA, Structure of Arrays | 같은 필드끼리 연속 배치되어 대량 계산에 유리하다. | 객체지향적 표현이 약해지고, index 관리가 필요하다. |
Player[]를 엄밀히 말하면 C의 AoS와 같다고 보기 어렵다. C의 struct Player players[N]는 struct 값이 연속 배치될 수 있지만, Java의 Player[]는 reference 배열이다. 그래도 “객체 중심 배열”과 “필드별 배열”의 대비로 AoS / SoA 용어를 쓴다.// 객체 배열: 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는 객체에 남기는 편이 현실적이다.
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 피하기
Integer[] scores1; // Integer 객체 reference 배열
List<Integer> scores2; // 내부적으로 boxing 비용이 생길 수 있음
int[] scores3; // primitive 값 배열
성능이 중요한 숫자 루프에서는 boxed type보다 primitive array가 예측 가능하다. 단, 이런 최적화는 코드 구조를 복잡하게 만들 수 있으므로 병목이 확인된 루프부터 적용한다.
14. 실전 체크리스트
- 가장 안쪽 루프 변수를 먼저 본다.
- 그 변수가 각 배열의 첫 번째 인덱스인지 두 번째 인덱스인지 본다.
- C row-major에서는 두 번째 인덱스가 변해야 연속 접근이다.
- 안 변하는 값은 register에 잡힌 scalar처럼 재사용되는지 본다.
- stride가 1인지, 행 크기만큼 점프하는지 확인한다.
- working set이 L1/L2/L3 중 어디에 들어갈지 추정한다.
- miss가 compulsory, capacity, conflict 중 어디에 가까운지 분류한다.
- 큰 행렬에서는 blocking으로 작은 타일을 cache에 넣을 수 있는지 본다.
- Java에서는 객체 배열이 값 배열이 아니라 참조 배열이라는 점을 기억한다.
- JavaScript 숫자 커널은 typed array와 flat indexing을 우선 고려한다.
15. 연습 문제
- 문제: 64B cache line에 8B
double은 몇 개 들어가는가?
답: 8개. - 문제: C 배열
A[i][j]에서j가 안쪽 루프에서 변하면 왜 좋은가?
답: row-major에서 같은 행의 원소들이 연속 배치되어 stride-1 접근이 되기 때문이다. - 문제:
ijk에서 A와 B 중 어느 쪽이 cache-friendly하지 않은가?
답: B. 안쪽 루프k가 변하면B[k][j]는 열 방향으로 움직인다. - 문제:
ikj와kij가 빠른 공통 이유는?
답: 안쪽 루프가j라서 B와 C를 행 방향으로 연속 접근하고, A는 scalar처럼 재사용된다. - 문제: blocking의 핵심 목적은 spatial locality인가 temporal locality인가?
답: 주 목적은 temporal locality 강화다. 작은 타일을 cache에 넣고 여러 번 재사용한다. - 문제: Java에서
Player[]가int[] hp보다 대량 체력 검사에 불리할 수 있는 이유는?
답:Player[]는 객체 참조 배열이고 실제 객체는 흩어져 있을 수 있다.hp만 연속으로 읽기 어렵다.
최종 암기 카드
현재 반복 구간이 계속 만지는 데이터 묶음이다. 이 크기가 L1, L2, L3 경계를 넘으면 성능 계단이 생긴다.
연속 원소를 읽으면 cache line을 거의 다 쓰고, 큰 stride는 가져온 line 일부만 쓰고 버린다.
C의 2차원 배열은 두 번째 인덱스가 변할 때 연속 접근이다. 첫 번째 인덱스가 변하면 행 크기만큼 점프한다.
ikj, kij는 inner j라서 B와 C가 row-wise다. jki, kji는 inner i라서 A와 C가 column-wise다.
큰 문제를 cache에 들어가는 작은 tile로 쪼개서 temporal locality를 살린다.
int[]는 값 배열 감각이고, Player[]는 reference 배열이다. hot data는 primitive SoA로 분리할 수 있다.
참고 기준
아래 공개 자료의 개념 흐름과 수식 기준으로 대조했다.
- CMU 15-213 Cache Memories lecture
- CMU 15-213 Memory Hierarchy lecture
- CS:APP Chapter 6 preview
- CS:APP official figure source files
- CS:APP Figure 6.43-6.45 memory mountain XLS
- CS:APP Figure 6.48 matrix multiply performance XLS
- CS:APP memory mountain source code
- CS:APP, A Gallery of Memory Mountains
- Cornell CS 3410 Cache Optimization notes
- V8 hidden classes, V8 elements kinds
- Bun runtime documentation, WebKit JavaScriptCore documentation
- MDN JavaScript typed arrays
- Python C API list object, NumPy ndarray documentation
- Apache Arrow columnar memory format, Polars documentation