메모리 할당자를 정리한 글에서는 jemalloc 의 arena, TCMalloc 의 per-CPU 캐시, JVM 의 TLAB 이 작은 할당을 얼마나 빨리 처리하는지를 따라갔습니다. 세 방식 모두 할당 경로의 경합을 줄이는 기법입니다. 그런데 할당이 빨리 끝난 뒤 그 메모리를 읽고 쓰는 비용은 다른 데서 정해집니다. 서버에 CPU 소켓이 여러 개라면 같은 RAM 이라도 어느 CPU 가 접근하느냐에 따라 지연 시간과 대역폭이 달라집니다. 이런 구조를 NUMA 라고 부릅니다.

멀티코어에서 데이터 배치를 다룬 글의 6장에서는 CPU affinity 와 NUMA memory policy 가 각각 실행 위치와 메모리 위치를 정한다는 점, 페이지가 처음 만든 스레드의 노드에 생긴다는 점, automatic NUMA balancing 이 페이지를 옮긴다는 점을 다뤘습니다. 이 글은 페이지의 위치가 정해지는 시점을 더 자세히 따라가고 그 위에서 JVM 과 할당자가 무엇을 바꾸는지 이어서 정리합니다. 출발점은 다음 질문입니다.

할당자가 메모리를 빨리 내주면 그 메모리에 접근하는 비용도 낮을까요? 물리 페이지가 어느 NUMA 노드에 놓일지는 누가, 언제 정할까요?

Linux 커널, OpenJDK, jemalloc, TCMalloc 의 공식 문서와 소스 코드, 클라우드 사업자의 인스턴스 문서를 바탕으로 정리했습니다. 직접 실행한 실험이나 측정 결과는 포함하지 않았습니다. 글에 나오는 지연 시간 수치와 예시 서버 구성은 설명을 위한 가정입니다. 클라우드 인스턴스의 노드 수는 각 사업자 문서의 값입니다.

1. 메모리 접근 비용은 왜 균일하지 않게 되었을까요?

CPU 는 연산에 필요한 데이터를 레지스터와 캐시에서 먼저 찾습니다. cache miss 가 나면 메인 메모리까지 가야 합니다. 이 접근은 캐시보다 훨씬 오래 걸립니다.

코어 수가 늘면 더 많은 코어가 동시에 메모리를 읽고 씁니다. 모든 코어가 하나의 메모리 경로를 공유하면 그 경로의 대역폭이 병목이 됩니다. NUMA(Non-Uniform Memory Access)는 이 문제를 줄이려고 CPU 그룹마다 가까운 메모리와 메모리 컨트롤러를 따로 두는 구조입니다. 각 그룹이 자기 메모리를 병렬로 쓰므로 전체 대역폭은 늘어납니다. 대신 다른 그룹의 메모리에 접근할 때는 노드 사이의 연결(interconnect)을 거치므로 더 느립니다.

구분UMA(Uniform Memory Access)NUMA(Non-Uniform Memory Access)
메모리 접근 비용어느 CPU 에서 접근해도 비교적 비슷함CPU 와 물리 메모리의 위치에 따라 달라짐
구조공유 경로로 메모리에 접근CPU 그룹마다 가까운 메모리가 있음
코어를 늘릴 때공유 경로의 대역폭이 한계가 됨그룹별 메모리 컨트롤러와 대역폭을 함께 씀

NUMA 는 로컬 접근으로 확장성을 얻고 원격 접근의 비용을 받아들이는 구조입니다. 모든 메모리 접근이 빨라지지는 않습니다. 참고: Linux Kernel, What is NUMA?.

2. NUMA 노드는 CPU 소켓과 같은 것일까요?

NUMA 노드는 메모리 접근 특성이 비슷한 CPU 와 메모리를 묶은 단위입니다. 소켓이 두 개인 서버를 단순하게 그리면 다음과 같습니다.

코어 수와 메모리 용량은 예시입니다. 가운데 굵은 선이 노드 사이의 연결(interconnect)이고, 초록 실선은 socket 0 코어의 local access, 주황 점선은 remote access 경로입니다. 실제 서버는 소켓 하나를 여러 노드로 나누기도 합니다.

