하드웨어 중급 #4 NUMA — 메모리는 균일하지 않다

5 분 소요

3편까지의 메모리는 암묵적인 가정이 하나 있었습니다. 어느 코어에서 접근하든 메모리는 똑같이 빠르다는 가정입니다. CPU 소켓이 하나인 서버에서는 대체로 맞지만, 소켓이 두 개를 넘는 순간 깨집니다. 이번 글은 그 비균일성, NUMA를 다룹니다. 서버 사양표의 “2소켓"이라는 한 줄이 성능에 어떤 의미를 가지는지 살펴봅니다.

NUMA란 — 메모리에 거리가 생긴다 #

NUMA(Non-Uniform Memory Access, 비균일 메모리 접근)는 멀티소켓 서버의 메모리 구조입니다. 각 CPU 소켓은 자기에게 직접 연결된 메모리를 갖고, 소켓과 그 메모리의 묶음을 NUMA 노드라고 부릅니다.

2소켓 서버의 구조
┌─ 노드 0 ─────────────┐      ┌─ 노드 1 ─────────────┐
│  CPU 소켓 0           │ 인터커넥트 │  CPU 소켓 1           │
│  (코어 0〜15)         │◀────▶│  (코어 16〜31)        │
│  메모리 256GB (로컬)   │      │  메모리 256GB (로컬)   │
└──────────────────────┘      └──────────────────────┘

노드 0의 코어가 노드 0의 메모리를 읽으면 로컬 접근, 노드 1의 메모리를 읽으면 소켓 사이 인터커넥트를 건너는 리모트 접근입니다. 리모트는 로컬보다 지연이 길고(대략 1.5〜2배 수준) 대역폭도 좁습니다. 기초 3편의 메모리 계층 그림에 “같은 RAM 안에서도 가까운 RAM과 먼 RAM이 있다"는 층이 하나 더 생기는 셈입니다.

운영체제는 이를 알고 움직입니다. 리눅스는 기본적으로 프로세스가 도는 노드의 메모리를 먼저 할당하고(first-touch), 스케줄러도 작업을 같은 노드에 머물게 하려고 노력합니다. 문제는 그 노력이 깨지는 순간들입니다.

언제 문제가 되는가 #

NUMA가 성능 사건으로 드러나는 전형적인 장면은 세 가지입니다.

  • 한 노드보다 큰 메모리를 쓰는 프로세스 — 256GB 노드 두 개짜리 서버에서 400GB를 쓰는 데이터베이스는 필연적으로 두 노드에 걸칩니다. 어느 코어에서 돌든 절반쯤은 리모트 접근입니다.
  • 한쪽 노드의 메모리 고갈 — 프로세스들이 노드 0에 몰리면, 노드 1에 메모리가 남아 있어도 노드 0이 먼저 마릅니다. 커널은 리모트 할당이나 노드 0의 페이지 회수(심하면 스왑)로 대응하는데, “전체 메모리는 남는데 스왑이 도는” 어리둥절한 증상이 이렇게 만들어집니다.
  • 스레드 이동 — 스케줄러가 부하 균형을 맞추느라 스레드를 다른 노드로 옮기면, 그 스레드의 메모리는 원래 노드에 남습니다. 이후의 접근이 전부 리모트가 됩니다. 2편의 핀닝이 캐시만이 아니라 NUMA 때문에도 등장하는 이유입니다.

증상의 공통점은 지표상 자원은 남는데 처리량이 안 나오는 모습이라는 것입니다. CPU 사용률도 메모리 양도 여유인데 같은 워크로드가 1소켓 장비보다 느리다면 NUMA를 의심할 차례입니다.

보는 법 — numastat과 numactl #

구조 확인은 numactl --hardware로, 동작 확인은 numastat으로 합니다.

numastat
$ numastat
                    node0          node1
numa_hit         98214532       97103211
numa_miss         1203334        5421887
numa_foreign      5421887        1203334
other_node        1456220        5673001

