🟣 빨라진 프로세서가 메모리를 기다린다
앞 장에서 파이프라인은 매 사이클 명령어를 뽑아낼 만큼 프로세서를 밀어 올렸다. 그런데 그 명령어의 상당수가 메모리에서 값을 읽거나 쓴다. 문제는 메모리가 프로세서만큼 빨라지지 못했다는 데 있다. 프로세서 속도는 오랜 세월 가파르게 올랐지만 DRAM 접근 속도는 훨씬 완만하게 올랐고, 그 격차가 벌어질수록 프로세서는 데이터를 기다리며 놀게 됐다.
숫자로 보면 심각하다. 레지스터 접근이 한 사이클이라면, 주 기억장치(DRAM) 접근은 수십에서 수백 사이클이 걸린다. lw 한 번에 프로세서가 백 사이클을 논다면, 애써 만든 파이프라인이 무색해진다. 이 장은 그 격차를 메우는 장치, 곧 메모리 계층과 캐시가 왜 필요했고 어떻게 동작하는지를 본다.
🟣 빠르고 작거나, 느리고 크거나
이상적인 메모리는 무한히 크고 무한히 빠르고 값도 싸야 한다. 현실에서 이 셋은 동시에 성립하지 않는다. 빠른 저장 장치는 비싸고 작으며, 싸고 큰 저장 장치는 느리다. 그래서 하나로 해결하는 대신 여러 종류를 계층으로 쌓는다.
위로 갈수록 빠르고 작고 비싸며, 아래로 갈수록 느리고 크고 싸다. 맨 위 레지스터에서 시작해 캐시(보통 L1, L2, L3로 나뉜다), 주 기억장치인 DRAM, 그 아래 디스크로 내려간다. 핵심 아이디어는 이것이다. 자주 쓰는 데이터를 위쪽 빠른 계층에 두고, 나머지는 아래에 두어, 대부분의 접근이 위쪽에서 끝나게 만든다. 그러면 평균 접근 시간이 위쪽 계층에 가까워진다.
이 계층이 성립하려면 전제가 하나 필요하다. "자주 쓰는 데이터"가 실제로 존재하고, 그것을 예측할 수 있어야 한다. 그 전제를 뒷받침하는 것이 지역성이다.
🟣 지역성: 캐시가 통하는 이유
프로그램의 메모리 접근은 아무렇게나 흩어지지 않는다. 뚜렷한 두 가지 쏠림이 있고, 이것을 지역성이라 부른다.
시간 지역성은 방금 접근한 데이터를 곧 다시 접근하는 경향이다. 반복문의 카운터 변수는 매 반복마다 읽히고, 자주 부르는 함수의 코드는 계속 실행된다. 공간 지역성은 방금 접근한 곳 근처를 이어서 접근하는 경향이다. 배열을 순서대로 훑거나, 명령어를 주소 순으로 실행하는 경우다.
캐시는 이 두 쏠림을 그대로 이용한다. 시간 지역성에 기대어 한 번 가져온 데이터를 버리지 않고 캐시에 남겨 두고, 공간 지역성에 기대어 한 바이트가 아니라 그 주변까지 한 덩어리(블록 또는 라인)로 가져온다. 배열의 한 원소를 읽을 때 옆 원소들까지 통째로 캐시에 올려 두면, 다음 원소들은 이미 캐시에 있어 빠르게 읽힌다. 지역성이 없다면 캐시는 무용지물이고, 지역성이 있기에 작은 캐시가 큰 효과를 낸다.
🟣 주소를 셋으로 자른다
캐시는 데이터를 블록 단위로 담는다. 어떤 주소의 데이터를 찾을 때, 캐시는 그 주소를 세 조각으로 잘라 읽는다. 어느 칸에 있을지(인덱스), 그 칸에 있는 것이 정말 내가 찾는 블록인지(태그), 블록 안 어디인지(오프셋)를 각각 다른 비트가 담당한다.
| 필드 | 태그 | 인덱스 | 블록 오프셋 |
|---|---|---|---|
| 역할 | 이 칸의 블록이 맞는지 확인 | 캐시의 몇 번 칸인지 | 블록 안 몇 번째 바이트인지 |
접근이 들어오면 인덱스로 칸을 찾아가고, 그 칸에 저장된 태그와 주소의 태그를 비교한다. 태그가 같으면 원하는 블록이 이미 캐시에 있는 것이니 캐시 히트이고, 오프셋으로 정확한 바이트를 꺼낸다. 태그가 다르거나 칸이 비었으면 캐시 미스이고, 아래 계층에서 블록을 통째로 가져와 그 칸에 채운 뒤 넘긴다. 캐시가 빠른 이유는 이 조회가 몇 사이클 안에 끝나기 때문이다.
🟣 어느 칸에 넣을 것인가: 사상 방식
블록을 캐시의 어느 칸에 넣느냐에 따라 세 가지 사상 방식이 있다. 여기에도 성능과 비용의 타협이 있다.
직접 사상은 한 블록이 들어갈 칸이 딱 하나로 정해진다. 조회가 단순하고 빠르지만, 서로 다른 두 블록이 같은 칸에 배정되면 번갈아 쓸 때마다 상대를 쫓아내며 미스가 반복된다. 완전 연관은 반대로 블록을 어느 칸에나 넣을 수 있다. 쫓아냄이 줄지만, 찾을 때 모든 칸의 태그를 다 비교해야 해서 회로가 비싸다. 집합 연관은 그 사이의 절충이다. 칸 몇 개를 한 묶음으로 두고, 블록은 정해진 묶음 안 어느 칸에나 들어간다. 조회 비용과 충돌을 함께 낮춘 방식이라 실제 프로세서 캐시가 대부분 이쪽을 쓴다.
🟣 미스는 왜 나는가: 세 가지 원인
캐시 미스를 원인별로 나누면 어디를 손봐야 할지 보인다. 세 종류로 갈린다.
강제 미스는 그 블록을 처음 접근할 때 나는 미스다. 캐시에 아직 한 번도 올라온 적이 없으니 피할 수 없다. 블록을 크게 잡아 한 번에 많이 끌어오면 뒤이은 접근의 강제 미스를 줄일 수 있다. 용량 미스는 프로그램이 쓰는 데이터가 캐시 크기보다 커서, 쫓겨났던 블록을 다시 불러오며 나는 미스다. 캐시를 키우는 것 말고는 근본 대책이 없다. 충돌 미스는 캐시에 자리가 있는데도 사상 방식 탓에 같은 칸으로 몰려 서로를 쫓아내며 나는 미스다. 연관도를 높이면(집합 연관, 완전 연관) 줄어든다.
쓰기 정책도 짧게 짚는다. 캐시에 쓴 값을 곧바로 아래 계층에도 반영하면(write-through) 일관성은 간단하지만 쓰기가 잦으면 느리고, 캐시에만 써 두고 쫓겨날 때 한꺼번에 반영하면(write-back) 빠른 대신 관리가 복잡하다. 여기서도 단순함과 성능이 맞바꿔진다는 같은 구도가 반복된다.
🟣 지역성이 실제 성능을 가른다
공부가 여기서 실무의 성능 감각으로 이어졌다. 캐시는 하드웨어가 알아서 관리하니 프로그래머가 직접 건드릴 일은 없어 보이지만, 코드가 메모리를 어떤 순서로 접근하느냐가 지역성을 좌우하고, 그것이 곧 캐시 히트율을 좌우한다.
가장 흔한 예가 이차원 배열 순회다. 배열은 메모리에 한 줄씩 이어져 저장되는데(행 우선), 바깥 반복을 행, 안쪽 반복을 열로 돌면 이어진 주소를 차례로 밟아 공간 지역성이 산다. 순서를 뒤집어 열을 먼저 돌면 매번 멀리 떨어진 주소로 건너뛰어, 가져온 블록의 나머지를 못 쓰고 버리며 미스가 폭증한다. 알고리즘의 계산량은 똑같은데 접근 순서만 바꿔도 실측 속도가 몇 배씩 갈린다. 구조체의 필드를 자주 함께 쓰는 것끼리 모아 두는 것도 같은 이유다.
이 감각은 앱 개발에서도 그대로 통했다. 리스트를 빠르게 스크롤할 때 셀 데이터를 미리 이어진 형태로 준비해 두면 접근이 매끄럽고, 여기저기 흩어진 객체를 매번 따라다니면 느려진다. CS 이론에서 배운 지역성이 실제 사용자가 느끼는 부드러움과 만나는 지점이었다. 캐시를 안다는 것은 캐시 회로를 외우는 게 아니라, 내 코드의 접근 패턴이 그 회로에 어떻게 걸리는지를 아는 것이었다.
🟣 정리
- 프로세서는 빨라졌지만 DRAM은 수십에서 수백 사이클로 느려, 그 격차를 메우려고 메모리 계층을 쌓는다.
- 빠르고 작은 계층 위에 자주 쓰는 데이터를 두어 평균 접근 시간을 위쪽에 가깝게 만든다. 이것이 성립하는 근거가 지역성이다.
- 캐시는 주소를 태그·인덱스·오프셋으로 잘라 조회하고, 데이터를 블록 단위로 담아 공간 지역성을 활용한다.
- 사상 방식(직접·집합 연관·완전 연관)과 미스 종류(강제·용량·충돌)는 모두 성능과 비용의 타협 위에 있다.
- 코드의 메모리 접근 순서가 지역성을 좌우하고, 그것이 실측 성능을 몇 배씩 가른다.
면접에서 캐시를 물으면 히트·미스의 정의에서 멈추지 말고, 배열 순회 순서 같은 구체적 코드가 지역성으로 어떻게 성능을 가르는지까지 내려가야 한다. 캐시 지식을 접근 패턴 최적화 경험과 이어 말할 수 있으면, 이론과 실무를 함께 갖춘 신호가 된다.
여기까지가 컴퓨터 구조의 한 바퀴였다. 명령어 집합이라는 약속에서 출발해, 그 명령어를 실행하는 datapath를 세우고, 처리량을 위해 파이프라인으로 겹쳐 흘리고, 그 속도를 메모리가 못 따라오는 문제를 캐시로 메웠다. 소프트웨어와 하드웨어가 하나의 흐름으로 맞물려 돈다는 처음의 문장을, 각 계층을 직접 따라가며 확인한 여정이었다. 학부에서 얇게 스친 교재를, 라즈베리파이를 옆에 두고 실물과 맞춰 가며 한 번 더 깊이 판 기록으로 남긴다.