node 0 의 CPU 가 RAM 0 에 접근하면 local access, RAM 1 에 접근하면 remote access 입니다. 일반적인 서버는 ccNUMA(cache-coherent NUMA) 구조입니다. 두 노드의 메모리가 하나의 물리 주소 공간을 이루고 캐시 일관성은 하드웨어가 유지합니다. node 0 의 CPU 도 RAM 1 을 읽고 쓸 수 있고 비용만 더 듭니다.

비슷하게 들리는 용어는 각각 이런 뜻입니다.

용어뜻
CPU socket메인보드에서 물리 프로세서가 장착되는 자리
core명령을 실행하는 연산 장치
logical CPU운영체제가 스케줄링 대상으로 보는 CPU. 하이퍼스레딩을 켜면 물리 코어보다 많아집니다
memory controllerDRAM 접근을 처리하는 하드웨어
NUMA nodeCPU 와 메모리의 지역성을 나타내는 단위

소켓과 NUMA 노드가 항상 1대1로 대응하지는 않습니다. 한 소켓을 여러 노드로 나눌 수도 있고 실제 구성은 프로세서와 펌웨어 설정에 따라 달라집니다. Azure 의 HBv4 VM 은 소켓 두 개짜리 서버를 소켓마다 노드 두 개로 나눠 노드 네 개로 구성하고 게스트 OS 에도 노드 네 개를 그대로 보여 줍니다. 참고: Azure HBv4-series overview.

3. 원격 메모리는 실제로 얼마나 느릴까요?

NUMA 의 비용은 latency 와 bandwidth 로 나눠 볼 수 있습니다.

지표뜻크게 작용하는 경우
latency메모리 접근 하나가 끝나기까지 걸리는 시간연결 리스트처럼 포인터를 따라가는 탐색, 불규칙한 접근
bandwidth일정 시간에 옮길 수 있는 데이터의 양큰 배열 순회, 대량 분석 처리

설명을 위해 로컬 RAM 접근이 100ns, 원격 RAM 접근이 180ns 라고 가정하겠습니다. 실제 값은 CPU 와 메모리 구성, 그리고 다른 코어와의 경합에 따라 달라집니다.

Node current = head;
while (current != null) {
    current = current.next;
}

연결 리스트는 현재 원소의 next 를 읽어야 다음 원소의 주소를 알 수 있습니다. 이런 pointer chasing 은 앞의 접근이 끝나야 다음 접근을 시작할 수 있어서 메모리 지연을 다른 작업으로 가리기 어렵습니다. 리스트 원소마다 cache miss 가 나고 원소가 원격 메모리에 있다면 가정한 수치로 접근마다 80ns 씩 더 기다립니다.

순차 배열 순회는 사정이 다릅니다. CPU 의 하드웨어 프리페처가 다음 데이터를 미리 가져오고 여러 접근을 겹쳐 처리할 수 있으므로 개별 지연보다 메모리 대역폭이 병목이 되기 쉽습니다. NUMA 에서는 대역폭도 데이터 위치에 따라 달라집니다. 데이터가 한 노드에 몰려 있으면 모든 코어가 그 노드의 memory controller 대역폭을 나눠 쓰고 다른 노드의 코어가 읽는 몫은 interconnect 도 지나야 합니다.

데이터가 캐시에 있으면 RAM 에 가지 않습니다. 그래서 가정한 수치처럼 원격 RAM 이 로컬보다 80% 느려도 애플리케이션 전체가 그만큼 느려진다고 말할 수는 없습니다. 실제 영향은 cache miss 가 얼마나 나고 그중 원격 메모리로 가는 접근이 얼마나 되는지에 달려 있습니다.

4. 스레드가 다른 노드로 옮겨 가면 메모리도 따라올까요?

스레드 A 가 node 0 의 CPU 에서 실행되며 node 0 메모리를 쓰고 있다고 하겠습니다. 스케줄러가 부하를 나누려고 스레드 A 를 node 1 의 CPU 로 옮기면 실행 위치는 바뀌지만 이미 할당된 물리 페이지는 node 0 에 그대로 남습니다. 스레드 A 에게 로컬이던 메모리가 원격 메모리가 됩니다.