읽는 열은 둘입니다. numa_hit는 의도한 노드에서 할당이 된 횟수, numa_miss는 의도한 노드가 말라서 다른 노드에서 대신 할당된 횟수입니다. miss가 hit 대비 무시 못 할 비율로 자라고 있다면, 위의 두 번째 장면(노드 쏠림)이 진행 중이라는 뜻입니다. 프로세스 단위로는 numastat -p <PID>로 어느 노드에 메모리가 얼마나 있는지 볼 수 있습니다.

배치를 직접 지정할 때는 numactl을 씁니다.

terminal
# 프로세스를 노드 0의 코어와 메모리에 묶어서 실행
numactl --cpunodebind=0 --membind=0 ./my-server

# 메모리를 모든 노드에 고르게 분산(인터리브)해서 실행
numactl --interleave=all ./my-database

두 옵션이 정반대 전략이라는 점이 NUMA 대처의 요점입니다. 노드 하나에 들어가는 워크로드는 묶어서 전부 로컬로 만들고, 한 노드보다 큰 워크로드는 인터리브로 고르게 펴서 “절반만 유난히 느린” 상황을 피합니다. 실제로 일부 데이터베이스의 운영 가이드가 인터리브 실행을 권장하는 것이 이 논리입니다.

가상화와 클라우드에서는 #

가상 머신에도 같은 구조가 나타납니다. 하이퍼바이저가 vCPU와 게스트 메모리를 같은 물리 노드에 두려고 노력하지만, 큰 가상 머신은 물리 서버처럼 여러 노드에 걸칩니다. 이때 게스트에게 가상 NUMA 토폴로지를 보여 줘서 게스트 커널이 알고 움직이게 하는 것이 일반적인 설계입니다.

클라우드 사용자 관점의 함의는 단순합니다. 작은 인스턴스에서는 NUMA를 만날 일이 거의 없고, 물리 서버 한 대에 가까운 큰 인스턴스(수십 vCPU 이상)에서는 게스트 안에서 numactl --hardware를 쳤을 때 노드가 여러 개 보일 수 있습니다. 그 크기의 인스턴스에서 성능을 쥐어짜야 한다면, 이 글의 도구들이 클라우드 안에서도 그대로 통합니다.

자주 만나는 함정 #

  • 메모리 총량만 보고 배치를 판단한다 — 총 512GB 여유가 “어디서나 512GB"는 아닙니다. 노드별 잔량을 봐야 쏠림이 보입니다.
  • 무조건 묶는 것이 좋다고 믿는다 — 핀닝과 membind는 노드 하나에 들어갈 때의 전략입니다. 노드보다 큰 워크로드를 묶으면 그 노드만 마르고 스왑을 부릅니다. 크기에 따라 묶기와 인터리브를 가립니다.
  • NUMA를 서버 전용 지식으로 둔다 — 큰 인스턴스와 베어메탈, 그리고 8편의 GPU 서버(GPU가 어느 노드에 붙었는지)까지, 클라우드 시대에도 NUMA는 따라옵니다.

정리 #

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

  • 멀티소켓 서버의 메모리는 노드 단위로 나뉘고, 로컬과 리모트 접근의 속도가 다릅니다.
  • 전형적인 사건은 노드보다 큰 프로세스, 노드 쏠림(전체는 남는데 스왑), 스레드 이동입니다.
  • numastat의 miss 비율로 진단하고, numactl로 묶거나(작은 워크로드) 인터리브합니다(큰 워크로드).
  • 큰 가상 머신과 클라우드 인스턴스에서도 같은 구조가 나타납니다.

다음 — 스토리지 성능 실측 #

다음 글인 “하드웨어 중급 #5 스토리지 성능 실측"에서는 세 번째 자원으로 갑니다. 기초 4편에서 IOPS와 지연의 개념을 잡았다면, 이번에는 fio로 직접 재 보고, 큐 깊이가 숫자를 어떻게 바꾸는지, SSD 내부의 사정(쓰기 증폭과 TRIM)이 왜 어제와 오늘의 성능을 다르게 만드는지를 다루겠습니다.

X