램이 느려서 캐시가 생겼다
번역이 끝나면 물리 주소가 나오고, CPU 는 그 자리를 읽습니다. 그런데 그 읽는 일 자체가 느립니다.
3GHz 짜리 CPU 는 한 사이클이 0.3나노초쯤입니다. 램에서 값 하나를 가져오는 데는 100나노초쯤 걸립니다. 계산하면 삼백 사이클을 기다린다는 뜻입니다. 그동안 CPU 는 할 일이 없습니다.
이 차이를 메우려고 중간에 작고 빠른 저장소를 끼웠습니다. 그게 캐시입니다.
계층은 다섯 단이다
위로 갈수록 빠르고 작습니다. 아래로 갈수록 느리고 큽니다.
L1 과 램 사이가 일흔 배 넘게 벌어집니다. 같은 코드라도 값이 어디에 있었느냐에 따라 이만큼 갈립니다.
그런데 이상한 점이 있습니다. L1 은 수십 KB 밖에 안 됩니다. 게임이 쓰는 데이터는 GB 단위입니다. 그 작은 칸에 무엇을 올려 둘지 어떻게 맞히는 걸까요.
캐시는 두 가지 습관에 겁니다
맞히는 것이 아니라 사람이 짠 코드에 습관이 있다는 데 겁니다. 그 습관을 지역성이라고 합니다.
두 번째가 중요합니다. 캐시는 값 하나만 가져오는 법이 없습니다. 주변까지 통째로 끌고 옵니다. 그래서 배열을 순서대로 훑으면 한 번 다녀올 때마다 여러 개를 얻고, 흩어진 자리를 만지면 매번 하나만 얻습니다.
이 습관이 없는 코드에서는 캐시가 없는 것과 같습니다. 매번 램까지 다녀오게 됩니다.
하드웨어가 미리 끌어온다
한 걸음 더 나갑니다. CPU 는 다음에 무엇을 만질지 짐작해서 미리 가져다 둡니다.
짐작의 근거는 규칙성입니다.
- 주소가 일정한 간격으로 늘어나면 다음 것을 미리 끌어옵니다. 잘 맞습니다
- 매번 다른 데로 뛰면 짐작할 근거가 없습니다. 아무것도 못 합니다
그래서 연속된 배열을 순서대로 도는 코드는 사실상 기다리지 않습니다. 필요할 때 이미 와 있습니다. 포인터를 따라 뛰어다니는 코드는 매번 값을 받고 나서야 다음에 어디로 갈지 알기 때문에, 미리 가져다 둘 수가 없습니다.
적중률 99% 와 90% 는 말로는 비슷합니다. 그런데 평균은 일곱 배 넘게 벌어집니다.
게임에서 보이는 자리
여기까지 오면 같은 말이 다르게 들립니다. 데이터가 어떻게 놓여 있느냐가 곧 속도입니다.
- 필요한 값만 붙여 놓은 배열을 순서대로 도는 코드는 캐시가 다 받쳐 줍니다
- 객체마다 이것저것 다 들어 있는 것을 목록으로 들고 다니면, 쓰지도 않을 값까지 같이 끌려 옵니다. 한 덩어리로 가져오니까요
- 포인터를 따라가는 자료구조는 미리 가져다 두는 것도 안 먹힙니다
주소를 번역하는 값과 이건 다른 축입니다. 번역은 “그 자리가 어디인가” 이고, 이건 “그 자리가 가까운가” 입니다. 둘 다 연속된 배열에서 유리한데 이유가 각각 따로 있습니다.
그리고 이 값은 프로파일러에서 느린 함수로 보입니다. 실제로 느린 것은 함수가 아니라 그 함수가 만지는 데이터의 배치입니다. 코드를 아무리 들여다봐도 안 나오는 이유가 이것입니다.
정리
- CPU 한 사이클과 램 접근은 삼백 배쯤 벌어져 있습니다. 그 사이를 메우려고 캐시가 생겼습니다
- 계층은 레지스터부터 저장 장치까지 다섯 단이고, 한 단 내려갈 때마다 자릿수가 바뀝니다
- 캐시는 두 습관에 겁니다. 방금 쓴 것을 또 쓴다, 쓴 것의 옆을 쓴다
- 그래서 값 하나를 가져올 때 주변을 통째로 끌고 옵니다. 순서대로 훑으면 한 번에 여러 개를 얻습니다
- 하드웨어는 규칙적인 접근을 짐작해 미리 가져다 둡니다. 뛰어다니면 짐작할 근거가 없어서 매번 기다립니다
- 이 값은 느린 함수로 보입니다. 실제로 느린 것은 데이터의 배치입니다