여기서 두 종류의 이동을 구별해야 합니다.

이동바뀌는 것
CPU migration스레드가 실행되는 CPU
page migration물리 페이지가 놓인 NUMA 노드

Linux 의 automatic NUMA balancing(kernel.numa_balancing)을 켜면 커널이 페이지 접근을 주기적으로 샘플링해 자주 접근하는 노드 쪽으로 페이지를 옮기거나 스레드를 데이터 가까이에 배치하려고 합니다. 페이지를 옮기는 데도 비용이 듭니다. 여러 노드의 스레드가 같은 데이터를 함께 쓰면 어느 노드로 옮겨도 누군가에게는 원격 접근이 됩니다. 그래서 이동이 항상 이득이 되지는 않습니다. 참고: Linux Kernel, numa_balancing.

노드의 로컬 메모리가 부족해도 원격 메모리가 쓰입니다. 기본 정책(5장에서 볼 local allocation)에서는 현재 노드에 빈 페이지가 없으면 커널이 다른 노드에서 페이지를 할당합니다. 서버 전체에 메모리가 남아 있어도 지금 실행 중인 CPU 의 노드에는 부족할 수 있고 그때부터 새로 할당되는 페이지는 원격 메모리에 놓입니다.

5. 물리 페이지는 언제, 어느 노드에 생길까요?

char *data = malloc(4096);
data[0] = 10;

malloc() 이 주소를 돌려준 순간 새 물리 페이지가 생기는 것은 아닙니다. 할당자는 이전에 해제된 공간을 다시 내줄 수도 있고 새로 받은 영역이라면 가상 주소만 확보한 상태일 수도 있습니다. 새 anonymous memory(파일과 연결되지 않은 메모리) 페이지는 실제로 접근할 때 page fault 가 나면서 물리 페이지와 연결됩니다.

아래에서 스레드 A 가 받은 buf 에 스레드 B 가 먼저 써 보세요. 페이지는 어느 노드에 생길까요?

buf 를 받은 스레드와 처음 쓴 스레드가 다르면?

스레드 A 가 node 0 에서 malloc 으로 buf 를 받았고, 아직 물리 페이지는 없습니다. 어느 스레드가 먼저 쓰거나 읽을지 골라 보고, 페이지가 어느 노드 메모리에 생기는지 확인하세요.

node 0 페이지 0 node 1 페이지 0 마지막 접근 중 원격 미측정

페이지 네 개짜리 buf 하나로 단순화한 모형입니다. 기본 정책(local allocation)이고, 두 노드 모두 빈 메모리가 충분하며, automatic NUMA balancing 은 꺼져 있다고 가정했습니다. malloc 이 블록 앞에 헤더를 쓰지 않는 할당자로 가정했습니다. glibc 처럼 헤더를 쓰는 할당자에서는 헤더가 놓인 페이지를 스레드 A 가 먼저 건드릴 수 있습니다. CPU 0~3 은 논리 CPU 입니다. page fault 처리, TLB, CPU 캐시는 그리지 않았습니다. zero page 는 여러 곳이 함께 쓰는 읽기 전용 페이지라 어느 노드 메모리에도 새로 그리지 않았습니다. 접근 패킷의 이동 시간은 경로 길이에 맞춰 그렸을 뿐 실제 지연 비율이 아닙니다.

Linux 의 기본 memory policy 는 local allocation 입니다. 페이지를 할당하는 시점에 실행 중인 CPU 의 노드에서 먼저 할당합니다. 새 페이지에 처음 쓸 때 위치가 정해지므로 이 동작을 흔히 first touch 라고 부릅니다. 참고: Linux Kernel, NUMA Memory Policy.

