하드웨어 고급 #3 메모리 심화 — 페이지 캐시, THP, 대역폭

9 분 소요

중급 3편에서 메모리 운영의 판단 기준을 잡았습니다. available로 여유를 판정하고, 더티 페이지의 폭주를 읽고, cgroup 한도에서 OOMKilled를 해석하는 수준입니다. 이번 글은 그 판단의 근거가 되는 메커니즘 안쪽으로 들어갑니다. 페이지 캐시는 읽기와 쓰기를 정확히 어떻게 처리하는지, 커널이 몰래 페이지를 키우는 THP가 왜 지연 스파이크의 범인이 되는지, 그리고 코어가 놀고 있는데도 처리량이 안 나오는 메모리 대역폭 병목은 어떻게 확인하는지입니다.

페이지 캐시 — 모든 파일 I/O가 지나는 길 #

애플리케이션이 read()를 부르면 커널은 디스크로 가기 전에 페이지 캐시부터 찾습니다. 찾는 페이지가 있으면(캐시 적중) 메모리 복사 한 번으로 끝나고, 디스크는 건드리지도 않습니다. 없으면(미스) 블록 계층에 I/O를 내려보내고, 읽어 온 내용을 캐시에 올린 뒤 돌려줍니다. 이때 커널은 접근이 순차적이라고 판단되면 요청보다 앞쪽 블록까지 미리 읽어 둡니다(readahead). 순차 읽기가 랜덤 읽기보다 캐시 적중률까지 좋아지는 이유입니다.

쓰기는 방향이 다릅니다. write()는 페이지 캐시에 쓰고 그 페이지를 더티로 표시하는 데서 끝나며, 디스크 반영은 writeback 스레드가 나중에 합니다.

페이지 캐시를 지나는 두 경로
읽기  read() ─▶ 페이지 캐시 조회 ─▶ 적중: 메모리 복사로 반환 (디스크 접근 없음)
                              └▶ 미스: 블록 I/O + readahead ─▶ 캐시에 적재 후 반환

쓰기  write() ─▶ 페이지 캐시에 기록 + 더티 표시 ─▶ (즉시 반환)
                              └▶ 나중에 writeback 스레드가 디스크로 반영

그래서 쓰기 호출 자체는 메모리 속도로 돌아오고, 대신 더티 페이지의 적체와 폭주라는 운영 현상이 생깁니다. 이 동작과 대응은 중급 3편에서 다뤘으므로 여기서는 경로만 확인해 두겠습니다.

이 경로에는 예외가 하나 있습니다. O_DIRECT로 연 파일은 페이지 캐시를 우회해서 디스크와 직접 주고받습니다. 데이터베이스가 자주 쓰는 방식인데, 자기만의 버퍼 풀을 갖고 있어서 커널 캐시까지 거치면 같은 데이터가 메모리에 두 번 올라가기 때문입니다. “DB 서버에서 페이지 캐시가 이상하게 작다"는 관찰은 장애가 아니라 이 설계의 결과일 수 있습니다.

THP — 공짜가 아닌 대용량 페이지 #

리눅스의 기본 페이지는 4KB이고, 가상 주소를 물리 주소로 바꾼 결과는 TLB(Translation Lookaside Buffer, 주소 변환 캐시)에 저장됩니다. 문제는 TLB 엔트리가 코어당 수천 개 수준으로 한정된다는 점입니다. 4KB 페이지로는 수십 MB 정도만 커버할 수 있어서, 워킹셋이 수십 GB인 프로세스는 메모리 접근마다 TLB 미스와 페이지 테이블 탐색을 반복하게 됩니다.

THP(Transparent Huge Pages, 투명 대용량 페이지)는 이 비용을 줄이려고 커널이 4KB 페이지 512개를 2MB 페이지 하나로 자동 승격하는 기능입니다. 같은 TLB 엔트리 수로 커버하는 범위가 512배가 되니, 메모리를 크게 쓰는 워크로드에서 수 퍼센트의 처리량 향상이 실제로 나옵니다.

그런데 대가가 있습니다. 2MB 페이지는 물리적으로 연속된 2MB 메모리가 필요합니다. 오래 돌아 단편화된 서버에는 그런 연속 구간이 드물어서, 커널이 흩어진 페이지를 옮겨 연속 공간을 만드는 컴팩션(compaction)을 돌립니다. 이 컴팩션이 메모리 할당 경로에서 동기로 발생하면 그 순간의 할당이 수십 ms씩 멈추고, 백그라운드의 khugepaged가 페이지를 병합하는 동안에도 CPU와 잠금 비용이 듭니다. 평균 처리량은 좋아지는데 꼬리 지연(tail latency)이 나빠지는 전형적인 교환입니다.

현재 모드와 사용량은 다음에서 확인합니다.

THP 상태 확인
$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never

$ grep AnonHugePages /proc/meminfo
AnonHugePages:   8388608 kB     # 익명 메모리 중 2MB 페이지로 승격된 양

