Chapter 06

메모리와 캐시

CPU가 아무리 빨라도 재료가 늦게 오면 기다릴 수밖에 없다. 1장에서 CPU의 한 박자를 1초로 늘리면 메모리까지 다녀오는 데 5분이 걸린다고 했다. 이 장은 그 5분을 어떻게 숨기는지에 대한 이야기다. 핵심 단어는 하나, “가까운 곳에 두기”다.

메모리는 번호 붙은 사물함

메모리(RAM)는 아주 긴 사물함 줄이라고 생각하면 된다. 사물함 한 칸에는 1바이트(8비트)가 들어가고, 칸마다 0번부터 시작하는 번호가 붙어 있다. 이 번호를 주소(address)라 한다. 16 GB 메모리라면 사물함이 약 160억 칸이다.

CPU는 “1000번지 값을 줘” 또는 “1000번지에 65를 넣어”라고만 말한다. 주소만 알면 몇 번째 칸이든 똑같이 빠르게 접근할 수 있어서 RAM(Random Access Memory, 아무 곳이나 바로 접근하는 메모리)이라는 이름이 붙었다. 처음부터 차례로 감아야 하는 카세트테이프와 대비되는 이름이다.

SIMULATOR

메모리 사물함: 주소와 값

주소와 글자를 정하고 쓰기를 눌러 보세요. 글자는 2장의 약속(UTF-8)에 따라 숫자로 바뀌어 한 칸에 한 바이트씩 들어갑니다.
해볼 것: “포인터 만들기”는 0번지에 다른 칸의 주소를 숫자로 적어 둔다. 값 자체가 아니라 “어디에 있는지”를 적어 둔 것이다. 프로그래밍에서 이것을 포인터라 하고, 13장에서 다시 만난다.
주소도 결국 숫자

주소는 메모리 칸의 번호이고, 이 번호 자체도 다른 칸에 숫자로 저장할 수 있다. “사물함 3번 안에 ‘17번 사물함을 열어 봐’라는 쪽지” 같은 것이다. 이 단순한 아이디어로 목록, 트리, 웹 페이지의 링크 구조 같은 복잡한 데이터를 엮는다.

RAM의 정체: 새는 물동이

3장의 래치는 트랜지스터 여러 개로 1비트를 기억했다. 빠르지만 자리를 많이 차지한다. 메인 메모리로 쓰는 DRAM(Dynamic RAM)은 1비트를 트랜지스터 1개 + 아주 작은 전기 그릇(커패시터) 1개로 기억한다. 그릇에 전하가 차 있으면 1, 비어 있으면 0이다. 덕분에 아주 촘촘하게, 싸게 만들 수 있다.

문제는 이 그릇이 조금씩 샌다는 것이다. 가만히 두면 몇십 밀리초 안에 1이 0처럼 보일 만큼 전하가 빠져나간다. 그래서 메모리 칩은 모든 칸을 주기적으로 읽어서 다시 채워 넣는 리프레시(refresh)를 끊임없이 한다. 보통 64 ms마다 한 바퀴를 돈다. 이름의 “Dynamic(동적)”이 바로 이 쉼 없는 새로고침을 뜻한다.

SIMULATOR

DRAM 셀의 누설과 리프레시

저장한 1의 개수—
0으로 잘못 읽히는 칸—
각 칸은 커패시터 하나다. 진한 색 = 전하 가득(1). 점선 아래로 떨어지면 0으로 읽힌다(빨간 테두리). 리프레시를 끄면 저장한 데이터가 서서히 사라진다. 전원을 끄면 RAM의 내용이 사라지는 이유도 같다.

CPU 안의 캐시와 레지스터는 새지 않는 래치 방식의 SRAM(Static RAM)을 쓴다. 리프레시가 필요 없고 훨씬 빠르지만, 비트당 트랜지스터가 6개 정도 들어서 같은 면적에 담을 수 있는 양이 적고 비싸다. 빠른 것은 작고 비싸며, 큰 것은 느리고 싸다. 이 맞바꿈이 다음 절의 메모리 계층을 만든다.

메모리 계층: 가까울수록 빠르고 작다

이상적인 메모리는 “무한히 크고, 무한히 빠르고, 공짜”일 것이다. 그런 것은 없다. 그래서 컴퓨터는 성격이 다른 메모리 여러 층을 쌓아 쓴다. 위로 갈수록 빠르고 작고 비싸며, CPU와 가깝다.

레지스터수백 바이트 · 0.3 ns
L1 캐시코어당 수십 KB · 1 ns
L2 캐시코어당 1~2 MB · 4 ns
L3 캐시공유 수십 MB · 12 ns
메인 메모리 (DRAM)8~64 GB · 100 ns
저장장치 (SSD)0.5~4 TB · 50~100 µs (전원이 꺼져도 남는다)
그림 6-1. 메모리 계층. 숫자는 대표적인 크기와 접근 시간이다. 한 층 내려갈 때마다 수 배~수천 배 느려진다.
요리사의 동선

요리사는 지금 쓰는 재료를 손(레지스터)에 쥐고, 자주 쓰는 양념은 손 닿는 선반(L1), 조금 덜 쓰는 것은 뒤쪽 선반(L2·L3), 오늘 쓸 재료 전체는 조리대(RAM), 나머지는 창고(SSD)에 둔다. 선반에서 꺼내는 데 1초, 조리대까지 몇 분, 창고까지는 며칠이 걸린다고 생각하면 1장의 시간 변환기와 맞아떨어진다. 좋은 요리사는 다음에 쓸 재료를 미리 선반으로 옮겨 둔다.

