7.2 메모리 계층과 캐시 일관성 — RAM이라는 이름은 균일 비용을 약속하지 않는다
같은 O(n) 순회의 실측이 10배 갈리고 스레드를 늘렸는데 총처리량이 떨어진다면, 원인은 대부분 코드가 아니라 데이터가 어디에 어떻게 놓여 있는가에 있다. 이 문서는 캐시 계층·캐시 라인·프리페처·일관성 프로토콜의 동작 모델을 세우고, working set 크기별 접근 시간 곡선과 false sharing 확장성 실험으로 그 모델을 검증한다. 1.1의 147배 실측이 현상으로 남겨 둔 원인을 여기서 회수한다.
학습 목표
- 메모리 계층이 존재하는 이유를 비용 구조와 지역성 베팅으로 설명하고, L1부터 DRAM까지의 지연을 자릿수로 배치한다.
- 캐시 라인·연관도·프리페처·메모리 수준 병렬성(MLP)을 근거로 접근 패턴별 성능 차이를 진단한다.
- 캐시 미스를 compulsory·capacity·conflict·coherence로 분류하고 각 미스를 줄이는 데이터 배치를 선택한다.
- 캐시 일관성 프로토콜이 코어 간에 만드는 트래픽을 설명하고, false sharing을 확장성 곡선과 패딩 대조 실험으로 진단·수리한다.
- working set 크기 실험과 worker 확장성 실험을 직접 실행해 자기 하드웨어의 계층 경계를 확인한다.
배경: 왜 이것이 존재하는가
빠른 메모리는 작고 비싸며, 큰 메모리는 느리다. 이것은 공학적 절충이 아니라 물리와 경제의 제약이다 — SRAM 셀은 트랜지스터 6개로 커서 비싸고 빠르며, DRAM 셀은 1개로 싸고 크지만 느리다. 여기에 신호가 이동해야 하는 물리적 거리가 더해진다. 그래서 어떤 단일 기술로도 "크고 빠른" 메모리는 만들 수 없다.
한편 CPU 코어의 연산 속도와 DRAM 지연의 격차는 수십 년간 벌어져 왔다(이른바 memory wall). 오늘날 DRAM 접근 한 번의 시간이면 코어는 산술 명령을 수백 개 실행할 수 있다. 이 격차를 안고도 CPU가 굶지 않는 이유는 계층 구조가 지역성(locality)이라는 경험적 성질에 베팅하기 때문이다.
- 시간 지역성: 방금 접근한 데이터는 곧 다시 접근될 가능성이 높다 — 그러니 작은 빠른 메모리에 잡아 둔다.
- 공간 지역성: 접근한 주소의 이웃도 곧 접근될 가능성이 높다 — 그러니 한 번에 덩어리(캐시 라인)로 가져온다.
베팅이 맞는 동안 메모리는 L1처럼 빠르고 DRAM처럼 커 보인다. 이 문서의 모든 성능 현상은 이 베팅이 어긋나는 지점에서 나온다. 그리고 중요한 것은, 베팅의 성패를 하드웨어가 아니라 코드의 데이터 배치와 접근 순서가 결정한다는 점이다.
핵심 개념
지연의 자릿수 — L1과 DRAM은 다른 세계다
대표적인 자릿수 감각(구체 값은 CPU 세대·구성 의존):
| 계층 | 지연 자릿수 | 비고 |
|---|---|---|
| L1 캐시 | ~1 ns (4~5 사이클) | 코어 전용, 수십~수백 KiB |
| L2 캐시 | 수 ns (십수 사이클) | 코어 전용 또는 클러스터 공유, 수백 KiB~수십 MiB |
| L3/SLC | ~10 ns대 | 여러 코어 공유, 수 MiB~수백 MiB |
| DRAM | ~100 ns | 산술 명령 수백 개 분량 |
이 표의 값을 외우는 것보다 자기 하드웨어에서 확인하는 절차가 중요하다. 캐시 파라미터는 macOS에서 sysctl hw.cachelinesize hw.perflevel0.l1dcachesize hw.perflevel0.l2cachesize, Linux에서 lscpu -C 또는 getconf -a | grep CACHE로 읽는다. 지연 자체는 아래 관찰 1이 측정한다.
여기서 지연 시간(latency)과 대역폭(bandwidth)을 구분해야 한다. 지연은 의존적인 접근 하나가 기다리는 시간이고, 대역폭은 많은 독립적인 데이터를 지속해서 옮기는 처리량이다. DRAM은 지연이 나쁜 것에 비해 대역폭은 훌륭하다 — 순차 스트리밍이 포인터 추적보다 압도적으로 빠른 이유의 절반이 여기 있다(나머지 절반은 프리페처와 MLP다). "메모리가 느리다"는 진단은 둘 중 무엇이 모자란지 나눠야 처방이 나온다: 지연 병목은 접근 패턴·배치를 바꿔야 하고, 대역폭 병목은 옮기는 총량을 줄여야 한다.
캐시 라인 — 전송의 단위가 성능의 단위다
캐시와 메모리 사이의 전송 단위는 바이트가 아니라 캐시 라인(x86 64B, Apple Silicon 128B가 일반적)이다. arr[0]을 읽으면 그 라인 전체가 올라온다. 이 단위 하나가 두 얼굴을 만든다.
- 공짜 로드: 4바이트 정수를 읽었는데 이웃 15개(64B 라인 기준)가 같이 올라왔다. 이어서 그 이웃들을 읽으면 전부 L1 적중이다. 순차 순회가 빠른 직접적 이유다.
- 낭비되는 로드: 라인에서 4바이트만 쓰고 버리면 전송량의 94%가 낭비다. 큰 stride로 건너뛰는 순회, 뿔뿔이 흩어진 객체 필드 접근이 이 경우다.
2차원 데이터의 행/열 순회가 고전 사례다. 행 우선으로 저장된 배열을 행 방향으로 순회하면 라인의 모든 원소를 쓰고 넘어가지만, 열 방향으로 순회하면 라인당 원소 하나만 쓰고 다음 라인을 부른다. 같은 O(n²), 같은 연산량에서 전송량이 라인 크기 배수만큼 갈린다 — 챕터 실습에서 이 차이를 직접 측정하고, working set 크기·프리페처라는 경쟁 원인과 분리한다.
관찰 1 — working set 곡선에서 계층 경계가 보인다
계층 구조는 코드에 보이지 않지만 측정에는 선명하게 나타난다. 배열 원소가 "다음에 방문할 인덱스"를 담게 만들어(전체가 하나의 사이클인 무작위 순열) 포인터 추적을 재현한다. 무작위 순서라 프리페처가 돕지 못하고, 다음 주소가 직전 로드 결과에 의존하므로 지연이 그대로 드러난다.
// wset.mjs — node wset.mjs 로 실행
const STEPS = 1 << 23; // 크기와 무관하게 같은 접근 횟수
const sizesKiB = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096,
8192, 16384, 32768, 65536, 131072, 262144];
function makeCycle(n) {
// Sattolo 알고리즘: 전체가 하나의 사이클인 순열
const next = new Int32Array(n);
for (let i = 0; i < n; i++) next[i] = i;
for (let i = n - 1; i > 0; i--) {
const j = Math.floor(Math.random() * i); // j < i
[next[i], next[j]] = [next[j], next[i]];
}
return next;
}
function chase(next, steps) {
let p = 0;
for (let i = 0; i < steps; i++) p = next[p];
return p;
}
let sink = 0;
for (const kib of sizesKiB) {
const n = (kib * 1024) / 4;
const next = makeCycle(n);
sink = chase(next, STEPS); // 워밍업 겸 JIT 승격
const samples = [];
for (let r = 0; r < 5; r++) {
const t0 = process.hrtime.bigint();
sink = chase(next, STEPS);
samples.push(Number(process.hrtime.bigint() - t0));
}
samples.sort((a, b) => a - b);
console.log(`${String(kib).padStart(6)} KiB: ${(samples[2] / STEPS).toFixed(2)} ns/access`);
}
console.log('checksum', sink);Node.js v24.14.0, Apple M5 Pro(P-코어 L1d 128KiB, P-클러스터 공유 L2 16MiB, 라인 128B), macOS 26.5의 실측:
| working set | 접근당 시간 | 해석 |
|---|---|---|
| 16~128 KiB | 1.3 ns | L1 안 — 평평하다 |
| 256 KiB~1 MiB | 2.4~3.1 ns | L1 경계(128KiB)를 넘어 L2로 |
| 2~16 MiB | 5.4~13.3 ns | L2 안에서 점진 증가 |
| 32~64 MiB | 44~80 ns | L2 경계(16MiB)를 넘어 SLC·DRAM 혼합 |
| 128~256 MiB | 98~107 ns | 사실상 매 접근이 DRAM |
같은 코드, 같은 접근 횟수인데 원소당 비용이 80배 갈린다. 곡선의 꺾이는 지점이 sysctl로 읽은 캐시 크기와 일치한다는 것 — 이것이 "곡선이 캐시 계층 때문"이라는 가설의 핵심 증거다. 두 가지 주의: 첫째, 큰 working set 구간에는 주소 변환 캐시(TLB)의 미스 비용도 섞여 있다. 주소 변환에도 캐시가 있다는 사실과 그 커버리지는 8.2 가상 메모리가 다룬다. 둘째, macOS는 스레드를 P/E 코어 어디에 둘지 스스로 정하므로 어느 코어의 캐시 파라미터가 적용됐는지 통제되지 않는다 — 경계를 정밀하게 읽으려면 코어 고정이 가능한 Linux에서 재현한다.
이 곡선은 1.1의 147배를 정량적으로 회수한다. 힙 전체에 흩어진 연결 리스트의 노드 추적은 이 곡선의 오른쪽 끝(접근당 ~100ns)에서 실행되고, 배열 순회는 왼쪽 끝 + 프리페처의 도움으로 원소당 1ns 아래에서 실행된다. 두 자릿수 배율은 여기서 나온다.
매핑과 연관도 — 2의 거듭제곱 stride의 함정
캐시는 라인을 아무 곳에나 두지 않는다. 주소의 일부 비트로 세트(set)를 정하고, 각 세트 안의 몇 개 슬롯(way) 중 하나에 둔다 — N-way set-associative 구조다. 이 매핑이 만드는 함정이 conflict miss다: 서로 주소가 세트 수 × 라인 크기의 배수만큼 떨어진 데이터들은 같은 세트를 두고 경쟁한다. 캐시 전체 용량은 텅텅 비어 있는데 특정 세트만 넘쳐서 미스가 나는 상황이다.
실무에서 이것이 나타나는 전형이 2의 거듭제곱 stride다. 4096바이트 간격으로 원소를 순회하면(행 크기가 2^k인 행렬의 열 순회, 2^k 크기 구조체 배열의 특정 필드만 순회) 접근들이 소수의 세트에 몰린다. working set이 캐시 용량보다 훨씬 작은데도 성능이 무너지면 conflict를 의심하고, stride나 행 크기에 패딩을 더해(예: 행 길이를 2^k + 라인 하나로) 세트 분산을 되살리는 것이 처방이다.
하드웨어 프리페처 — 패턴이 보이면 미리 가져온다
프리페처는 미스가 나기 전에 데이터를 가져와 지연을 숨기는 투기 장치다. 순차 접근과 일정한 stride는 몇 번의 접근만으로 감지해 몇 라인 앞서 달린다. 배열 순회가 DRAM 대역폭 수준의 속도로 흐르는 이유다.
프리페처가 무력한 것이 포인터 추적이다. 다음 주소가 데이터 안에 있으므로 로드가 끝나기 전에는 어떤 하드웨어도 다음 위치를 알 수 없다. 관찰 1이 의도적으로 무작위 사이클을 쓴 이유이자, 1.1의 무작위 연결 리스트가 147배로 진 나머지 절반의 원인이다. 프리페처는 공짜도 아니다 — 잘못 예측된 프리페치는 대역폭을 낭비하고 유용한 라인을 밀어내므로, 대역폭이 포화된 스트리밍 워크로드에서는 불규칙 접근을 섞지 않는 것이 프리페치 정확도까지 지킨다.
MLP — 미스를 겹칠 수 있는가가 지연을 나눈다
7.1의 비순차 실행은 메모리에서 이렇게 나타난다: 캐시 미스로 멈춘 로드 뒤에서도 CPU는 계속 명령어를 살펴보고, 서로 독립인 미스 여러 개를 동시에 진행시킨다(memory-level parallelism, MLP). 미스 10개를 겹치면 체감 지연은 1/10이 된다. 배열 기반 무작위 접근(인덱스가 미리 계산 가능한 경우)이 연결 리스트보다 훨씬 빠른 이유다 — 같은 무작위 접근이라도 전자는 미스를 겹칠 수 있고, 후자는 다음 주소가 직전 결과에 의존해 한 번에 하나씩만 진행된다.
관찰 1의 수치가 이를 보여 준다. DRAM 구간의 접근당 107ns는 DRAM 지연 그 자체다 — 의존 체인이라 MLP가 0에 가깝기 때문이다. 같은 배열을 독립 인덱스로 무작위 접근하게 바꾸면 접근당 시간은 이보다 수 배 낮게 나온다. 즉 "무작위 접근은 느리다"의 정확한 형태는 "의존적인 무작위 접근은 지연을 숨길 수 없다"이다.
캐시 미스의 분류 — 처방이 다르므로 나눈다
- compulsory: 처음 접근하는 라인. 프리페치나 전송량 자체의 축소로만 줄인다.
- capacity: working set이 캐시보다 크다. 처방은 working set 축소 — 블로킹(타일링), 더 작은 타입, 핫 데이터 분리.
- conflict: 세트 경쟁. 처방은 stride·정렬 조정, 패딩.
- coherence: 다른 코어의 쓰기가 내 사본을 무효화했다. 멀티코어 고유의 미스로, 아래에서 다룬다.
주소 변환 캐시(TLB) 미스는 이 분류와 별개 축으로 겹쳐 나타나며 8.2의 영역이다. 컨텍스트 스위치가 캐시·TLB에 쌓인 상태를 오염시켜 스위치 직후 미스의 연쇄(재워밍업 구간)를 만든다는 것은 8.1이 다루고 측정한다.
데이터 배치는 코드의 결정이다 — AoS vs SoA, 핫/콜드
"캐시는 하드웨어 문제"라는 통념과 달리, 위 미스들의 대부분은 배치 결정의 결과다. 대표 패턴 둘:
// AoS(Array of Structures): 객체 단위 배치 — 한 개체의 모든 필드가 이웃
const users = [{ id: 1, age: 30, bio: '...' }, /* ... */];
let sum = 0;
for (const u of users) sum += u.age; // age 4바이트를 위해 개체 전체 라인을 로드
// SoA(Structure of Arrays): 필드 단위 배치 — 같은 필드끼리 이웃
const ages = new Int32Array(n); // age만 순회하면 라인의 100%가 유효 데이터한 필드만 훑는 집계·필터가 지배적이면 SoA가 전송량을 라인 활용률만큼 줄이고, 한 개체의 여러 필드를 함께 쓰는 접근이 지배적이면 AoS가 맞다. 같은 원리의 변형이 핫/콜드 필드 분리다 — 자주 접근하는 필드와 어쩌다 접근하는 큰 필드(설명 문자열, 이력)를 한 구조체에 섞으면 핫 순회의 라인 활용률이 무너지므로, 콜드 필드를 별도 배열이나 참조 뒤로 밀어낸다. 이것이 1.2에서 세운 배열 vs 포인터 추적 판단 기준의 하드웨어 쪽 근거다.
캐시 일관성 — 사본이 여럿이면 합의가 필요하다
멀티코어에서 같은 라인의 사본이 여러 코어의 캐시에 있을 수 있다. 하드웨어는 캐시 일관성 프로토콜(MESI 계열: 라인마다 Modified/Exclusive/Shared/Invalid 상태)로 "같은 주소에 대한 쓰기가 결국 모두에게 같은 순서로 보인다"를 보장한다. 상태 전이표를 외울 필요는 없다. 성능에 중요한 구조는 하나다:
어떤 코어가 라인에 쓰려면 그 라인의 다른 모든 사본을 무효화해야 한다. 쓰기 권한(Modified/Exclusive)은 한 번에 한 코어만 가진다. 코어 A가 쓴 라인을 코어 B가 읽으면 A의 캐시에서 B로 라인이 넘어가고, B가 다시 쓰면 A의 사본이 무효화된다. 읽기 전용 공유는 평화롭다 — 모두가 Shared 사본을 들고 로컬 캐시 속도로 읽는다. 문제는 쓰기가 섞인 공유다. 같은 라인을 여러 코어가 번갈아 쓰면 라인이 코어 사이를 핑퐁하고, 매 이동이 코어 간 왕복(수십 ns, DRAM에 준하는 자릿수)을 치른다. 이 왕복이 위 분류의 coherence miss다.
하드웨어가 보장하는 것은 여기까지 — 단일 주소에 대한 쓰기 가시성의 합의 — 라는 점도 분명히 하자. 서로 다른 주소에 대한 접근들이 어떤 순서로 보이는지(메모리 순서), 언어 수준에서 무엇을 약속하는지(happens-before, atomics)는 별개의 층이고 챕터 8a에서 다룬다.
관찰 2 — false sharing: 논리적 공유가 없어도 물리적 공유는 생긴다
일관성 프로토콜의 단위는 변수가 아니라 라인이다. 서로 다른 변수라도 같은 라인에 있으면 프로토콜의 눈에는 하나다. worker마다 자기 카운터만 갱신하는 — 논리적 공유가 전혀 없는 — 코드로 재현한다.
// false-sharing-worker.mjs — 자기 슬롯만 갱신하는 worker
import { parentPort, workerData } from 'node:worker_threads';
const { sab, index, iters } = workerData;
const counters = new Int32Array(sab);
// 워밍업: JIT 승격을 미리 끝낸다
for (let i = 0; i < 1_000_000; i++) Atomics.add(counters, index, 0);
parentPort.once('message', () => {
for (let i = 0; i < iters; i++) Atomics.add(counters, index, 1);
parentPort.postMessage('done');
});// false-sharing.mjs — 인접 카운터 vs 패딩된 카운터의 확장성
import { Worker } from 'node:worker_threads';
const ITERS = 20_000_000;
const LINE_INTS = 128 / 4; // 라인 128B(Apple Silicon) 기준, x86은 64/4
function runOnce(workers, stride) {
return new Promise((resolve) => {
const sab = new SharedArrayBuffer(4 * stride * workers + 256);
const ws = [];
let ready = 0, done = 0, t0 = 0n;
for (let w = 0; w < workers; w++) {
const worker = new Worker(new URL('./false-sharing-worker.mjs', import.meta.url), {
workerData: { sab, index: w * stride, iters: ITERS },
});
worker.once('online', () => {
if (++ready === workers) {
setTimeout(() => { // 워밍업을 기다렸다가 동시에 출발
t0 = process.hrtime.bigint();
for (const x of ws) x.postMessage('go');
}, 300);
}
});
worker.once('message', () => {
if (++done === workers) {
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
for (const x of ws) x.terminate();
resolve(ms);
}
});
ws.push(worker);
}
});
}
async function run(workers, stride, label) {
const samples = [];
for (let r = 0; r < 5; r++) samples.push(await runOnce(workers, stride));
samples.sort((a, b) => a - b);
const ms = samples[2];
console.log(`${label} workers=${workers}: ${ms.toFixed(0)} ms, ` +
`total ${((ITERS * workers) / ms / 1e3).toFixed(0)} Mops/s`);
}
for (const w of [1, 2, 4]) await run(w, 1, '인접(4B 간격) ');
for (const w of [1, 2, 4]) await run(w, LINE_INTS, '패딩(128B 간격)');같은 환경(코어 18개: P 6 + E 12)의 실측:
| 구성 | worker 1 | worker 2 | worker 4 |
|---|---|---|---|
| 인접(같은 라인) | 180 Mops/s | 85 Mops/s | 83 Mops/s |
| 패딩(라인 분리) | 183 Mops/s | 355 Mops/s | 724 Mops/s |
worker 1개일 때 두 구성은 같다 — 공유가 없으니 당연하다. worker를 늘리자 인접 구성은 총처리량이 절반 이하로 후퇴하고(코어를 4배 쓰는데 1코어보다 느리다), 패딩 구성은 거의 선형으로 확장된다. 각 worker는 여전히 자기 슬롯만 쓴다. 달라진 것은 슬롯들이 같은 128B 라인에 있는가뿐이다. 인접 구성에서는 매 증가마다 라인의 소유권이 코어 사이를 핑퐁하고, 그 왕복 비용이 증가 연산 자체보다 수십 배 크다.
증거 해석 — cache-misses 카운터는 false sharing의 직접 증거가 아니다
perf stat의 일반 cache-misses는 compulsory·capacity·conflict·coherence를 합산한 값이라, 그 수치가 높다는 것만으로 false sharing을 지목할 수 없다. 이 실험의 기본 증거는 한 변수만 바꾼 대조군의 확장성 곡선이다 — 패딩(라인 분리) 유무만 바꿨을 때 확장성이 무너졌다 회복되면, 원인이 라인 공유였다는 것이 곧 실험 설계에서 따라 나온다. 카운터로 보강하려면 일반 미스가 아니라 coherence를 구분하는 이벤트가 필요하다: Linux perf c2c(캐시 라인 경합 분석 전용), 지원되는 CPU의 HITM(다른 코어의 Modified 라인 적중) 계열 이벤트가 그것이다. 지원 이벤트는 CPU마다 다르므로, 특정 이벤트가 없다고 진단이 불가능해지지 않도록 시간·확장성 곡선을 항상 기본 증거로 삼는다.
실무 관점
"메모리를 더 쓰더라도 접근을 연속으로"가 이기는 조건
포인터 그래프를 배열로 펴거나, 인덱스를 정렬해 접근 순서를 메모리 순서에 맞추거나, 중복을 감수하고 데이터를 복제해 순차 스트림을 만드는 최적화는 모두 "전송량·메모리 사용량을 늘려서 지연·MLP·프리페처 조건을 개선하는" 교환이다. 이기는 조건: working set이 캐시를 넘고, 접근이 지배적 비용이며, 순회가 반복된다. 지는 조건: 데이터가 어차피 L1~L2에 다 들어가는 크기(배치를 바꿔도 곡선의 평평한 구간 안이다), 순회가 한 번뿐(재배치 비용을 회수 못 한다), 대역폭이 이미 포화(전송량 증가가 역효과).
false sharing의 전형과 처방
전형적인 발생 지점은 "스레드별로 하나씩"인 작은 쓰기 대상들이다 — 스레드별 카운터·통계 배열, worker 상태 플래그 배열, 락 구조체 배열. stats[threadId]++ 패턴이 보이면 의심한다. 처방은 세 가지다.
- 패딩: 스레드별 항목을 라인 크기 간격으로 배치한다(관찰 2의 수리). 언어에 따라 정렬 지시자(C++
alignas(std::hardware_destructive_interference_size), Rust#[repr(align(128))])가 있다. - 스레드 로컬 집계: 각 스레드가 자기 지역 변수에 누적하고 마지막에 한 번 합친다. 핫 경로에서 공유 쓰기 자체를 없애므로 패딩보다 근본적이다.
- 쓰기 빈도 축소: N번에 한 번만 공유 카운터에 반영한다.
역방향 함정도 있다: 라인 크기 패딩을 남발하면 캐시 용량과 대역폭을 낭비한다. 확장성 곡선으로 문제가 입증된 지점에만 적용한다.
스레드를 늘렸는데 느려지는 패턴의 진단 순서
관찰 2는 4코어에서 1코어보다 느린 극단을 보였다. 실무에서 "코어를 늘렸는데 확장이 안 된다"를 만나면: (1) worker 수를 1, 2, 4로 바꾼 확장성 곡선을 그린다 — 완만한 포화는 대역폭·락 경합 쪽, 절대 성능의 후퇴는 일관성 트래픽(false/true sharing) 쪽 가설에 무게가 실린다. (2) 공유 쓰기 대상을 찾아 패딩·로컬 집계 대조군을 만든다. (3) 회복되지 않으면 진짜 공유(true sharing — 같은 변수를 실제로 다투는 경우), 메모리 대역폭 포화, 8.1의 스케줄링 요인으로 가설을 넓힌다. 참고로 다중 소켓 서버에는 NUMA(코어와 메모리의 물리적 원근)라는 추가 축이 있어 "어느 소켓의 메모리에 놓였는가"까지 개입하는데, 이 커리큘럼에서는 이름과 증상(소켓 경계를 넘는 접근의 지연 증가)까지만 잡아 둔다.
벤치마크 시 주의 — 하드웨어도 측정을 흔든다
이 문서의 실험을 재현할 때 1.1의 방법론에 더해 하드웨어 요인을 통제한다. 터보 부스트·발열 스로틀링은 클럭을 실험 도중에 바꾸고, P/E 이기종 코어는 실행 위치에 따라 캐시 파라미터 자체를 바꾼다. 코어 고정(Linux taskset)과 클럭 고정이 가능한 환경이 정밀 측정의 기준이고, 그것이 불가능한 환경에서는 반복·중앙값과 함께 "절대값이 아니라 배율의 구도"로 결론을 제한한다.
더 깊이
캐시 라인 크기를 코드가 어떻게 아는가
이식성 있는 코드는 라인 크기를 하드코딩하는 대신 런타임에 조회하거나(macOS sysctl hw.cachelinesize, Linux sysconf(_SC_LEVEL1_DCACHE_LINESIZE)), 보수적으로 큰 값(128B)을 쓴다. C++17의 std::hardware_destructive_interference_size(false sharing 방지용)와 hardware_constructive_interference_size(함께 두고 싶은 데이터용) 쌍은 이 두 방향의 요구를 표준 어휘로 만든 것이다.
스토어는 어차피 기다리지 않는다 — 그런데 왜 false sharing은 아픈가
쓰기는 스토어 버퍼에 넣고 지나가면 될 텐데 왜 관찰 2가 저렇게 느린지 의문이 들 수 있다. 두 이유다. 첫째, 스토어가 버퍼에서 캐시로 실제 반영되려면 결국 그 라인의 배타적 소유권이 필요하므로, 소유권 왕복 비용은 사라지지 않고 뒤로 밀릴 뿐이며 지속적인 쓰기 스트림에서는 버퍼가 차서 결국 실행을 막는다. 둘째, 관찰 2의 Atomics.add는 원자적 read-modify-write라 소유권 확보가 완료될 때까지 다음 연산이 진행되지 못한다 — 경합 비용이 즉시, 전액 청구되는 조건이다. 일반 쓰기라면 후퇴 폭은 이보다 완만하지만 확장성이 깎이는 구조는 같다.
정리
- 메모리 계층은 지역성에 대한 베팅이고, 베팅의 성패는 코드의 데이터 배치와 접근 순서가 결정한다. L1과 DRAM의 지연은 두 자릿수 배율로 다르다.
- 전송 단위는 캐시 라인이다. 라인 활용률이 순차/stride/포인터 추적, 행/열 순회, AoS/SoA의 성능 차이를 만든다.
- 프리페처와 MLP는 독립적인 접근의 지연만 숨긴다. 다음 주소가 직전 결과에 의존하는 포인터 추적은 어느 쪽의 도움도 받지 못한다 — 147배의 원인이다.
- 미스는 compulsory·capacity·conflict·coherence로 나눠야 처방이 나온다. working set 곡선, stride 실험, 확장성 곡선이 각각을 가려내는 대조 실험이다.
- 일관성 프로토콜의 단위는 라인이다. 논리적 공유가 없어도 같은 라인에 쓰면 물리적 공유가 생기고(false sharing), 패딩·로컬 집계로 수리한다. 입증의 기본 증거는 카운터가 아니라 패딩 대조군의 확장성 곡선이다.
확인 문제
1. 관찰 1의 곡선에서 어떤 개발자가 "1MiB 지점(3.1ns)과 256MiB 지점(107ns)의 차이는 TLB 미스 때문"이라는 대안 가설을 제시했다. 이 가설과 캐시 계층 가설을 구분할 수 있는 실험이나 증거를 제시하라.
정답과 해설
두 가설 모두 "working set이 커지면 느려진다"를 예측하므로 곡선 모양만으로는 구분되지 않는다. 구분 방법: (1) 꺾이는 지점을 하드웨어 파라미터와 대조한다 — 전환점들이 sysctl/lscpu로 읽은 L1·L2 용량과 일치하는지, TLB 엔트리 수 × 페이지 크기(TLB 커버리지)와 일치하는지 각각 확인한다. (2) 페이지 크기만 바꾼 대조군 — Linux에서 huge page(2MiB)로 같은 실험을 하면 TLB 커버리지가 수백 배 늘어나므로, 곡선이 크게 개선되는 부분이 TLB 몫이고 남는 부분이 캐시 몫이다. (3) 카운터 — dTLB-load-misses와 캐시 미스 계열을 함께 기록해 각 구간에서 어느 쪽이 접근 수 대비 튀는지 본다. 실제로는 두 효과가 겹쳐 있으며, 8.2에서 TLB 쪽을 다룬다.
2. 스레드 8개가 각자 다른 인덱스의 stats[threadId].count++를 수행하는 서버가 있다. perf에서 cache-misses가 높게 나왔고, 동료는 이것으로 "false sharing 확정"이라 보고했다. 이 보고의 논리적 결함과, 확정에 필요한 추가 증거 두 가지를 제시하라.
정답과 해설
결함: 일반 cache-misses는 compulsory·capacity·conflict·coherence 미스의 합산이라, 높다는 사실은 여러 원인과 양립한다(예: working set이 그냥 커서 나는 capacity 미스). 추가 증거: (1) 한 변수만 바꾼 대조군 — stats 원소를 캐시 라인 크기로 패딩하거나 스레드 로컬 집계로 바꿨을 때 처리량·확장성이 회복되는지 본다. 회복되면 라인 공유가 원인이었다는 직접 증거다. (2) coherence를 구분하는 관찰 — perf c2c로 경합 라인과 접근 오프셋을 확인하거나, 지원되는 CPU에서 HITM 계열 이벤트가 해당 코드 경로에 몰리는지 본다. 스레드 수를 1·2·4·8로 바꾼 확장성 곡선에서 절대 성능의 후퇴가 보이는지도 기본 증거가 된다.
3. 1,000만 개 레코드(id, score, 512바이트 blob)에서 score 상위 100개를 매초 다시 계산하는 서비스가 느리다. 데이터 배치 관점의 가설과 개선안, 그리고 그 개선이 무의미해지는 경계 조건을 제시하라.
정답과 해설
가설: AoS 배치에서 score 순회가 레코드 전체(약 520B, 라인 여러 개)를 로드해 라인 활용률이 1% 미만이고, 전송량이 수 GiB/s 규모로 불어나 메모리 대기가 지배한다. 개선: score(와 id)를 별도의 연속 배열(SoA·핫/콜드 분리)로 빼서 순회하면 전송량이 두 자릿수 배율로 줄고 프리페처 조건도 살아난다. 검증: 배치만 바꾼 대조군의 시간 비교, 가능하면 캐시 미스 계열 카운터 동반. 경계 조건: 핫 배열이 캐시에 다 들어가는 크기라면(1,000만 × 4B = 40MB는 L3/SLC 규모라 부분 적중) 개선 폭이 줄고, 레코드 수가 적어 전체가 L2 안이면 배치는 거의 무의미하다. 반대로 blob까지 함께 읽는 접근이 지배적인 워크로드로 바뀌면 분리가 오히려 라인 왕복을 늘린다.
참고 자료
- Ulrich Drepper, What Every Programmer Should Know About Memory (2007) — 캐시 구조, 연관도, 프리페처, 측정 기법의 1차 참조. §3–§6이 이 문서의 범위와 겹친다. 수치는 2007년 기준이므로 자릿수 감각으로만 쓴다.
- John L. Hennessy, David A. Patterson, Computer Architecture: A Quantitative Approach 6th ed. (2017), Ch. 2 & Appendix B — 캐시 최적화와 미스 분류의 표준 서술.
- Daniel J. Sorin, Mark D. Hill, David A. Wood, A Primer on Memory Consistency and Cache Coherence 2nd ed. (2020) — 일관성(coherence)과 메모리 순서(consistency)의 구분, MESI 계열 프로토콜의 정확한 모델. 이 문서가 일관성까지만 다루고 순서를 위임한 경계가 이 책의 1장에 정리되어 있다.
- Igor Ostrovsky, Gallery of Processor Cache Effects — 캐시 라인, 연관도, false sharing의 재현 가능한 미니 실험 모음. 블로그이므로 자체 재현(이 문서의 관찰 1·2가 그 재현이다)을 전제로 보조 참조한다.
- Linux perf, perf-c2c(1) — 캐시 라인 경합(HITM) 분석 도구의 공식 문서. false sharing의 카운터 기반 보강 증거를 수집할 때 사용한다.