그래서 기본 정책에서 페이지의 위치는 그 페이지에 처음 쓴 스레드를 따라갑니다. 정책 설정과 노드의 남은 메모리에 따라 결과는 달라집니다. 처음 접근이 읽기라면 커널이 공용 zero page 를 매핑해 두고 첫 쓰기 때 실제 페이지를 할당하므로 위치가 정해지는 시점이 더 늦어질 수 있습니다. malloc 을 부른 스레드가 첫 페이지를 먼저 쓰는 경우도 있습니다. glibc malloc 은 블록 앞에 청크 헤더를 쓰므로 헤더가 놓인 페이지가 아직 비어 있었다면 그 페이지는 malloc 을 부른 스레드가 first touch 합니다. 참고: glibc MallocInternals.

실행 위치와 메모리 위치를 따로 지정합니다

# 논리 CPU 0~3에서 실행하도록 제한
taskset -c 0-3 ./server

# NUMA node 0의 CPU에서 실행하고 node 0의 메모리에서만 할당
numactl --cpunodebind=0 --membind=0 ./server

taskset 은 실행할 CPU 만 정하고 메모리 정책은 바꾸지 않습니다. 다만 기본 정책에서는 실행 중인 CPU 의 노드가 새 페이지의 위치를 정하므로 실행 위치를 옮기면 이후 처음 쓰는 페이지의 노드도 함께 바뀝니다. 이미 할당된 페이지는 그대로입니다. numactl 은 두 가지를 함께 정할 수 있습니다.

옵션동작
--cpunodebind=0node 0 에 속한 CPU 에서만 실행
--membind=0node 0 의 메모리에서만 할당
--interleave=0,1페이지를 node 0 과 node 1 에 번갈아 할당

--membind 는 지정한 노드의 메모리가 부족해도 다른 노드로 넘어가지 않으므로 할당이 실패할 수 있습니다. 한 노드에 묶으면 그 노드의 코어와 메모리 대역폭만 쓰게 됩니다. 작업을 여러 노드의 CPU 에 나눠서 얻는 병렬성이 원격 접근 비용보다 크다면 묶지 않는 쪽이 더 빠를 수 있습니다. 참고: numactl(8).

6. JVM 힙과 TLAB 은 NUMA 노드를 고려할까요?

java -Xms512m -Xmx2g -jar server.jar

-Xms 는 최소이자 초기 힙 크기, -Xmx 는 최대 힙 크기입니다. JVM 은 힙에 쓸 가상 주소 공간을 예약하고 필요한 만큼 커밋합니다. 커밋해도 물리 페이지가 바로 생기지는 않습니다. 기본 설정에서는 애플리케이션이 힙을 쓰면서 처음 쓰는 페이지마다 물리 페이지가 연결됩니다. 그래서 힙이 연속된 가상 주소 공간 하나여도 그 뒤의 물리 페이지는 여러 노드에 흩어질 수 있습니다. -XX:+UseNUMA 와 -XX:+AlwaysPreTouch 를 끈 기본 설정에서는 5장의 first touch 규칙이 힙에도 그대로 적용됩니다. 두 옵션은 이 장 끝에서 다룹니다.

Java 코드에서 객체를 만들 때는 두 단계가 있습니다.

  1. JVM 이 힙 안에서 객체를 놓을 자리를 정합니다.
  2. 그 자리에 아직 물리 페이지가 연결되지 않았다면 첫 쓰기 때 OS 가 memory policy 에 따라 페이지를 할당합니다.

한 번 쓰인 힙 영역은 이미 물리 페이지와 연결되어 있습니다. 그 영역이 node 1 에 있다면 node 0 의 스레드가 그 안에 새 객체를 만들어도 first touch 가 다시 일어나지 않고 객체는 node 1 메모리에 놓입니다.

TLAB 은 할당 경쟁을 줄입니다

TLAB(Thread Local Allocation Buffer)은 Java 스레드마다 주는 객체 할당 전용 영역입니다. 작은 객체는 보통 자기 TLAB 안에서 할당 포인터만 앞으로 옮기는 bump pointer 방식으로 할당됩니다.

object_address = top
top = top + object_size