데이터베이스 벤더들이 THP를 끄라고 권고하는 이유가 이것입니다. DB는 평균보다 꼬리 지연에 민감하고, Redis처럼 fork로 스냅샷을 뜨는 시스템은 2MB 페이지 때문에 copy-on-write 복사 단위가 커져서 메모리 사용까지 부풉니다. 절충안으로 madvise 모드가 있는데, 전체 자동 승격 대신 madvise()로 명시적으로 요청한 영역만 대용량 페이지를 쓰게 하는 설정입니다. “THP를 켤까 끌까"라는 질문의 답은 워크로드가 평균을 파는지 꼬리를 파는지에 달려 있습니다.

명시적 hugepages — 예약해 두고 쓰는 큰 페이지 #

THP의 문제가 “런타임에 몰래 만들려다” 생기는 것이라면, 답은 미리 만들어 두는 것입니다. 명시적 hugepages는 vm.nr_hugepages로 2MB(또는 1GB) 페이지를 부팅 직후처럼 단편화가 없을 때 예약해 두고, 신청한 애플리케이션만 쓰게 하는 방식입니다. 예약된 페이지는 스왑 대상도 아니고 컴팩션도 필요 없으므로, THP의 지연 스파이크 없이 TLB 이득만 가져갑니다.

예약 상태는 /proc/meminfo에서 봅니다.

hugepages 예약 확인
$ grep -i hugepages /proc/meminfo
HugePages_Total:    8192      # 예약된 2MB 페이지 수 (= 16GB)
HugePages_Free:     2048      # 아직 안 쓰인 양
HugePages_Rsvd:      512      # 신청은 됐지만 아직 접근 전인 양
Hugepagesize:       2048 kB

주의할 점은 예약된 만큼 일반 메모리가 줄어든다는 것입니다. 위처럼 16GB를 예약하면 hugepages를 못 쓰는 일반 프로세스 입장에서는 서버 메모리가 16GB 작은 것과 같습니다. 신청자가 정해진 메모리만 예약하는 것이 원칙입니다.

쓰는 쪽도 정해져 있습니다. PostgreSQL과 Oracle은 공유 버퍼를 hugepages에 올리는 설정을 제공하고, KVM은 게스트 메모리를, DPDK 같은 고성능 패킷 처리 프레임워크는 버퍼 풀을 여기에 둡니다. 부수 효과로 페이지 테이블 자체가 작아지는 이득도 있습니다. 수백 개의 프로세스가 같은 수십 GB 공유 메모리를 매핑하는 DB에서는 프로세스마다 만들어지는 페이지 테이블만으로 수 GB를 먹을 수 있는데, 페이지가 512배 커지면 그 테이블이 그만큼 줄어듭니다.

스왑 정책 심화 — swappiness의 실제 구현과 zswap #

중급 3편에서 swappiness를 “무엇을 먼저 내보낼지의 성향"이라고 정리했습니다. 구현 수준으로 내려가면, 커널은 회수 대상 페이지를 익명 페이지(프로세스 메모리) 목록과 파일 페이지(페이지 캐시) 목록으로 나눠 관리하고, swappiness는 회수 때 두 목록을 어떤 비율로 스캔할지의 가중치입니다. 그래서 0으로 둬도 스왑이 꺼지지 않습니다. 파일 페이지를 다 회수하고도 부족하면 커널은 결국 익명 페이지를 내립니다.

cgroup v2에서는 이 값이 0〜200까지 확장됐고, 100을 넘는 값은 “익명 페이지 회수가 파일 페이지 회수보다 싸다"는 선언입니다. 회전 디스크 스왑 시대에는 말이 안 되는 값이었지만, 스왑이 NVMe나 압축 메모리로 가는 요즘에는 합리적인 선택지가 됐습니다.

그 압축 메모리가 zswap입니다. 스왑으로 나가는 페이지를 디스크에 쓰기 전에 RAM 안의 압축 풀에 먼저 저장하고, 풀이 차면 오래된 것부터 디스크로 내립니다. 압축률이 2〜3배쯤 나오는 워크로드라면 스왑 I/O의 상당 부분이 디스크 속도가 아니라 압축 해제 속도로 처리됩니다. 메모리를 과약정하는 데스크톱과 일부 클라우드 환경에서 기본으로 켜고 출고하는 이유입니다.

메모리 대역폭 — 코어는 노는데 버스가 포화 #

지금까지는 메모리의 양과 지연을 다뤘는데, 세 번째 축이 있습니다. 초당 옮길 수 있는 바이트 수, 대역폭입니다. 소켓 하나의 메모리 대역폭은 대략 채널 수와 메모리 속도의 곱으로 정해집니다. DDR5-4800 8채널이면 이론상 300GB/s 안팎입니다. 큰 숫자처럼 보이지만, 수십 개 코어가 큰 배열을 훑는 분석 쿼리나 과학 계산을 동시에 돌리면 채워집니다.