캐시: 다음에 쓸 것을 옆에 두기

캐시(cache)는 메인 메모리의 일부 내용을 복사해 CPU 바로 옆에 둔 작고 빠른 메모리다. CPU가 어떤 주소의 데이터를 요청하면 먼저 캐시를 본다.

아래 시뮬레이터의 캐시는 8칸짜리다. 메모리 주소마다 들어갈 수 있는 캐시 칸이 정해져 있다(주소를 8로 나눈 나머지). 이런 방식을 직접 사상 캐시(direct-mapped cache)라 한다. 접근 패턴을 바꿔 가며 적중률이 어떻게 달라지는지 보자.

SIMULATOR

8칸 캐시 시뮬레이터

접근 패턴
진행 0
“한 번 접근”을 눌러 보세요.
적중 / 실패—
적중률—
평균 접근 시간—
적중 = 1 ns, 실패 = 100 ns로 계산한다. “충돌” 패턴에서는 캐시가 거의 비어 있는데도 0과 8이 같은 칸(나머지 0)을 두고 서로 쫓아내 계속 실패한다. 실제 캐시는 한 주소가 들어갈 수 있는 칸을 여러 개 두어(연관 사상) 이런 충돌을 줄인다.

적중률이 조금만 달라져도 평균 속도는 크게 달라진다. 적중률이 99%면 평균 약 2 ns이지만, 90%면 약 11 ns로 다섯 배 넘게 느려진다. 실제 프로그램은 대부분 L1 캐시에서 95% 이상 적중한다. 어떻게 그렇게 잘 맞힐까? 비결은 프로그램의 습관, 즉 지역성에 있다.

지역성: 프로그램의 습관

프로그램은 메모리를 아무렇게나 쓰지 않는다. 두 가지 뚜렷한 습관이 있다.

캐시는 공간 지역성을 이용하려고 한 바이트만 가져오지 않고 주변 64바이트를 한꺼번에 가져온다. 이 묶음을 캐시 라인(cache line)이라 한다. 그래서 데이터를 메모리에 놓인 순서대로 읽으면 빠르고, 띄엄띄엄 건너뛰며 읽으면 느리다. 같은 계산을 하는데도 말이다.

SIMULATOR

표를 읽는 두 가지 순서

읽는 순서

표 한 줄이 메모리에 가로로 이어져 저장된다. 캐시 라인 = 가로 4칸, 캐시 크기 = 라인 6개.

읽은 칸0
캐시 실패0
걸린 시간 (적중 1, 실패 100)0
진한 테두리 = 지금 캐시에 들어 있는 라인. 가로로 읽으면 한 번 실패할 때 옆 3칸이 따라 들어와 공짜로 적중한다. 세로로 읽으면 매번 다른 줄로 건너뛰어서, 가져온 라인을 다 쓰기도 전에 캐시에서 밀려난다. 결과는 똑같은 합계인데 시간은 몇 배 차이가 난다.
하드웨어를 아는 프로그래머

게임 엔진, 데이터베이스, 인공지능 라이브러리를 만드는 개발자들은 데이터를 메모리에 어떤 순서로 둘지 신중하게 설계한다. 계산 횟수를 줄이는 것만큼 캐시 실패를 줄이는 것이 중요하기 때문이다. 5장의 분기 예측과 함께, “같은 결과, 다른 속도”를 만드는 대표적인 이유다.

핵심 정리

  1. 메모리는 1바이트짜리 칸이 줄지은 사물함이다. 칸 번호가 주소이고, 주소 자체도 숫자로 저장할 수 있다(포인터).
  2. DRAM은 커패시터에 전하를 담아 기억한다. 전하가 새므로 계속 리프레시해야 하고, 전원이 꺼지면 내용이 사라진다.
  3. 빠른 메모리는 작고 비싸다. 그래서 레지스터 → L1/L2/L3 캐시 → DRAM → SSD의 계층으로 쌓아 쓴다.
  4. 캐시는 최근·근처 데이터를 CPU 옆에 복사해 둔다. 적중하면 빠르고, 실패하면 메모리까지 가야 한다.
  5. 프로그램의 시간·공간 지역성 덕분에 캐시가 잘 맞는다. 메모리에 놓인 순서대로 읽는 코드가 빠르다.

확인 퀴즈

RAM이 전원이 꺼지면 내용을 잃는 이유로 가장 알맞은 것은?

DRAM은 새는 커패시터에, SRAM은 서로 붙잡는 래치에 정보를 담는다. 둘 다 전원이 있어야 유지된다. 이런 성질을 휘발성이라 한다.

메모리 계층에서 위(CPU 쪽)로 갈수록 어떻게 되는가?

레지스터와 L1 캐시는 가장 빠르지만 매우 작다. 아래로 갈수록 커지고 느려진다.

적중률 90%, 적중 시 1 ns, 실패 시 100 ns라면 평균 접근 시간은?

0.9 × 1 + 0.1 × 100 = 0.9 + 10 = 10.9 ns. 실패 10%가 평균을 지배한다.

배열을 처음부터 끝까지 차례로 읽을 때 캐시가 잘 맞는 이유는?

캐시는 64바이트 묶음(라인)으로 가져온다. 이웃한 데이터를 곧 읽으면 이미 캐시에 와 있다.