스레드마다 TLAB 이 있으므로 공유 할당 포인터를 두고 경쟁하지 않고 bump pointer 덕분에 할당 연산도 남은 공간 검사와 덧셈으로 끝납니다. TLAB 이 정하는 것은 어느 스레드가 어느 힙 구간에 할당할지까지입니다. 그 구간의 물리 페이지가 어느 노드에 있는지는 TLAB 이 정하지 않습니다. 이미 쓰인 구간이면 그 페이지를 그대로 쓰고 아직 쓰인 적 없는 페이지라면 처음 쓰는 스레드의 노드에 생깁니다.

GC 가 객체를 옮기면 노드도 바뀔까요?

G1 같은 복사형 GC 는 살아 있는 객체를 다른 region(G1 이 힙을 나눈 고정 크기 구역)으로 복사합니다. 커널도 page migration 으로 페이지를 다른 노드로 옮길 수 있습니다. 두 이동 가운데 무엇이 객체의 물리 노드를 바꿀까요? 아래에서 세 가지 이동을 하나씩 눌러 보세요.

객체 주소가 바뀌는 것과 물리 노드가 바뀌는 것

obj 는 힙의 R1 region 에 있고, R1 의 물리 페이지는 node 0 에 있습니다. 세 가지 이동을 하나씩 눌러 obj 의 주소와 물리 노드 중 무엇이 바뀌는지 비교해 보세요.

obj 주소 0x10040 obj 의 물리 노드 node 0 스레드 A 의 읽기 미측정

region 네 개와 객체 하나로 단순화한 모형입니다. region 하나에 물리 페이지 하나만 그렸지만 실제 region 은 여러 페이지로 이루어집니다. 주소 값은 예시입니다. region 별 노드는 처음 상태로 정해 둔 가정입니다. 기본 설정이라면 각 region 에 처음 쓴 스레드가, UseNUMA 를 켜면 JVM 이 정합니다. 페이지 이동은 스레드 A 의 접근과 상관없는 수동 이동으로 그렸습니다. automatic NUMA balancing 이라면 자주 접근하는 노드 쪽으로 옮깁니다. GC 가 객체를 옮긴 뒤 참조를 고치는 과정, 옛 자리의 회수, 페이지를 옮기는 비용은 그리지 않았습니다. 버튼마다 처음 상태에서 다시 시작합니다.

JVM 의 객체 복사와 Linux 의 page migration 은 서로 다른 동작입니다. 객체 복사는 객체의 주소를 바꿉니다. 복사된 객체의 물리 노드는 옮겨 간 region 의 페이지가 어느 노드에 있는지를 따릅니다. region 의 페이지 위치는 기본 설정에서는 그 region 에 처음 쓴 스레드가 정하고 UseNUMA 를 켜면 JVM 이 정합니다. page migration 은 주소는 그대로 두고 그 주소 뒤의 물리 페이지를 바꿉니다. 커널의 automatic NUMA balancing 이나 migrate_pages() 같은 호출이 이 이동을 일으킵니다. 참고: Linux Kernel, Page migration, migrate_pages(2).

동작Java 객체 주소물리 NUMA 노드
GC 가 같은 노드에 있는 다른 region 으로 객체 복사바뀜같음
GC 가 다른 노드에 있는 region 으로 객체 복사바뀜달라짐
OS 가 물리 페이지를 다른 노드로 이동그대로달라짐

HotSpot 에는 -XX:+UseNUMA 옵션이 있고 기본값은 GC 에 따라 다릅니다. G1 은 JDK 14 부터 이 옵션을 켜면 힙 region 을 노드에 고르게 나눠 두고 스레드가 새 region 을 받을 때 그 스레드가 실행 중인 노드의 region 을 먼저 줍니다. 이때 region 의 노드는 JVM 이 정합니다. 이 동작은 young generation 에만 적용되고 old generation 과 humongous region 은 노드를 고려하지 않습니다. -XX:+AlwaysPreTouch 를 켜면 JVM 이 힙을 애플리케이션에 내주기 전에 모든 페이지를 먼저 건드립니다. 이 경우 힙 페이지는 객체를 만드는 스레드가 쓰기 전에 이미 연결되어 있습니다. 두 옵션을 켜도 모든 객체가 로컬 노드에 놓인다는 보장은 없고 GC 와 JDK 버전에 따라 지원 범위가 다릅니다. 참고: The java Command, JEP 345: NUMA-Aware Memory Allocation for G1.