증상이 특이합니다. 코어를 더 줘도 안 빨라지고, CPU 사용률이 100%여도 perf stat의 IPC(사이클당 명령 수)가 유난히 낮습니다. 코어가 일하는 게 아니라 메모리에서 데이터가 오기를 기다리며 사이클을 태우고 있기 때문입니다.

perf stat: 대역폭 병목의 전형적인 모습
$ perf stat -a sleep 10
   1,284,332,109,442   cycles
     412,587,221,830   instructions   #  0.32 insn per cycle

캐시에 잘 맞는 워크로드의 IPC가 2〜4쯤 나오는 것과 비교하면, 0.3대의 IPC는 코어가 대부분의 사이클을 기다림에 쓰고 있다는 신호입니다. 1편에서 다룬 perf의 topdown 분석에서 backend bound, 그중에서도 memory bound 비중이 지배적으로 나오면 이 그림이 확정되고, 인텔의 pcm-memory 같은 도구를 쓰면 소켓별 메모리 트래픽을 GB/s 단위로 직접 볼 수 있습니다.

이 서버가 실제로 낼 수 있는 상한은 STREAM 벤치마크로 잽니다. 캐시보다 훨씬 큰 배열을 복사하고 더하는 단순 루프만 돌려서 지속 가능한 대역폭을 측정하는 고전 도구입니다.

STREAM 결과 예
Function    Best Rate MB/s
Copy:           241854.3
Scale:          238102.7
Add:            252331.9
Triad:          251887.4      # 이론치 300GB/s 대비 약 84%

이론치의 80% 안팎이 나오면 정상 범위입니다. 워크로드의 실측 트래픽이 이 수치에 붙어 있다면 그 병목은 코드 최적화나 코어 증설로는 풀리지 않습니다. 처방의 방향도 다릅니다. DIMM을 채널마다 고르게 꽂아 채널을 다 살리는 것, 그리고 메모리 접근을 캐시 친화적으로 바꿔 트래픽 자체를 줄이는 것입니다.

NUMA와 만나는 곳 #

이 글의 주제들은 모두 중급 4편의 NUMA와 노드 단위로 다시 만납니다. 대역폭은 서버 전체가 아니라 노드별로 계산되므로, 한 노드의 버스가 포화여도 다른 노드는 여유일 수 있습니다. hugepages 예약과 THP의 단편화·컴팩션도 노드 단위로 일어나서, 노드 0에는 연속 공간이 없는데 노드 1에는 남아 있는 상황이 생깁니다. 멀티소켓 서버에서 이번 글의 도구를 쓸 때는 numastat과 노드별 지표를 항상 옆에 두는 것이 안전합니다.

자주 만나는 함정 #

  • THP를 무조건 끈다 — DB 권고를 모든 서버에 일반화한 결과입니다. 꼬리 지연이 중요하지 않은 배치·분석 워크로드는 THP의 이득이 실재합니다. 워크로드의 민감 축을 먼저 정합니다.
  • swappiness 0을 스왑 비활성화로 안다 — 0은 스캔 가중치이지 스위치가 아닙니다. 극한 부족에서는 여전히 스왑이 돌고, 정말 끄려면 스왑 영역 자체를 제거해야 합니다. 다만 완충이 사라지는 대가는 중급 3편에서 정리한 대로입니다.
  • CPU 100%를 보고 코어를 늘린다 — IPC가 낮고 memory bound가 지배적이면 코어가 아니라 대역폭 병목입니다. 늘린 코어는 같은 버스를 나눠 쓰며 함께 기다릴 뿐입니다.

정리 #

이번 글에서 잡은 그림입니다.

  • 파일 I/O는 페이지 캐시를 지나며, 읽기는 적중·미스와 readahead로, 쓰기는 더티 표시 후 writeback으로 처리됩니다. O_DIRECT는 이 경로를 우회합니다.
  • THP는 TLB 미스를 줄이는 대신 컴팩션으로 꼬리 지연을 키울 수 있습니다. 평균과 꼬리 중 무엇이 중요한지로 결정합니다.
  • 명시적 hugepages는 미리 예약해서 THP의 부작용 없이 TLB 이득을 얻는 방식이고, DB·가상화·패킷 처리의 표준 도구입니다.
  • swappiness는 회수 스캔의 가중치이며, zswap은 스왑 앞단의 압축 풀로 스왑 비용을 낮춥니다.
  • 메모리 대역폭 병목은 낮은 IPC와 memory bound 비중으로 식별하고, STREAM으로 상한을 재며, 처방은 채널 구성과 접근 패턴입니다.

다음 — ZFS 심화 #

다음 글인 “하드웨어 고급 #4 ZFS 심화"에서는 스토리지로 갑니다. 체크섬과 copy-on-write로 데이터 무결성을 파일시스템이 책임지는 ZFS의 구조, ARC 캐시와 메모리의 관계, 그리고 RAID-Z와 풀 설계에서 운영자가 내려야 하는 결정들을 다루겠습니다.

X