데이터베이스 서버의 CPU 사용률이 100%로 찍히면 우리는 흔히 "일을 최대로 하고 있다"고 받아들인다. 그러나 이 가정은 현대 CPU의 작동 방식과 어긋난다. Pivotlake의 기술 블로그 'It's the Memory Latency, Stupid'는 바로 이 착각을 파고든다. 리눅스에서 CPU가 '유휴(idle)'로 분류되는 조건은 단지 처리할 태스크가 없을 때일 뿐, CPU 내부 연산 유닛이 실제로 꽉 차 있다는 뜻이 아니다. 즉 100%로 보이는 CPU도 실제로는 상당 시간 아무 일도 못 한 채 멈춰 서 있을 수 있다.
그 멈춤의 주범은 메모리 접근 지연이다. 현대 CPU는 1나노초에 약 4~8개의 명령을 처리할 수 있지만, RAM에서 데이터를 한 번 읽어오는 데는 약 50~100나노초가 걸린다. 환산하면 RAM 접근 한 번을 기다리는 동안 CPU는 명령 400~800개를 처리할 수 있다는 뜻이다. 이 격차 때문에 CPU는 처음부터 RAM을 기다리지 않으려고 설계됐고, 그 핵심 장치가 바로 CPU 캐시다. 칩 안에 직접 박혀 있는 가장 가까운 L1 캐시는 접근에 약 1나노초, RAM보다 50~100배 빠르다. 다만 L1은 약 64KB로 매우 작고, 더 바깥 캐시도 최상급 프로세서에서야 수백 MB에 이른다. 그래서 좋은 데이터베이스는 데이터를 작은 '청크' 단위로 다뤄 캐시 안에서 일을 끝내려 애쓴다.
GROUP BY가 드러내는 캐시의 민낯
필자는 이 원리를 GROUP BY 집계로 설명한다. 가장 소박한 구현은 그룹 키로 색인되는 해시테이블을 열어두고, 행이 들어올 때마다 키가 있으면 집계값을 갱신하고 없으면 새로 넣는 방식이다. 문제는 고유 그룹이 수백만 개에 이를 때다. 해시테이블이 캐시에 담기지 못할 만큼 커지면 거의 모든 조회가 캐시를 벗어나 RAM까지 가는 '콜드 히트'가 된다. 이때 CPU는 100%처럼 보이지만 실제로는 차가운 메모리 접근을 끝없이 기다리고 있을 뿐이다.
검증을 위해 필자는 자사 Pivot 엔진의 복잡한 로직을 걷어내고 소박한 구현만 남긴 뒤, ClickBench가 제공하는 9,900만 행 규모의 hits 데이터셋에 'SELECT count(*), UserID FROM hits GROUP BY UserID'를 돌렸다. 고유 UserID는 약 1,700만 개다. 측정 환경은 CherryServers의 베어메탈 서버(AMD EPYC 9124 Genoa, 16코어, 128GB DDR5)이며, DRAM 컨트롤러에서 넘어오는 메모리량 같은 내부 카운터를 읽기 위해 일반 클라우드 인스턴스 대신 물리 서버를 썼다. 또 이 소박한 구현은 다중 코어로 확장되지 않으므로 단일 코어에 고정했다.
결과는 첫 실행 3.9초, 디스크 영향이 사라진 두 번째 실행 3.6초였다. taskset으로 프로세스를 특정 코어에 묶고 perf stat으로 내부를 들여다보니 실상이 드러났다. 전체 시간의 50.1%는 CPU가 메모리를 기다리느라 아무 일도 못 한 시간이었고, 실제로 연산을 처리(retire)한 비율은 16.1%에 불과했다. 캐시라인 한 번이 64바이트임을 감안해 환산하면, 이 쿼리는 CPU 로드만으로 DRAM에서 4.5GB를 끌어왔다.
더 많은 일을 하고도 빨라지는 역설
개선책으로 쓴 기법이 레이딕스 파티셔닝(radix partitioning)이다. 1단계에서는 집계를 하지 않고 키의 해시 상위 12비트(2의 12제곱=4,096)로 행을 4,096개 파티션에 나눠 담기만 한다. 2단계에서는 데이터가 다 들어온 뒤 파티션을 하나씩 돌며 해시맵을 만들어 집계하고 결과를 내보낸다. 얼핏 보면 행마다 해시하고 벡터에 밀어 넣는 단계가 추가돼 오히려 일이 늘어난 구조다. 핵심은 집계용 해시맵을 4,096조각으로 쪼개 작게 만든 데 있다. 작아진 해시맵은 CPU 캐시 안에 들어갈 확률이 높아지고, 조회 때마다 필요한 데이터가 캐시에 있을 가능성이 커진다.
실제로 실행 시간은 3.3초, 두 번째는 2.7초로 줄었다. perf 수치의 변화가 더 인상적이다. 메모리 대기 비율은 50%대에서 23.5%로 내려갔고, 연산 처리 비율은 32%로 올라갔다. 흥미롭게도 전체 연산 수는 약 114억에서 194억으로 늘었다. 그런데도 메모리 때문에 멈춘 CPU 슬롯은 약 83억에서 38억으로 줄었다. DRAM에서 끌어온 양도 4.5GB에서 1.5GB로 감소했다. 차가운 DRAM 접근을 CPU 연산으로 바꿀 수 있다면 거의 항상 이득이라는 명제가 수치로 입증된 셈이다.
실무자가 가져갈 함의와 한계
이 글이 주는 교훈은 단순하다. CPU 사용률 그래프만 보고 '포화 상태'라 단정해선 안 된다. 같은 100%라도 어떤 CPU는 연산을 쏟아내고 어떤 CPU는 메모리를 기다리며 서 있다. 성능 병목을 제대로 진단하려면 perf 같은 도구로 메모리 스톨 비율과 실제 연산 처리 비율, DRAM에서 끌어오는 데이터량을 함께 봐야 한다. 특히 고유 그룹이 많은 대규모 집계에서는 자료구조를 캐시에 맞게 쪼개는 설계가 체감 성능을 좌우한다.
다만 몇 가지 한계는 짚어둘 필요가 있다. 측정은 단일 쿼리, 단일 데이터셋(ClickBench hits), 단일 코어라는 제한된 조건에서 이뤄졌고, 내부 카운터 접근을 위해 일반 클라우드가 아닌 특정 베어메탈 하드웨어를 사용했다. 파티션 수 4,096이나 상위 12비트 같은 값은 이 환경에 맞춘 선택이므로, 데이터 분포나 하드웨어가 다르면 그대로 옮겨 쓰기 어렵다. 또 필자 스스로 밝혔듯 캐시 활용은 DRAM 지연을 숨기는 여러 방법 중 하나일 뿐이다. 필요한 데이터를 미리 캐시로 당겨오는 프리페칭, 메모리 처리량을 극대화하는 알고리즘 등은 이번 글에서 다루지 않았고, JOIN 프로빙 구현은 다음 글로 미뤘다. 벤더가 자사 엔진을 소개하는 글이라는 맥락도 감안해 읽는 편이 좋다.