7. 할당자가 블록을 다시 쓰면 그 블록은 어느 노드에 있을까요?

메모리 할당자 글에서 본 jemalloc arena 는 할당 관리 구조를 여러 개로 나눠 경합을 줄이고 TCMalloc per-CPU 캐시는 CPU 마다 다시 쓸 블록을 보관해 빠른 할당 경로를 만듭니다. 둘 다 할당 비용을 줄이는 구조입니다. 캐시에 든 블록의 물리 페이지가 어느 노드에 있는지는 따로 관리하지 않습니다.

다음 순서를 따라가 보겠습니다.

  1. node 0 의 스레드 A 가 블록을 할당해 처음 씁니다. 물리 페이지는 node 0 에 생깁니다.
  2. 스레드 A 가 free() 를 부릅니다.
  3. 할당자는 블록을 OS 에 돌려주지 않고 내부 캐시에 둡니다.
  4. 블록이 할당자의 공유 계층(TCMalloc 의 central free list, jemalloc arena 의 bin 등)으로 내려간 뒤 node 1 의 스레드 B 가 같은 크기를 요청해 그 블록을 받습니다.

free 한 블록을 다른 노드의 스레드가 다시 받으면?

node 0 의 스레드 A 가 블록을 할당해 썼고, 블록의 페이지 네 개가 node 0 메모리에 있습니다. A 가 free 한 뒤 할당자가 페이지를 들고 있을 때와 OS 에 돌려줬을 때, node 1 의 스레드 B 가 같은 블록을 받아 쓰면 어떻게 다른지 비교하세요.

블록을 쓰는 스레드 A 블록의 페이지 node 0 B 의 쓰기 중 원격 미측정

페이지 네 개짜리 블록 하나로 단순화한 모형입니다. 블록의 가상 주소는 그대로 두고, OS 에 돌려주는 경우는 그 주소 뒤의 물리 페이지를 즉시 비우는 반납(MADV_DONTNEED 등)으로 보았습니다. MADV_FREE 로 반납했다면 커널이 회수하기 전에는 B 가 쓸 때 node 0 의 원래 페이지가 그대로 쓰입니다. 실제 할당자는 크기와 시간 기준에 따라 더 큰 단위로 반납합니다. 블록이 스레드나 CPU 전용 캐시에서 공유 계층으로 내려간 뒤 B 에게 넘어가는 경로는 할당자 캐시 하나로 줄여 그렸습니다. 기본 정책(local allocation)이고 automatic NUMA balancing 은 꺼져 있다고 가정했습니다. 쓰기 패킷의 이동 시간은 경로 길이에 맞춰 그렸을 뿐 실제 지연 비율이 아닙니다. 버튼마다 처음 상태에서 다시 시작합니다.

블록의 페이지는 1단계에서 이미 node 0 에 할당되었으므로 스레드 B 의 접근은 원격 접근이 됩니다. 블록을 다시 받는 것은 새 first touch 가 아니기 때문입니다. 할당자가 그 사이 페이지를 즉시 비우는 방식(MADV_DONTNEED 등)으로 OS 에 돌려주었거나 커널이 페이지를 옮겼다면 결과가 달라집니다. MADV_FREE 로 돌려준 페이지는 커널이 회수하기 전에 다시 쓰면 원래 페이지가 그대로 쓰이므로 여전히 node 0 에 있습니다. 참고: madvise(2).

두 할당자 모두 CPU 나 노드를 고려하는 옵션이 있습니다.

  • jemalloc 의 opt.percpu_arena 를 percpu 로 설정하면 스레드가 현재 실행 중인 CPU 에 따라 arena 를 고릅니다. arena 를 CPU 에 맞춰 고를 뿐이고 페이지를 특정 노드에 옮기지는 않습니다. 참고: jemalloc(3).
  • TCMalloc 은 NUMA 인식을 넣어 빌드한 라이브러리(tcmalloc_numa_aware 등)에서 TCMALLOC_NUMA_AWARE 환경 변수로 NUMA 인식 모드를 켤 수 있습니다. 기본값은 꺼짐이고 rseq 를 쓸 수 없으면 켜지지 않습니다. 켜면 노드를 partition 으로 묶어 partition 마다 관리 구조를 따로 둡니다. 메모리를 노드에 바인딩할지는 no-binding, advisory-binding, strict-binding 중에서 고릅니다. 참고: TCMalloc BUILD, numa.h, numa.cc.

8. 내 서비스에서도 NUMA 를 신경 써야 할까요?

작은 Spring Boot 서버에서 NUMA 튜닝이 가장 먼저 할 일인 경우는 드뭅니다. 응답 시간 대부분이 DB 호출이나 네트워크 I/O 에서 나온다면 그 병목부터 줄여야 합니다. 작은 VM 이라면 애초에 게스트 OS 에 노드가 하나만 보일 수도 있습니다(9장).

다음과 같은 환경에서는 NUMA 가 성능에 크게 작용할 수 있습니다.

  • 코어가 많은 멀티소켓 서버에서 여러 노드의 메모리를 함께 쓰는 경우
  • 인메모리 DB 나 벡터 검색 엔진처럼 큰 데이터에 반복해서 접근하는 경우
  • 병렬 분석이나 고성능 계산처럼 코어 수와 메모리 대역폭이 모두 필요한 경우
  • 큰 VM 이나 컨테이너를 운영하면서 CPU 배치와 메모리 배치가 어긋날 수 있는 경우

예를 들어 노드 두 개에 코어 64개, RAM 256GB 인 서버에서 메모리를 많이 쓰는 애플리케이션을 돌린다고 하겠습니다. 일부 스레드는 node 0 에서 실행되는데 이 스레드가 자주 쓰는 페이지는 node 1 에 몰려 있을 수 있습니다. 시작할 때 한 스레드가 데이터를 모두 초기화했거나(5장), 스케줄러가 스레드를 옮겼거나(4장), 다른 노드의 스레드가 쓰던 블록을 다시 받은 경우(7장)입니다. 이때 원격 메모리 접근 비율과 노드별 메모리 대역폭을 측정하고 작업과 데이터를 같은 노드에 두도록 바꾸면 성능이 나아질 수 있습니다.

CPU 사용률만으로는 NUMA 가 병목인지 알 수 없습니다. cache miss, 메모리 대역폭, 원격 메모리 접근 횟수, 스레드가 실행된 CPU 를 함께 기록해야 합니다. 노드별 페이지 배치는 9장의 명령으로 확인할 수 있습니다. affinity 나 memory policy 를 바꿨다면 바꾸기 전과 후의 처리량과 p95, p99 지연 시간을 같은 부하에서 비교합니다.

9. 클라우드 VM 에서도 NUMA 노드가 보일까요?

클라우드에서는 물리 서버의 NUMA 구조(pNUMA)와 VM 의 게스트 OS 에 보이는 가상 NUMA 구조(vNUMA)를 따로 봐야 합니다. 물리 호스트가 NUMA 구조여도 작은 VM 에는 노드가 하나만 보일 수 있습니다.

플랫폼공식 문서의 예게스트 OS 에 보이는 노드
AWS EC2c5.24xlarge 의 lscpu 출력2개
GCP N2 (Cascade Lake)vNUMA 를 켠 32 vCPU 이상 VM2개, 32 vCPU 미만은 1개
GCP N2 (Ice Lake)vNUMA 를 켠 48 vCPU 초과 VM2개, 48 vCPU 이하는 1개
Azure HBv4HPC VM 시리즈모든 크기에서 4개, 물리 구성과 같음

참고: AWS Ground Station Agent, 인스턴스 선택, Google Cloud, General-purpose machine family, Azure HBv4-series overview.

같은 GCP N2 안에서도 CPU 세대에 따라 기준이 다르므로 “vCPU 가 몇 개 이상이면 NUMA 노드가 생긴다”는 공통 기준은 없습니다. 인스턴스 사양 문서와 게스트 OS 가 실제로 보는 구성을 각각 읽어야 합니다.

Linux 에서 직접 확인하는 명령

# CPU, 소켓, NUMA 노드 구성
lscpu

# 노드별 CPU 목록, 메모리 용량, 노드 간 거리
numactl --hardware

# 프로세스의 메모리 영역별 노드 배치 (PID를 바꿔서 실행)
cat /proc/<PID>/numa_maps

# 시스템 전체의 NUMA 관련 누적 카운터
grep numa /proc/vmstat

lscpu 의 NUMA node(s) 가 1이면 게스트 OS 에는 노드가 하나만 보입니다. 물리 호스트에 NUMA 구조가 없는지는 이 값만으로 알 수 없습니다. /proc/<PID>/numa_maps 는 메모리 영역마다 각 노드에 페이지가 몇 개 있는지 보여 주므로 힙이나 큰 버퍼가 어느 노드에 몰렸는지 확인할 때 씁니다. /proc/vmstat 의 numa_hit, numa_miss 는 할당 시점의 통계라서 실행 중 원격 메모리를 얼마나 읽는지는 알려 주지 않습니다. 원격 읽기 비율은 멀티코어 글 7장의 토스 사례처럼 perf 로 CPU 의 PMU(성능 모니터링 카운터) 이벤트를 측정해야 알 수 있습니다. 참고: Linux Kernel, NUMA statistics.

10. 처음 질문으로 돌아가면

할당자가 메모리를 빨리 내주면 그 메모리에 접근하는 비용도 낮을까요? 물리 페이지가 어느 NUMA 노드에 놓일지는 누가, 언제 정할까요?

할당 속도를 정하는 계층과 물리 페이지의 위치를 정하는 계층은 따로 있습니다.

계층정하는 것물리 노드에 미치는 영향
TLAB, bump pointer객체를 힙의 어디에 놓을지정하지 않음. 이미 쓰인 자리면 그 페이지를 쓰고, 새 페이지라면 처음 쓴 스레드의 노드를 따름
UseNUMA, AlwaysPreTouch힙 페이지를 어느 노드에, 언제 연결할지켜면 JVM 이 region 의 노드를 정하거나 시작할 때 페이지를 미리 연결함
jemalloc arena, TCMalloc per-CPU 캐시어느 관리 구조에서 블록을 줄지기본 설정에서는 없음. 재사용한 블록은 처음 할당된 노드에 있음
Linux memory policy새 페이지를 어느 노드에 할당할지첫 쓰기 시점에 실행 중인 CPU 의 노드가 기본
automatic NUMA balancing, page migration이미 할당된 페이지를 옮길지접근 패턴에 따라 노드를 바꿈
GC 의 객체 복사객체를 어느 region 으로 옮길지옮겨 간 region 의 노드를 따름

할당자가 메모리를 빨리 내줘도 그 메모리의 물리 페이지는 원격 노드에 있을 수 있습니다. 기본 정책에서 새 페이지는 처음 쓴 스레드가 실행 중이던 CPU 의 노드에 생깁니다. 이후 스케줄러, 할당자의 블록 재사용, GC, 커널의 page migration 이 실행 위치와 데이터 위치의 관계를 다시 바꿉니다. NUMA 를 의심할 때는 스레드가 어느 CPU 에서 실행되는지, 자주 쓰는 데이터가 어느 노드에 있는지, 지금 부족한 것이 병렬성인지 메모리 지역성인지를 차례로 확인합니다.

이 글은 문서와 소스를 읽고 정리한 내용이라 원격 접근이 실제 처리량을 얼마나 바꾸는지는 아직 측정하지 않았습니다. 노드가 두 개인 서버에서 numactl --cpunodebind 와 --membind 조합을 바꿔 가며 메모리 대역폭, 지연 시간, 애플리케이션 처리량을 재 보는 것이 다음 실험입니다.


참고 자료:

TCMalloc 은 GitHub 기본 브랜치의 소스, JVM 옵션은 JDK 25 문서를 기준으로 정리했습니다. 버전에 따라 세부 동작은 바뀔 수 있습니다.