NUMA (Non-Uniform Memory Access)
NUMA: 하드웨어 토폴로지(Topology), ACPI SRAT/SLIT, pglist_data, 메모리 정책(Memory Policy)(mbind/set_mempolicy), Automatic NUMA Balancing, NUMA-aware 스케줄링, numactl, CXL, vNUMA를 다룹니다.
NUMA 하드웨어 토폴로지, ACPI 테이블 파싱, 커널 자료구조, 메모리 정책, Automatic NUMA Balancing, NUMA-aware 스케줄링, CXL 확장까지 — 비균일 메모리 접근 아키텍처를 소스 코드 수준에서 분석합니다.
pg_data_t 개요는 메모리 관리(Memory Management) — NUMA 섹션을, Hugepage와 NUMA 연동은 Huge Pages — NUMA 섹션을 참조하세요.
핵심 요약
- NUMA — 메모리 접근 시간이 CPU와 메모리의 물리적 위치에 따라 달라지는 아키텍처입니다.
- Node — CPU 소켓(Socket)과 로컬 메모리를 묶은 단위. 커널에서
pglist_data구조체(Struct)로 표현됩니다. - SRAT/SLIT — ACPI 테이블로 NUMA 토폴로지(노드 구성)와 거리(접근 지연(Latency))를 커널에 전달합니다.
- 메모리 정책 —
mbind()/set_mempolicy()로 프로세스(Process)의 메모리 할당 노드를 제어합니다. - NUMA Balancing — 커널이 자동으로 페이지(Page)를 자주 접근하는 CPU 노드로 마이그레이션합니다.
단계별 이해
- 토폴로지 확인 —
numactl --hardware로 시스템의 NUMA 노드 수, 각 노드의 CPU와 메모리 크기를 확인합니다.커널 함수:
acpi_numa_init()→acpi_parse_srat()가 SRAT 테이블을 파싱하여numa_meminfo에 노드 정보를 저장합니다. - 로컬 vs 원격 접근 — 같은 노드의 메모리 접근은 빠르고(~80ns), 다른 노드 접근은 느립니다(~140ns).
커널 구조체:
numa_distance[][]배열에 SLIT 기반 노드 간 거리가 저장됩니다.numastat으로 노드별 할당 통계를 모니터링할 수 있습니다. - 정책 적용 —
numactl --cpubind=0 --membind=0 ./app으로 특정 노드에 CPU와 메모리를 바인딩합니다.커널 함수:
do_set_mempolicy()가struct mempolicy를 태스크(Task)에 설정하고,alloc_pages_mpol()이 할당 시 정책을 적용합니다. - 커널 자동 밸런싱 —
/proc/sys/kernel/numa_balancing으로 자동 마이그레이션을 활성화/비활성화합니다.커널 함수:
task_tick_numa()→task_numa_work()→ VMA 스캔 →do_numa_page()→task_numa_fault()→migrate_misplaced_folio()체인으로 페이지를 최적 노드로 이동시킵니다.
NUMA 탄생 배경과 기초 개념
NUMA를 제대로 이해하려면 먼저 왜 NUMA가 필요하게 되었는지와 로컬/원격 메모리 접근이 실제로 어떻게 다른지를 파악해야 합니다. 이 섹션에서는 기초 개념, 핵심 용어, 그리고 실제 성능 영향을 설명합니다.
UMA에서 NUMA로 — 확장성 한계의 해결
초창기 다중 프로세서 시스템은 UMA(Uniform Memory Access, 균일 메모리 접근) 구조를 사용했습니다. 모든 CPU가 하나의 공유 메모리 버스를 통해 동일한 메모리에 접근하므로 구현이 단순하고 프로그래밍 모델도 직관적입니다. 그러나 CPU 수가 증가할수록 공유 버스(Bus) 경합(Contention)이 기하급수적으로 증가하며, 8~16코어를 넘어서면 메모리 버스가 포화되어 추가 CPU가 오히려 전체 처리량을 떨어뜨립니다.
NUMA(Non-Uniform Memory Access, 비균일 메모리 접근)는 이 병목(Bottleneck)을 해결하기 위해 각 CPU 소켓(또는 CPU 그룹)에 자신만의 로컬 메모리를 독립적으로 붙이는 방식으로 설계되었습니다. 로컬 메모리는 높은 대역폭과 낮은 지연 시간으로 접근할 수 있습니다. 반면 다른 소켓의 메모리(원격 메모리, Remote Memory)에 접근하려면 소켓 간 인터커넥트(Intel UPI, AMD Infinity Fabric 등)를 경유해야 하므로 추가 지연이 발생합니다. 어느 메모리에 접근하느냐에 따라 속도가 달라지므로 "비균일"이라는 이름이 붙었습니다.
NUMA 핵심 용어 정리
NUMA 문서와 커널 코드 전반에서 반복적으로 등장하는 핵심 용어를 정리합니다. 이 용어들을 먼저 익혀두면 이후 섹션을 훨씬 쉽게 이해할 수 있습니다.
| 용어 | 설명 | 커널 표현 / 확인 명령 |
|---|---|---|
| 노드 (Node) | CPU 소켓과 그에 직접 연결된 로컬 메모리를 묶은 단위. NUMA 시스템의 기본 구성 요소 | pg_data_t, NODE_DATA(nid) |
| 로컬 메모리 (Local Memory) | 현재 실행 중인 CPU와 같은 노드에 있는 메모리. 접근 지연이 가장 낮고 대역폭이 높음 | numa_node_id(), numastat의 local_node |
| 원격 메모리 (Remote Memory) | 다른 노드에 있는 메모리. 인터커넥트를 경유하므로 로컬보다 1.5~3배 느림 | numastat의 other_node, numa_miss |
| NUMA 거리 (Distance) | 노드 간 상대적 접근 비용. 자기 자신은 항상 10, 값이 클수록 접근이 느림 | numa_distance[][], SLIT 테이블, numactl -H |
| NUMA Ratio | 원격 거리 / 로컬 거리 비율. 2.0 이상이면 NUMA 최적화가 성능에 큰 영향을 미침 | numactl --hardware의 node distances로 계산 |
| NUMA Hit / Miss | 로컬 노드에서 메모리 할당 성공(Hit) vs 원격 노드로 폴백(Miss). 낮은 Miss 비율이 좋음 | /proc/vmstat의 numa_hit, numa_miss |
| NUMA Balancing | 커널이 자동으로 페이지를 접근이 잦은 CPU 노드로 이동시키는 메커니즘. 장기 실행 프로세스의 로컬리티를 자동으로 개선 | /proc/sys/kernel/numa_balancing |
| 메모리 정책 (Memory Policy) | 메모리 할당 시 어떤 노드를 선택할지 규칙. DEFAULT, BIND, INTERLEAVE, PREFERRED 등 | struct mempolicy, mbind(), numactl |
| 로컬리티 (Locality) | CPU와 접근하는 메모리가 같은 노드에 있어 빠르게 접근 가능한 상태. 좋은 로컬리티 = 높은 성능 | NUMA Balancing이 자동으로 개선 시도 |
| NUMA 친화성 (Affinity) | 특정 프로세스나 스레드를 특정 노드에서 실행하도록 바인딩하는 것 | numactl --cpubind, set_mempolicy() |
로컬 메모리와 원격 메모리의 성능 차이
NUMA의 핵심은 "어디서 메모리를 할당받았는가"가 성능에 직접 영향을 미친다는 점입니다. 같은 물리 메모리라도 어느 CPU(소켓)에서 접근하느냐에 따라 지연 시간과 대역폭이 크게 달라집니다. 다음 예제는 Node 0에서 실행 중인 프로세스가 로컬(Node 0)과 원격(Node 1) 메모리에 각각 접근할 때의 성능 차이를 측정합니다.
/* NUMA 로컬/원격 메모리 대역폭 측정 예제
* 빌드: gcc -O2 -o numa_bench numa_bench.c -lnuma
* 실행: numactl --cpubind=0 ./numa_bench
*/
#define _GNU_SOURCE
#include <numa.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#define BUF_SIZE (256UL * 1024 * 1024) /* 256 MB */
#define ITERS 10
static double bench_bandwidth(char *buf, size_t size)
{
struct timespec t0, t1;
volatile long sum = 0;
memset(buf, 0, size); /* 첫 접근으로 페이지 폴트 처리 */
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int it = 0; it < ITERS; it++)
for (size_t j = 0; j < size; j += 64) /* 캐시 라인 단위 순차 읽기 */
sum += *(volatile long *)(buf + j);
clock_gettime(CLOCK_MONOTONIC, &t1);
double elapsed = (t1.tv_sec - t0.tv_sec) +
(t1.tv_nsec - t0.tv_nsec) * 1e-9;
return (double)(size * ITERS) / elapsed / (1024.0 * 1024 * 1024);
}
int main(void)
{
if (numa_available() < 0 || numa_max_node() < 1) {
fputs("NUMA 미지원 또는 단일 노드 시스템\n", stderr);
return 1;
}
numa_run_on_node(0); /* 현재 스레드를 Node 0에 고정 */
/* Node 0 메모리 할당 → CPU 0 기준 로컬 접근 */
char *local_buf = numa_alloc_onnode(BUF_SIZE, 0);
/* Node 1 메모리 할당 → CPU 0 기준 원격 접근 */
char *remote_buf = numa_alloc_onnode(BUF_SIZE, 1);
double local_bw = bench_bandwidth(local_buf, BUF_SIZE);
double remote_bw = bench_bandwidth(remote_buf, BUF_SIZE);
printf("로컬 메모리 대역폭: %.1f GB/s (Node 0 → Node 0)\n", local_bw);
printf("원격 메모리 대역폭: %.1f GB/s (Node 0 → Node 1)\n", remote_bw);
printf("성능 비율: %.2fx 차이\n", local_bw / remote_bw);
numa_free(local_buf, BUF_SIZE);
numa_free(remote_buf, BUF_SIZE);
return 0;
}
/* 일반적 출력 결과:
* Intel 2소켓 Xeon (DDR4-3200):
* 로컬 메모리 대역폭: 48.3 GB/s (Node 0 → Node 0)
* 원격 메모리 대역폭: 28.7 GB/s (Node 0 → Node 1)
* 성능 비율: 1.68x 차이
*
* AMD EPYC 2소켓 (NPS1, DDR5-4800):
* 로컬 메모리 대역폭: 62.1 GB/s
* 원격 메모리 대역폭: 38.4 GB/s
* 성능 비율: 1.62x 차이
*/
NUMA가 성능에 영향을 미치는 상황
모든 프로그램이 NUMA를 의식해야 하는 것은 아닙니다. 다음 표를 참고하여 NUMA 최적화가 실질적으로 필요한지 판단하세요.
| 상황 | NUMA 영향도 | 권장 조치 |
|---|---|---|
| 단일 소켓 서버 (1S) | 없음 | NUMA 고려 불필요 |
| 2소켓 서버, 소규모 단일 프로세스 | 낮음 | 기본 설정(Automatic NUMA Balancing)으로 충분 |
| 2소켓 서버, 메모리 대역폭 집약 워크로드 (HPC, DB, ML 추론) | 높음 | numactl --cpubind=N --membind=N으로 노드 고정 |
| 4소켓 이상 대형 서버, 멀티 인스턴스 DB/캐시 | 매우 높음 | 인스턴스당 노드 분리, mbind() 적극 적용 |
| AMD EPYC NPS4 (소켓 내 4노드 분할) | 높음 | 단일 소켓에서도 원격 메모리 발생, NUMA-aware 배치 필요 |
| Redis, Memcached 등 단일 스레드 캐시 서버 | 중간 | 인스턴스를 노드별로 분리 운영 (numactl --cpubind=N) |
| 컨테이너/VM (vNUMA) | 중간~높음 | vNUMA 토폴로지를 호스트와 일치시켜 구성 |
| Nginx, Apache 멀티 프로세스 웹 서버 | 낮음~중간 | 자동 NUMA Balancing으로 충분한 경우가 많음 |
numastat에서numa_miss/ (numa_hit+numa_miss) 비율이 5% 이상- CPU 사용률은 낮은데 메모리 대기 시간(Latency)이 높은 상태 (
sar -B또는perf stat) - 소켓 0은 메모리가 거의 없고 소켓 1은 여유가 많은 불균형 상태
- 서비스 시작 후 한동안 성능이 낮다가 점점 올라가는 현상 (NUMA Balancing 적응 중)
perf stat에서mem_load_retired.remote_dram이벤트 비율이 높음 (Intel)
내 시스템 NUMA 현황 첫 진단
NUMA 시스템을 처음 다룬다면 다음 순서로 진단을 시작하세요. 각 명령의 출력을 어떻게 해석해야 하는지도 함께 설명합니다.
# ① NUMA 시스템인지 먼저 확인
$ numactl --hardware
available: 2 nodes (0-1) # 2개 이상이면 NUMA 시스템
node 0 cpus: 0 1 2 3 4 5 6 7 # Node 0에 속한 CPU 번호
node 0 size: 65536 MB # Node 0의 물리 메모리 용량
node 0 free: 48230 MB # Node 0의 현재 여유 메모리
node 1 cpus: 8 9 10 11 12 13 14 15
node 1 size: 65536 MB
node 1 free: 51200 MB
node distances: # NUMA 거리 매트릭스
node 0 1
0: 10 21 # 10=로컬, 21=Node 1로의 원격 거리
1: 21 10 # NUMA Ratio = 21/10 = 2.1 → 최적화 강력 권장
# ② 현재 NUMA Miss 통계 확인 (miss 비율 5% 이상이면 문제)
$ numastat
node0 node1
numa_hit 142857391 98234567 # 로컬 할당 성공 횟수
numa_miss 1234567 2345678 # 원격 폴백 횟수 (낮을수록 좋음)
numa_foreign 2345678 1234567 # 이 노드가 다른 노드의 miss를 수용
local_node 139400824 95888889
other_node 4690134 4690356 # 원격 할당 횟수
# ③ 특정 프로세스의 노드별 메모리 분포 확인
$ numastat -p $(pgrep -x mysqld)
Per-node process memory usage (MB):
Node 0 Node 1 Total
Heap 512.00 256.00 768.00 # 이상적으로는 한 노드에 집중
Total 850.48 320.23 1170.71 # 분산이 크면 --membind 사용 고려
# ④ NUMA Balancing 상태 확인
$ cat /proc/sys/kernel/numa_balancing
1 # 1=자동 밸런싱 활성화 (대부분의 경우 권장)
# 지연 민감 워크로드는 0으로 비활성화 후 수동 배치
# ⑤ miss 비율 계산 (5% 이상이면 NUMA 최적화 필요)
$ numastat | awk '/numa_hit/{hit=$2+$3} /numa_miss/{miss=$2+$3}
END{printf "Miss 비율: %.1f%%\n", miss*100/(hit+miss)}'
Miss 비율: 1.7% # 5% 미만이면 양호한 상태
# ⑥ CPU가 어느 NUMA 노드에 속하는지 확인
$ cat /sys/devices/system/node/node0/cpulist
0-7 # Node 0에 CPU 0-7이 속함
$ cat /sys/devices/system/cpu/cpu3/topology/physical_package_id
0 # CPU 3은 소켓(패키지) 0 = Node 0
node distances의 원격 거리를 10으로 나누면 NUMA Ratio가 나옵니다.
- 거리 11~15 (Ratio 1.1~1.5): 영향이 적음. 자동 밸런싱으로 충분
- 거리 21 (Ratio 2.1): 원격이 로컬보다 약 2배 느림. NUMA-aware 배치 강력 권장
- 거리 31 이상 (Ratio 3.1+): 2홉 이상 원격. 성능 차이 매우 큼. 반드시 노드 격리 필요
NUMA 하드웨어 토폴로지
NUMA(Non-Uniform Memory Access) 시스템에서 각 CPU 소켓은 자신에게 직접 연결된 로컬 메모리를 가지며, 다른 소켓의 메모리에 접근할 때는 인터커넥트(QPI, UPI, Infinity Fabric 등)를 경유합니다. 이로 인해 메모리 접근 지연 시간(latency)과 대역폭(bandwidth)이 위치에 따라 달라집니다.
4소켓 이상의 대규모 서버에서는 인터커넥트가 멀티홉(Multi-hop)으로 구성되어 NUMA 거리 편차가 더 커집니다. 직접 연결된 노드(1홉)과 중간 노드를 경유하는 노드(2홉)의 지연 시간 차이가 크므로, 애플리케이션 배치 전략이 2소켓보다 훨씬 중요합니다.
NUMA Ratio와 영향
| 시스템 유형 | 인터커넥트 | 로컬 지연 (일반적 범위) | 리모트 지연 (일반적 범위) | NUMA Ratio |
|---|---|---|---|---|
| Intel 2S (Xeon Scalable) | UPI 2.0/3.0 | ~80ns | ~130-150ns | 1.6-1.9x |
| Intel 4S/8S | UPI (멀티홉) | ~80ns | ~170-300ns | 2.1-3.7x |
| AMD EPYC (2S) | Infinity Fabric | ~90ns | ~140-160ns | 1.5-1.8x |
| AMD EPYC (NPS4) | IF (소켓 내) | ~85ns | ~110-130ns | 1.3-1.5x |
| ARM64 서버 | CCIX/CXL | ~100ns | ~200-350ns | 2.0-3.5x |
| CXL 메모리 확장 | CXL 2.0 | ~80ns | ~170-250ns | 2.1-3.1x |
# NUMA 토폴로지와 거리 매트릭스 확인
$ numactl --hardware | grep -A10 "node distances"
node distances:
node 0 1
0: 10 21
1: 21 10
# NUMA Ratio 계산: 원격 거리 / 로컬 거리 = 21 / 10 = 2.1
# 2.0 이상이면 NUMA 최적화 필수, 1.5 이하면 영향 적음
# 커널의 NUMA 거리 정보 (sysfs)
$ cat /sys/devices/system/node/node0/distance
10 21
# 커널 부팅 시 NUMA 탐색 로그
$ dmesg | grep -i numa
[ 0.000000] SRAT: Node 0 PXM 0 [mem 0x00000000-0x7fffffff]
[ 0.000000] SRAT: Node 1 PXM 1 [mem 0x80000000-0xffffffff]
[ 0.000000] NUMA: Node 0 [mem 0x00000000-0x7fffffff] + ...
[ 0.000000] NUMA: Initmem setup node 0 [mem 0x00000000-...]
AMD EPYC NPS(Nodes Per Socket) 토폴로지
AMD EPYC 프로세서(Processor)는 CCD(Core Complex Die) 칩렛 아키텍처를 사용합니다. BIOS에서 NPS 설정을 변경하면 단일 소켓 내부의 NUMA 노드 수를 조절할 수 있습니다. NPS 값이 높을수록 로컬 메모리 범위는 줄어들지만 접근 지연 편차가 감소합니다.
| NPS 모드 | 소켓당 노드 수 | 노드당 메모리 채널 | 특징 | 적합한 워크로드 |
|---|---|---|---|---|
| NPS1 | 1 | 전체 (8 또는 12) | 최대 대역폭, 높은 지연 편차 | 단일 대형 프로세스 (JVM, 단일 DB 인스턴스) |
| NPS2 | 2 | 절반 (4 또는 6) | 대역폭/지역성 균형 | 범용 서버, 가상화(Virtualization) |
| NPS4 | 4 | 1/4 (2 또는 3) | 최소 지연 편차, 좁은 로컬 범위 | HPC, 지연 민감 워크로드 |
Intel SNC(Sub-NUMA Clustering)
Intel Xeon Scalable 프로세서도 SNC(Sub-NUMA Clustering) 기능으로 소켓 내부를 여러 NUMA 노드로 분할합니다. SNC2는 소켓을 2개, SNC4(4세대 이상)는 4개의 NUMA 노드로 분할합니다. AMD NPS와 개념은 유사하지만, Intel은 LLC(Last Level Cache) 슬라이스를 기준으로 분할합니다.
numactl 바인딩 스크립트, cgroup cpuset.mems 설정, IRQ 친화성(Affinity) 규칙을 모두 재검토해야 합니다. 변경 전후에 numactl --hardware로 토폴로지를 확인하세요.
ACPI를 통한 NUMA 토폴로지 검색
커널은 부팅 시 ACPI 테이블을 파싱하여 NUMA 토폴로지를 구성합니다. 핵심 테이블은 SRAT(Static Resource Affinity Table)와 SLIT(System Locality Information Table)입니다.
SRAT (Static Resource Affinity Table)
/*
* SRAT는 CPU와 메모리가 어떤 NUMA 노드에 속하는지 정의합니다.
*
* SRAT 하위 구조:
* - Processor Local APIC Affinity: CPU(APIC ID) → Node 매핑
* - Memory Affinity: 메모리 범위 → Node 매핑
* - Processor Local x2APIC Affinity: x2APIC CPU → Node 매핑
* - GICC Affinity: ARM64 CPU → Node 매핑
* - Generic Initiator Affinity: CXL 장치 등 → Node 매핑
*/
/* arch/x86/kernel/acpi/srat.c */
static int __init
acpi_parse_processor_affinity(union acpi_subtable_headers *header,
const unsigned long end)
{
struct acpi_srat_cpu_affinity *p =
(struct acpi_srat_cpu_affinity *)header;
int pxm = p->proximity_domain_lo |
(p->proximity_domain_hi[0] << 8) |
(p->proximity_domain_hi[1] << 16) |
(p->proximity_domain_hi[2] << 24);
/* proximity domain → NUMA node 매핑 등록 */
set_apicid_to_node(p->apic_id, pxm_to_node(pxm));
return 0;
}
static int __init
acpi_parse_memory_affinity(union acpi_subtable_headers *header,
const unsigned long end)
{
struct acpi_srat_mem_affinity *ma =
(struct acpi_srat_mem_affinity *)header;
u64 start = ma->base_address;
u64 length = ma->length;
int node = pxm_to_node(ma->proximity_domain);
/* 메모리 범위를 노드에 등록 */
numa_add_memblk(node, start, start + length);
return 0;
}
SLIT (System Locality Information Table)
/*
* SLIT는 노드 간 상대적 거리를 N×N 매트릭스로 정의합니다.
* 거리 10 = 자기 자신 (로컬), 값이 클수록 먼 노드
*
* 예시: 4-노드 시스템 SLIT 매트릭스
* Node0 Node1 Node2 Node3
* Node0: 10 21 31 41
* Node1: 21 10 21 31
* Node2: 31 21 10 21
* Node3: 41 31 21 10
*/
/* drivers/acpi/numa/srat.c */
void __init acpi_numa_slit_init(struct acpi_table_slit *slit)
{
int i, j;
for (i = 0; i < slit->locality_count; i++)
for (j = 0; j < slit->locality_count; j++)
numa_set_distance(
pxm_to_node(i),
pxm_to_node(j),
slit->entry[i * slit->locality_count + j]);
}
HMAT (Heterogeneous Memory Attribute Table)
HMAT는 ACPI 6.2에서 도입된 테이블로, 각 메모리 초기자(Initiator, 예: CPU)와 메모리 대상(Target, 예: DRAM, CXL) 간의 지연 시간(Latency)과 대역폭(Bandwidth)을 구체적인 수치로 제공합니다. SLIT가 상대적 거리만 제공하는 반면, HMAT는 절대적인 성능 특성을 전달합니다.
/*
* HMAT 주요 구조:
*
* 1. Memory Proximity Domain Attributes
* - 각 노드의 메모리 특성 (읽기/쓰기 지연, 대역폭)
*
* 2. System Locality Latency and Bandwidth Information
* - 이니시에이터 → 타겟 간 접근 성능 매트릭스
* - 유형: Access, Read, Write 각각 별도
*
* 3. Memory Side Cache Information
* - CXL 장치 등의 메모리 사이드 캐시 정보
*
* 커널 파싱: drivers/acpi/numa/hmat.c
* → hmat_parse_proximity_domain()
* → hmat_parse_locality()
* → hmat_parse_cache()
*/
/* drivers/acpi/numa/hmat.c — HMAT 지연/대역폭 파싱 */
static int __init hmat_parse_locality(
union acpi_subtable_headers *header,
const unsigned long end)
{
struct acpi_hmat_locality *loc =
(struct acpi_hmat_locality *)header;
/* data_type: 0=Access, 1=Read, 2=Write */
/* min_transfer_size, 대상 노드 수, 이니시에이터 수 */
for (initiator ...) {
for (target ...) {
u16 value = entries[initiator * targets + target];
/* value는 나노초(latency) 또는 MB/s(bandwidth) */
hmat_update_target_access(target, initiator,
loc->data_type, value);
}
}
return 0;
}
# HMAT 정보 확인 (sysfs)
# 각 노드의 접근 성능 속성
$ ls /sys/devices/system/node/node0/access0/initiators/
read_bandwidth read_latency write_bandwidth write_latency
# Node 0에서 자기 자신(DRAM) 접근
$ cat /sys/devices/system/node/node0/access0/initiators/read_latency
80 # 80ns
$ cat /sys/devices/system/node/node0/access0/initiators/read_bandwidth
51200 # 51.2 GB/s
# CXL 노드 (Node 2)의 접근 성능
$ cat /sys/devices/system/node/node2/access0/initiators/read_latency
170 # 170ns (CXL 경유)
$ cat /sys/devices/system/node/node2/access0/initiators/read_bandwidth
32000 # 32 GB/s
# dmesg에서 HMAT 파싱 결과 확인
$ dmesg | grep -i hmat
ACPI: HMAT: Memory Proximity Domain Attributes: PXM=0 Init=0
ACPI: HMAT: Locality: Flags=0 Type=Access Initiator Domains=2 Target Domains=3
ACPI: HMAT: Initiator=0 Target=0 Read Latency=80ns
ACPI: HMAT: Initiator=0 Target=2 Read Latency=170ns
CONFIG_MEMORY_TIER)는 HMAT의 지연/대역폭 정보를 기반으로 메모리 노드를 자동으로 티어(빠른/느린)로 분류합니다. HMAT가 없는 시스템에서는 /sys/devices/system/node/nodeN/memtier를 수동으로 설정해야 합니다.
토폴로지 확인 명령
# NUMA 노드 목록
$ ls /sys/devices/system/node/
node0 node1
# 노드별 CPU 매핑
$ cat /sys/devices/system/node/node0/cpulist
0-7,16-23
$ cat /sys/devices/system/node/node1/cpulist
8-15,24-31
# 노드 간 거리 매트릭스
$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23
node 0 size: 65366 MB
node 0 free: 48230 MB
node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31
node 1 size: 65536 MB
node 1 free: 51200 MB
node distances:
node 0 1
0: 10 21
1: 21 10
# SRAT 정보 (dmesg)
$ dmesg | grep -i srat
ACPI: SRAT: Node 0 PXM 0 [mem 0x00000000-0x0fffffff]
ACPI: SRAT: Node 0 PXM 0 [mem 0x100000000-0xfffffffff]
ACPI: SRAT: Node 1 PXM 1 [mem 0x1000000000-0x1fffffffff]
# lstopo (hwloc)로 시각적 토폴로지 확인
$ lstopo --of txt
Machine (128GB total)
NUMANode L#0 (P#0 64GB)
Package L#0
L3 L#0 (30MB)
L2 L#0 (256KB) + L1d L#0 (32KB) + L1i L#0 (32KB) + Core L#0
PU L#0 (P#0)
PU L#1 (P#16)
...
NUMANode L#1 (P#1 64GB)
...
커널 자료구조: pglist_data
각 NUMA 노드는 struct pglist_data (별칭 pg_data_t)로 표현됩니다. 이 구조체는 노드의 메모리 존, 페이지 프레임(Page Frame), 통계 정보를 모두 관리합니다.
/* include/linux/mmzone.h (주요 필드 발췌) */
typedef struct pglist_data {
/* ---- 존 정보 ---- */
struct zone node_zones[MAX_NR_ZONES]; /* 노드 내 존 배열 */
struct zonelist node_zonelists[MAX_ZONELISTS]; /* 할당 폴백 순서 */
int nr_zones; /* 활성 존 수 */
/* ---- 페이지 프레임 ---- */
struct page *node_mem_map; /* 노드의 struct page 배열 */
unsigned long node_start_pfn; /* 시작 페이지 프레임 번호 */
unsigned long node_present_pages; /* 실제 존재하는 페이지 수 */
unsigned long node_spanned_pages; /* 시작~끝 범위 (hole 포함) */
int node_id; /* 이 노드의 번호 */
/* ---- 페이지 회수 (reclaim) ---- */
wait_queue_head_t kswapd_wait; /* kswapd 대기 큐 */
wait_queue_head_t pfmemalloc_wait;
struct task_struct *kswapd; /* 이 노드의 kswapd 스레드 */
int kswapd_order;
enum zone_type kswapd_highest_zoneidx;
/* ---- LRU 리스트 (페이지 에이징) ---- */
struct lruvec __lruvec; /* 노드 단위 LRU 벡터 */
/* ---- 통계 ---- */
unsigned long totalreserve_pages;
struct per_cpu_nodestat __percpu *per_cpu_nodestats;
/* ---- Compaction ---- */
unsigned long compact_cached_free_pfn;
unsigned long compact_cached_migrate_pfn[ASYNC_AND_SYNC];
/* ---- NUMA Balancing ---- */
spinlock_t numabalancing_migrate_lock;
unsigned long numabalancing_migrate_nr_pages;
unsigned long numabalancing_migrate_next_window;
} pg_data_t;
/* 전역 노드 배열 */
extern struct pglist_data *node_data[];
#define NODE_DATA(nid) (node_data[nid])
Zonelist — 할당 폴백 순서
/*
* 메모리 할당 시 존리스트(zonelist)를 따라 폴백합니다.
*
* Node 0의 zonelist[ZONELIST_FALLBACK]:
* Node0:ZONE_NORMAL → Node0:ZONE_DMA32 → Node0:ZONE_DMA
* → Node1:ZONE_NORMAL → Node1:ZONE_DMA32 → Node1:ZONE_DMA
*
* 순서: 로컬 노드 우선 → 거리가 가까운 노드 순
* SLIT 거리 기반으로 정렬됨
*/
struct zonelist {
struct zoneref _zonerefs[MAX_ZONES_PER_ZONELIST + 1];
};
struct zoneref {
struct zone *zone; /* 존 포인터 */
int zone_idx; /* 존 인덱스 */
};
/* zonelist 탐색 매크로 */
for_each_zone_zonelist(zone, z, zonelist, highest_zoneidx) {
/* 로컬 노드부터 리모트 노드까지 순서대로 시도 */
page = rmqueue(zone, order, gfp_mask);
if (page)
return page;
}
노드 통계 확인
# 노드별 메모리 통계 (/sys/devices/system/node/node*/meminfo)
$ cat /sys/devices/system/node/node0/meminfo
Node 0 MemTotal: 65536000 kB
Node 0 MemFree: 48230000 kB
Node 0 MemUsed: 17306000 kB
Node 0 Active: 8120000 kB
Node 0 Inactive: 6450000 kB
Node 0 AnonPages: 5230000 kB
Node 0 FilePages: 9340000 kB
Node 0 Slab: 1530000 kB
Node 0 SReclaimable: 1200000 kB
...
# 노드별 존 정보
$ cat /proc/zoneinfo | grep -A3 "Node 0"
Node 0, zone Normal
pages free 12057500
min 16384
low 20480
# numastat — 노드별 NUMA 적중/미스 통계
$ numastat
node0 node1
numa_hit 142857391 98234567
numa_miss 1234567 2345678
numa_foreign 2345678 1234567
interleave_hit 3456789 3456789
local_node 139400824 95888889
other_node 4690134 4690356
NUMA 메모리 정책
Linux 커널은 프로세스와 메모리 영역별로 NUMA 메모리 할당 정책을 지정할 수 있습니다. 이는 set_mempolicy(), mbind() 시스템 콜(System Call)과 numactl 도구로 제어합니다.
numactl --membind=0 ./app을 실행하면 app이 사용하는 메모리는 반드시 Node 0에서만 할당됩니다. 정책을 지정하지 않으면 커널은 현재 CPU와 같은 노드(로컬)에서 우선 할당을 시도합니다. 네 가지 핵심 정책:
- DEFAULT: 상위 정책(태스크 → 시스템)으로 폴백.
mbind()로 지정한 VMA 정책 해제 시 사용 - BIND: 지정 노드에서만 강제 할당. 해당 노드 메모리 부족 시 OOM 발생. 완벽한 메모리 격리 목적
- PREFERRED: 선호 노드를 지정하되, 부족하면 다른 노드로 폴백. 소프트 바인딩
- INTERLEAVE: 지정 노드들에 라운드-로빈으로 분산 할당. 전체 대역폭 극대화(대형 해시 테이블 등)
정책 유형
| 정책 | 상수 | 동작 | 용도 |
|---|---|---|---|
| Default | MPOL_DEFAULT | 명시 정책을 제거하고 다음으로 구체적인 범위로 폴백 | task/VMA 정책 해제, system default 복귀 |
| Bind | MPOL_BIND | 지정된 노드 집합에서만 할당 (실패 시 OOM) | 메모리 격리(Isolation), 전용 노드 |
| Preferred | MPOL_PREFERRED | 선호 노드에서 우선 할당, 실패 시 다른 노드 폴백 | 소프트 바인딩 |
| Preferred Many | MPOL_PREFERRED_MANY | 지정 nodemask 내에서 우선 만족시키고 부족하면 다른 노드 폴백 | 유연한 선호 (v5.15+) |
| Interleave | MPOL_INTERLEAVE | 익명/공유 메모리는 page offset, page cache는 task 카운터 기준 분산 | 대역폭 극대화, 해시 테이블(Hash Table) |
| Local | MPOL_LOCAL | 폴백이 아니라 명시적으로 local allocation을 선택 | MPOL_DEFAULT와 구분되는 명시적 로컬 할당 |
| Weighted Interleave | MPOL_WEIGHTED_INTERLEAVE | MPOL_INTERLEAVE와 같은 경로에서 가중치 비율만 다르게 적용 | CXL 이종 메모리, 비대칭 대역폭 환경 (v6.9+) |
정책 범위와 재바인딩
mempolicy는 단일 전역 설정이 아닙니다.
커널은 정책 범위(Policy Scope)를 system default, task policy, VMA policy, shared policy로 나누어 해석하며,
어떤 범위가 실제로 적용되는지는 매핑 유형과 cpuset 제약에 따라 달라집니다.
특히 regular file의 MAP_SHARED page cache는 VMA 정책을 직접 따르지 않는다는 점을
명시적으로 이해해야 합니다.
| 항목 | 핵심 의미 | 실전 주의점 |
|---|---|---|
MPOL_F_STATIC_NODES | cpuset 허용 노드가 바뀌어도 사용자 nodemask를 상대 재해석하지 않습니다. | 허용 노드와 교집합이 비면 정책이 사실상 무력화되고 default/local 폴백처럼 보일 수 있습니다. |
MPOL_F_RELATIVE_NODES | 사용자 nodemask를 현재 cpuset에 대한 상대 위치로 해석합니다. | 컨테이너(Container)나 cpuset이 자주 바뀌는 환경에서 더 예측 가능하지만, 실제 물리 노드 번호와 1:1 대응된다고 보면 안 됩니다. |
MPOL_F_NUMA_BALANCING | MPOL_BIND와 함께 NUMA balancing을 허용하는 플래그입니다. | 모든 mode에 붙는 범용 플래그가 아닙니다. 잘못 조합하면 EINVAL이 납니다. |
| home node | 기존 VMA 정책 범위에 대해 할당 시작점을 더 가까운 노드로 조정합니다. | set_mempolicy_home_node()는 새 정책 생성이 아니라 기존 VMA policy 범위의 home node만 갱신합니다. |
시스템 콜
#include <numaif.h>
/* 프로세스 전체 NUMA 정책 설정 */
long set_mempolicy(
int mode, /* MPOL_DEFAULT, MPOL_BIND, ... */
const unsigned long *nodemask, /* 대상 노드 비트마스크 */
unsigned long maxnode /* nodemask 비트 수 */
);
/* 특정 메모리 영역의 NUMA 정책 설정 */
long mbind(
void *addr, /* 시작 주소 (페이지 정렬) */
unsigned long len, /* 길이 */
int mode, /* MPOL_BIND, MPOL_INTERLEAVE, ... */
const unsigned long *nodemask,
unsigned long maxnode,
unsigned int flags /* MPOL_MF_MOVE, MPOL_MF_STRICT, ... */
);
/* 현재 task policy, 특정 주소의 policy, 또는 노드 위치 조회 */
long get_mempolicy(
int *policy,
unsigned long *nodemask,
unsigned long maxnode,
void *addr,
unsigned long flags /* MPOL_F_NODE, MPOL_F_ADDR */
);
/* 페이지를 다른 노드로 마이그레이션 */
long migrate_pages(
pid_t pid,
unsigned long maxnode,
const unsigned long *old_nodes, /* 원본 노드 */
const unsigned long *new_nodes /* 대상 노드 */
);
/* 개별 페이지 단위 마이그레이션 */
long move_pages(
pid_t pid,
unsigned long count,
void **pages, /* 페이지 주소 배열 */
const int *nodes, /* 대상 노드 배열 (NULL이면 조회) */
int *status, /* 결과/현재 노드 */
int flags
);
set_mempolicy()는 task default policy를 바꾸고,
mbind()는 주소 범위의 VMA/shared policy를 설정합니다.
get_mempolicy()는 현재 task policy를 복원 가능한 형태로 읽거나,
플래그 조합에 따라 특정 주소의 policy 또는 현재 노드 위치를 조회하는 API입니다.
numactl 사용
# 특정 노드에서만 메모리 할당 (bind)
$ numactl --membind=0 ./my_application
# 특정 CPU에서 실행 + 해당 노드의 메모리 사용
$ numactl --cpunodebind=0 --membind=0 ./my_application
# 인터리브 모드 (모든 노드에 분산)
$ numactl --interleave=all ./hash_table_server
# 선호 노드 지정 (폴백 허용)
$ numactl --preferred=1 ./my_application
# 현재 실행 중인 프로세스의 NUMA 매핑 확인
$ numastat -p <pid>
Per-node process memory usage (in MBs) for PID <pid>
Node 0 Node 1 Total
--------- --------- -----------
Huge 0.00 0.00 0.00
Heap 256.50 12.30 268.80
Stack 0.12 0.00 0.12
Private 1024.00 48.00 1072.00
...
# 프로세스의 NUMA 메모리 맵 (/proc/PID/numa_maps)
$ cat /proc/self/numa_maps
00400000 default file=/usr/bin/cat mapped=10 N0=10
7f8a1000 default anon=3 dirty=3 N0=2 N1=1
7ffd2000 default stack anon=2 dirty=2 N0=2
커널 내부 NUMA 할당 API
/* NUMA-aware 커널 메모리 할당 */
/* 특정 노드에서 페이지 할당 */
struct page *alloc_pages_node(int nid, gfp_t gfp, unsigned int order);
/* 현재 CPU의 로컬 노드에서 할당 */
struct page *alloc_pages(gfp_t gfp, unsigned int order);
/* 특정 노드에서 slab 할당 */
void *kmalloc_node(size_t size, gfp_t flags, int node);
void *kzalloc_node(size_t size, gfp_t flags, int node);
void *kvmalloc_node(size_t size, gfp_t flags, int node);
/* kmem_cache에서 특정 노드 할당 */
void *kmem_cache_alloc_node(struct kmem_cache *s, gfp_t flags, int node);
/* GFP 플래그로 NUMA 제어 */
__GFP_THISNODE /* 지정된 노드에서만 할당 (폴백 금지) */
/* 현재 CPU의 NUMA 노드 번호 */
int nid = numa_node_id(); /* 현재 CPU의 노드 */
int nid = cpu_to_node(cpu); /* 특정 CPU의 노드 */
int nid = page_to_nid(page); /* 페이지가 속한 노드 */
/* 디바이스의 NUMA 노드 (PCIe 장치 등) */
int nid = dev_to_node(dev); /* 장치에 가장 가까운 노드 */
/* 예시: NIC의 로컬 노드에 sk_buff 할당 */
int nid = dev_to_node(&netdev->dev);
skb = __alloc_skb(size, GFP_ATOMIC, 0, nid);
Automatic NUMA Balancing
Automatic NUMA Balancing은 커널이 자동으로 프로세스의 메모리를 적절한 NUMA 노드로 마이그레이션하는 메커니즘입니다. 사용자 공간(User Space)의 개입 없이 NUMA 지역성을 최적화합니다.
echo 0 > /proc/sys/kernel/numa_balancing) 후 수동 배치(numactl)가 더 적합합니다.
동작 원리
Automatic NUMA Balancing은 4단계 파이프라인(Pipeline)으로 동작합니다. 스캔 → 폴트 → 판단 → 마이그레이션 순서로, 커널이 자동으로 페이지와 태스크(Task)의 NUMA 배치를 최적화합니다.
NUMA Hinting Fault
/* mm/memory.c — NUMA hinting fault 처리 */
static vm_fault_t do_numa_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct page *page;
int page_nid, target_nid, last_cpupid;
bool migrated;
/* 현재 PTE 복원 (PROT_NONE → 원래 권한) */
pte = pte_modify(old_pte, vma->vm_page_prot);
page = vm_normal_page(vma, vmf->address, pte);
page_nid = page_to_nid(page); /* 페이지의 현재 노드 */
target_nid = numa_migrate_prep(page, vma, vmf->address,
page_nid, &flags);
if (target_nid == NUMA_NO_NODE) {
/* 마이그레이션 불필요: 이미 최적 위치 */
put_page(page);
goto out;
}
/* 페이지를 target_nid로 마이그레이션 시도 */
migrated = migrate_misplaced_page(page, vma, target_nid);
if (migrated)
page_nid = target_nid;
out:
/* NUMA 폴트 통계 업데이트 */
if (page_nid != NUMA_NO_NODE)
task_numa_fault(last_cpupid, page_nid,
1, flags);
return 0;
}
튜닝 파라미터
# Automatic NUMA Balancing 활성/비활성
$ sysctl kernel.numa_balancing
kernel.numa_balancing = 1 # 1=활성, 0=비활성
# 스캔 주기 (ms) — 폴트가 없으면 점점 늘어남
$ sysctl kernel.numa_balancing_scan_delay_ms
kernel.numa_balancing_scan_delay_ms = 1000 # 초기 스캔 지연
$ sysctl kernel.numa_balancing_scan_period_min_ms
kernel.numa_balancing_scan_period_min_ms = 1000 # 최소 스캔 주기
$ sysctl kernel.numa_balancing_scan_period_max_ms
kernel.numa_balancing_scan_period_max_ms = 60000 # 최대 스캔 주기
# 한 번에 스캔할 페이지 수
$ sysctl kernel.numa_balancing_scan_size_mb
kernel.numa_balancing_scan_size_mb = 256 # MB 단위
# 프로세스별 NUMA 폴트 통계
$ cat /proc/<pid>/sched | grep numa
numa_pages_migrated : 12345
numa_preferred_nid : 0
total_numa_faults : 67890
numactl --membind로 메모리를 명시적으로 바인딩한 경우, 2) 대규모 인메모리 DB(Redis, memcached)에서 마이그레이션 오버헤드(Overhead)가 크면 성능 저하, 3) 실시간(Real-time)(RT) 워크로드에서 NUMA fault의 지연 시간 편차가 허용 불가, 4) KVM 게스트에서 호스트의 NUMA balancing과 충돌할 수 있음.
NUMA-aware 스케줄링
CFS 스케줄러(Scheduler)는 NUMA 토폴로지를 인식하여 태스크를 배치합니다. 스케줄링 도메인(sched_domain) 계층이 NUMA 거리를 반영합니다.
스케줄링 도메인 계층
스케줄링 도메인의 전체 계층 구조(SMT → MC → CL → DIE → NUMA), SD 플래그 상세, EAS(Energy Aware Scheduling) 등은 CPU 토폴로지 — 스케줄링 도메인에서 상세히 다룹니다.
태스크 선호 노드
/* kernel/sched/fair.c — NUMA 폴트 기반 선호 노드 결정 */
/*
* task_struct에서 NUMA 관련 필드:
*/
struct task_struct {
...
int numa_preferred_nid; /* 선호 노드 */
unsigned long numa_scan_seq; /* 스캔 시퀀스 */
unsigned long numa_scan_period; /* 현재 스캔 주기 */
unsigned long numa_scan_offset; /* 스캔 오프셋 */
struct numa_group *numa_group; /* NUMA 그룹 */
unsigned long *numa_faults; /* 노드별 폴트 카운터 */
unsigned long total_numa_faults;
unsigned long numa_pages_migrated;
...
};
/*
* task_numa_fault()가 폴트 이력을 분석하여
* 가장 많은 메모리 접근이 발생하는 노드를
* numa_preferred_nid로 설정합니다.
*
* CFS의 wake_affine()과 find_idlest_group()은
* numa_preferred_nid를 참고하여 태스크 배치를 결정합니다.
*/
NUMA 그룹
/*
* NUMA 그룹은 메모리를 공유하는 태스크들을 묶어서
* 함께 같은 노드로 마이그레이션합니다.
*
* 예: 멀티스레드 애플리케이션의 스레드들이
* 같은 공유 메모리를 접근하면 하나의 NUMA 그룹으로 묶임
* → 스케줄러가 그룹 전체를 같은 노드에 배치하려 함
*/
struct numa_group {
refcount_t refcount;
spinlock_t lock;
int nr_tasks; /* 그룹 내 태스크 수 */
pid_t gid; /* 그룹 ID */
int active_nodes; /* 활성 노드 수 */
struct rcu_head rcu;
unsigned long total_faults;
unsigned long max_faults_cpu;
unsigned long faults[]; /* 노드별 집계된 폴트 */
};
페이지 마이그레이션
NUMA 페이지 마이그레이션은 페이지를 한 노드에서 다른 노드로 이동시키는 메커니즘입니다. NUMA balancing, migrate_pages() 시스템 콜, 메모리 핫플러그(Hotplug) 등에서 사용됩니다.
마이그레이션 흐름
/*
* 페이지 마이그레이션 단계:
*
* 1. 대상 노드에 새 페이지 할당
* 2. 원본 페이지 잠금 (lock_page)
* 3. 모든 PTE에서 원본 페이지 언매핑 (try_to_migrate)
* - PTE를 migration entry로 교체
* - 이 동안 접근하는 프로세스는 대기
* 4. 페이지 데이터 복사 (migrate_folio / copy_highpage)
* 5. 새 페이지로 PTE 재매핑 (remove_migration_ptes)
* 6. 원본 페이지 해제
*/
/* mm/migrate.c — 핵심 마이그레이션 함수 (간략화) */
static int migrate_folio_move(
free_folio_t put_new_folio,
unsigned long private,
struct folio *src,
struct folio *dst,
enum migrate_mode mode)
{
int rc;
/* 1. 원본 folio 잠금 */
folio_lock(src);
/* 2. 모든 매핑에서 PTE 제거 (migration entry 설치) */
try_to_migrate(src, TTU_BATCH_FLUSH);
/* 3. 파일시스템/드라이버의 migrate 콜백 호출 */
rc = move_to_new_folio(dst, src, mode);
if (rc == MIGRATEPAGE_SUCCESS) {
/* 4. migration entry를 새 folio의 PTE로 교체 */
remove_migration_ptes(src, dst, false);
}
folio_unlock(src);
return rc;
}
마이그레이션 도구
# 프로세스의 모든 페이지를 node0에서 node1로 이동
$ migratepages <pid> 0 1
# 특정 메모리 영역을 다른 노드로 바인딩 (기존 페이지도 이동)
$ numactl --membind=1 --touch ./app
# 또는 mbind() + MPOL_MF_MOVE 플래그
# 마이그레이션 통계 확인
$ cat /proc/vmstat | grep numa
numa_pte_updates 1234567 # NUMA hinting fault용 PTE 변경 수
numa_huge_pte_updates 12345 # hugepage PTE 변경 수
numa_hint_faults 567890 # NUMA hinting fault 발생 수
numa_hint_faults_local 456789 # 로컬 노드 접근 (이동 불필요)
numa_pages_migrated 98765 # 마이그레이션된 페이지 수
pgmigrate_success 98000 # 성공한 마이그레이션
pgmigrate_fail 765 # 실패한 마이그레이션
NUMA-aware 서브시스템
커널의 주요 서브시스템은 NUMA를 인식하여 데이터를 로컬 노드에 배치합니다.
Slab 할당기 (SLUB)
/*
* SLUB 할당기는 노드별로 독립적인 partial slab 리스트를 유지합니다.
*
* struct kmem_cache_node {
* spinlock_t list_lock;
* unsigned long nr_partial; // 부분 사용 slab 수
* struct list_head partial; // partial slab 리스트
* };
*
* kmem_cache는 노드별 kmem_cache_node를 배열로 관리:
* kmem_cache->node[MAX_NUMNODES]
*
* 할당 흐름:
* 1. 현재 CPU의 per-cpu slab에서 시도 (가장 빠름)
* 2. 현재 노드의 partial 리스트에서 시도
* 3. 다른 노드의 partial 리스트에서 시도 (cross-node)
* 4. 새 slab 페이지 할당 (현재 노드 우선)
*/
# 노드별 slab 통계
$ cat /proc/slabinfo # 전체 통계
$ slabinfo -N 0 # node0의 slab 정보
# slabtop으로 실시간 모니터링
$ slabtop -s c
Per-CPU 변수와 NUMA
/*
* Per-CPU 변수는 각 CPU의 로컬 NUMA 노드에 할당됩니다.
* pcpu_alloc()이 cpu_to_node()를 사용하여 적절한 노드에 메모리 배치.
*
* 부팅 시 per-cpu 영역 초기화:
* setup_per_cpu_areas() → pcpu_embed_first_chunk()
* → 각 CPU에 대해 해당 노드의 메모리에 per-cpu 영역 할당
*/
/* 올바른 NUMA-aware per-CPU 사용 */
DEFINE_PER_CPU(struct my_stats, cpu_stats);
/* 접근 시 preemption 비활성화 필수 (다른 CPU로 이동 방지) */
preempt_disable();
this_cpu_inc(cpu_stats.counter); /* 로컬 NUMA 노드 접근 보장 */
preempt_enable();
/* NUMA-aware workqueue */
alloc_workqueue("my_wq", WQ_UNBOUND | WQ_NUMA, 0);
/* WQ_NUMA: work item을 제출한 CPU의 NUMA 노드에서 실행 */
네트워크 스택(Network Stack)과 NUMA
/* NIC의 NUMA 노드 확인 */
$ cat /sys/class/net/eth0/device/numa_node
0
/* IRQ를 NIC의 로컬 노드 CPU에 바인딩 (최적 성능) */
# NIC가 node0에 있으면 node0의 CPU에 IRQ 할당
$ echo 0-7 > /proc/irq/<irq>/smp_affinity_list
/* NUMA-aware sk_buff 할당 */
/* 네트워크 드라이버에서 NIC 로컬 노드의 메모리로 sk_buff 할당:
* dev_alloc_skb() → __netdev_alloc_skb()
* → 현재 CPU의 page_frag 캐시 사용
* → NAPI 컨텍스트에서는 NIC의 로컬 노드에서 할당
*/
# ethtool로 RX/TX 큐 확인
$ ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:
RX: 0
TX: 0
Other: 0
Combined: 32
Current hardware settings:
Combined: 16
# 각 큐의 IRQ가 NIC 로컬 노드에 바인딩되었는지 확인
$ for irq in $(grep eth0 /proc/interrupts | awk '{print $1}' | tr -d ':'); do
echo "IRQ $irq: node $(cat /proc/irq/$irq/node)"
done
NUMA와 메모리 회수(Memory Reclaim)
각 NUMA 노드는 자체 kswapd 스레드(Thread)를 가지며, 노드별로 독립적으로 메모리 회수(reclaim)가 수행됩니다.
/*
* NUMA 노드별 kswapd:
*
* Node 0: kswapd0 → node0의 존들을 모니터링/회수
* Node 1: kswapd1 → node1의 존들을 모니터링/회수
*
* 각 kswapd는 자기 노드의 워터마크를 기준으로 동작:
* - pages_free < watermark_low → kswapd 깨어남
* - pages_free > watermark_high → kswapd 슬립
*
* 문제: Node 0이 메모리 부족해도 Node 1에 충분하면
* 시스템 전체로는 여유가 있지만 Node 0에서 OOM 발생 가능
*/
# 노드별 kswapd 확인
$ ps aux | grep kswapd
root 31 0.0 0.0 0 0 S ? 0:05 [kswapd0]
root 32 0.0 0.0 0 0 S ? 0:03 [kswapd1]
# 노드별 워터마크 확인
$ cat /proc/zoneinfo | grep -A5 "Node 0, zone Normal"
Node 0, zone Normal
pages free 12057500
boost 0
min 16384
low 20480
high 24576
Zone Reclaim Mode
# zone_reclaim_mode — 로컬 노드 메모리 부족 시 동작 제어
$ sysctl vm.zone_reclaim_mode
vm.zone_reclaim_mode = 0
# 비트 플래그:
# 0 (기본): 로컬 노드 부족 시 리모트 노드에서 할당 (대부분의 경우 최적)
# 1 (RECLAIM_ZONE): 로컬 존에서 페이지 회수 시도
# 2 (RECLAIM_WRITE): 더티 페이지 쓰기 후 회수
# 4 (RECLAIM_UNMAP): 매핑된 페이지도 언매핑 후 회수
# zone_reclaim_mode=1이 유리한 경우:
# - NUMA ratio가 매우 큰 시스템 (3x 이상)
# - 메모리 접근 패턴이 극도로 로컬한 워크로드
# - 파일 캐시보다 애플리케이션 데이터가 중요한 경우
# 대부분의 경우 zone_reclaim_mode=0이 권장됨:
# - 리모트 노드 할당 비용 < 페이지 회수 비용
# - 파일 캐시 유지가 전체 성능에 유리
NUMA-aware I/O
PCIe 장치(NVMe SSD, GPU, NIC 등)는 특정 NUMA 노드에 물리적으로 연결됩니다. I/O 요청이 장치와 다른 NUMA 노드에서 발생하면 인터커넥트를 경유하여 지연이 증가하고 대역폭이 감소합니다. 고성능 I/O 워크로드에서는 장치의 NUMA 친화성(Affinity)을 반드시 고려해야 합니다.
NVMe와 NUMA
# NVMe 장치의 NUMA 노드 확인
$ cat /sys/block/nvme0n1/device/numa_node
0
$ cat /sys/block/nvme1n1/device/numa_node
1
# NVMe 큐의 IRQ가 올바른 노드에 바인딩되었는지 확인
$ for irq in $(grep nvme0 /proc/interrupts | awk '{print $1}' | tr -d ':'); do
echo "IRQ $irq → node $(cat /proc/irq/$irq/node), CPUs: $(cat /proc/irq/$irq/smp_affinity_list)"
done
# 최적 설정: NVMe 큐당 1개 IRQ, 로컬 노드 CPU에 매핑
# io_uring/fio에서 NUMA 인식 I/O 테스트
$ numactl --cpunodebind=0 --membind=0 fio \
--ioengine=io_uring --direct=1 --bs=4k --iodepth=128 \
--filename=/dev/nvme0n1 --rw=randread --numjobs=8
# 비교: --cpunodebind=1 (리모트 노드)로 실행하면 IOPS 10-30% 감소
블록 장치(Block Device) 레이어와 NUMA
/*
* 블록 장치의 NUMA-aware 동작:
*
* 1. blk-mq (Multi-Queue Block Layer):
* - 하드웨어 큐(hctx)가 CPU별로 매핑
* - hctx->numa_node로 큐의 NUMA 친화성 설정
* - 요청 메모리를 큐의 로컬 노드에서 할당
*
* 2. I/O 스케줄러 (mq-deadline, bfq, kyber):
* - 요청 병합/정렬에 NUMA 고려 없음 (장치 레벨 최적화)
* - 그러나 I/O 제출 CPU의 NUMA 위치가 성능에 영향
*
* 3. Page Cache와 NUMA:
* - 파일 읽기 시 page cache는 읽기 CPU의 로컬 노드에 할당
* - 여러 노드에서 같은 파일 접근 시 NUMA balancing 가능
*/
/* block/blk-mq.c — NUMA-aware 하드웨어 큐 매핑 */
static int blk_mq_hw_ctx_set_numa_node(
struct blk_mq_hw_ctx *hctx,
struct blk_mq_tag_set *set)
{
/* 장치의 NUMA 노드에 큐를 매핑 */
hctx->numa_node = set->numa_node;
/* 큐의 요청 메모리를 이 노드에서 할당 */
return 0;
}
# blk-mq NUMA 설정 확인
$ cat /sys/block/nvme0n1/queue/numa_node
0
irqbalance 데몬은 기본적으로 NUMA 토폴로지를 인식하여 IRQ를 장치의 로컬 노드에 배분합니다. 그러나 고성능 환경에서는 irqbalance를 비활성화하고 수동으로 /proc/irq/*/smp_affinity를 설정하는 것이 더 안정적입니다. 특히 DPDK나 SPDK를 사용하는 경우 IRQ 바인딩을 직접 관리해야 합니다.
CXL과 이종 NUMA
CXL(Compute Express Link)은 CPU에 외부 메모리를 연결하여 NUMA 노드로 노출하는 새로운 인터커넥트 기술입니다. CXL 메모리는 로컬 DRAM보다 지연이 크지만, 용량 확장과 비용 효율성을 제공합니다.
CXL 메모리 토폴로지
# CXL 메모리 노드 확인
$ dmesg | grep cxl
cxl_acpi ACPI0017:00: CXL region registered
cxl_mem mem0: CXL Type 3 device, 256 GB
# CXL 노드의 HMAT (Heterogeneous Memory Attribute Table) 정보
$ cat /sys/devices/system/node/node2/access0/initiators/read_latency
170
$ cat /sys/devices/system/node/node2/access0/initiators/read_bandwidth
32000
Weighted Interleave (v6.9+)
/*
* CXL처럼 노드 간 대역폭이 다른 이종 NUMA 환경에서는
* 단순 라운드-로빈 인터리브가 비효율적입니다.
*
* Weighted Interleave는 노드별 대역폭 비율에 맞춰
* 페이지를 가중 분산합니다.
*
* 예: Node0 (DRAM, 50GB/s) + Node2 (CXL, 16GB/s)
* 가중치: Node0=3, Node2=1
* → 4페이지 중 3페이지는 Node0, 1페이지는 Node2에 할당
*/
# 가중치 설정 (sysfs)
$ echo 3 > /sys/kernel/mm/mempolicy/weighted_interleave/node0
$ echo 1 > /sys/kernel/mm/mempolicy/weighted_interleave/node2
# 프로세스에 weighted interleave 정책 적용
# set_mempolicy(MPOL_WEIGHTED_INTERLEAVE, nodemask, maxnode)
$ numactl --weighted-interleave=0,2 ./memory_intensive_app
MPOL_WEIGHTED_INTERLEAVE는 메모리 정책(Memory Policy) 관점에서
MPOL_INTERLEAVE의 상위 호환입니다.
차이는 "다음 노드를 어떤 비율로 고를지"를 단순 라운드-로빈이 아니라
sysfs 가중치로 선택한다는 점뿐이며,
익명 페이지(Anonymous Page)와 공유 메모리(Shared Memory)는
faulting address의 페이지 오프셋(Offset)을 기준으로,
page cache는 태스크별 카운터를 기준으로 분산된다는 큰 구조는 그대로 유지됩니다.
| 할당 대상 | MPOL_INTERLEAVE | WEIGHTED_INTERLEAVE | 운영 해석 |
|---|---|---|---|
| 익명 페이지 / 공유 메모리 | faulting address의 페이지 오프셋을 nodemask에 라운드-로빈 적용 | 같은 오프셋 경로를 사용하되 노드 선택 비율만 가중치로 조정 | 대형 배열, CXL 혼합 메모리처럼 주소 범위가 뚜렷한 워크로드에서 해석이 쉽습니다. |
| page cache | 태스크별 interleave 카운터로 노드 순환 | 같은 카운터 경로를 사용하되 비율만 가중치에 맞춰 변경 | 파일 읽기 순서, readahead, 제출 스레드 수에 따라 분산 모양이 달라질 수 있습니다. |
| 정규 파일의 MAP_SHARED 매핑 | VMA 정책이 아니라 task 정책 또는 system default policy를 따릅니다. | 가중치 정책도 동일한 예외를 가집니다. | mbind()를 걸었다고 해서 shared file page cache에 그대로 적용된다고 보면 안 됩니다. |
메모리 티어링 (Memory Tiering)
/*
* 커널 메모리 티어링 (v5.18+):
*
* Tier 0 (Fast): Local DRAM (Node 0, 1)
* Tier 1 (Slow): CXL Memory (Node 2) / 영구 메모리 (PMEM)
*
* 핫 페이지는 Tier 0으로 프로모션
* 콜드 페이지는 Tier 1로 디모션
*
* kswapd가 Tier 0 메모리 부족 시:
* - 콜드 페이지를 Tier 1으로 디모션 (기존: swap/discard)
* - Tier 1에서 핫 접근 감지 시 Tier 0으로 프로모션
*/
# 디모션 타겟 설정
$ cat /sys/devices/system/node/node0/memtier
1
$ cat /sys/devices/system/node/node2/memtier
2
# 노드 간 디모션 경로
$ cat /sys/devices/system/node/node0/demotion_targets
2 # Node 0의 콜드 페이지는 Node 2(CXL)로 이동
# 디모션 활성화
$ echo 1 > /sys/kernel/mm/numa/demotion_enabled
# 프로모션 통계
$ cat /proc/vmstat | grep pgpromote
pgpromote_success 12345 # Tier 1 → Tier 0 프로모션 성공
pgdemote_kswapd 67890 # kswapd에 의한 디모션
pgdemote_direct 1234 # direct reclaim에 의한 디모션
가상화(Virtualization)와 vNUMA
KVM 가상화 환경에서 게스트 VM에 NUMA 토폴로지를 노출하면 게스트 OS가 NUMA-aware 최적화를 수행할 수 있습니다.
# QEMU/KVM에서 vNUMA 설정
$ qemu-system-x86_64 \
-smp 16,sockets=2,cores=4,threads=2 \
-m 16G \
-object memory-backend-ram,size=8G,id=ram0,host-nodes=0,policy=bind \
-object memory-backend-ram,size=8G,id=ram1,host-nodes=1,policy=bind \
-numa node,memdev=ram0,cpus=0-7,nodeid=0 \
-numa node,memdev=ram1,cpus=8-15,nodeid=1 \
-numa dist,src=0,dst=1,val=21 \
...
# libvirt XML에서 vNUMA 설정
<cpu>
<numa>
<cell id='0' cpus='0-7' memory='8388608' unit='KiB'/>
<cell id='1' cpus='8-15' memory='8388608' unit='KiB'/>
</numa>
</cpu>
<numatune>
<memory mode='strict' nodeset='0-1'/>
<memnode cellid='0' mode='strict' nodeset='0'/>
<memnode cellid='1' mode='strict' nodeset='1'/>
</numatune>
성능 분석과 최적화
perf로 NUMA 분석
# NUMA 관련 하드웨어 카운터 수집
$ perf stat -e \
node-loads,node-load-misses,\
node-stores,node-store-misses \
-- ./my_application
Performance counter stats for './my_application':
142,857,391 node-loads # 메모리 로드 총 수
1,234,567 node-load-misses # 리모트 노드 로드 (0.86%)
98,234,567 node-stores
345,678 node-store-misses # 리모트 노드 스토어
# NUMA 미스 비율이 높은 함수 프로파일링
$ perf record -e node-load-misses -g -- ./my_application
$ perf report --sort=dso,symbol
# perf c2c — NUMA false sharing / remote access 분석
$ perf c2c record -- ./my_application
$ perf c2c report --stdio
=================================================
Shared Data Cache Line Table
=================================================
Num RmtHitm LclHitm Stores ...
----- -------- ------- ------- --------
0 1234 56 7890 ...
Address: 0x7f8a...
Source: my_struct+0x40 (my_module.c:123)
numastat 분석
# 시스템 전체 NUMA 통계
$ numastat
node0 node1
numa_hit 142857391 98234567 # 의도한 노드에서 할당 성공
numa_miss 1234567 2345678 # 의도한 노드 실패 → 다른 노드
numa_foreign 2345678 1234567 # 다른 노드의 miss가 여기서 충족
interleave_hit 3456789 3456789 # interleave 정책 적중
local_node 139400824 95888889 # 로컬 CPU에서 로컬 메모리 할당
other_node 4690134 4690356 # 리모트 CPU에서 로컬 메모리 할당
# 높은 numa_miss → NUMA 정책 조정 필요
# 높은 other_node → 태스크가 잘못된 노드에서 실행 중
# numa_miss / numa_hit 비율이 5% 이상이면 최적화 검토
# 프로세스별 NUMA 메모리 분포
$ numastat -p <pid>
$ numastat -c qemu-kvm # 특정 프로세스 이름으로 조회
메모리 대역폭/지연 측정 도구
# ============ Intel MLC (Memory Latency Checker) ============
# 노드별 지연 시간과 대역폭 정밀 측정 (Intel 공식 도구)
$ mlc --latency_matrix
Measuring idle latencies (in ns)...
Numa node
Numa node 0 1
0 81.2 139.5
1 139.8 80.9
$ mlc --bandwidth_matrix
Measuring Memory Bandwidths (MB/sec)...
Numa node
Numa node 0 1
0 51200 24800 # 리모트 대역폭 ~48% 감소
1 24600 51100
# 부하 하 지연 (loaded latency) — 실제 워크로드 시뮬레이션
$ mlc --loaded_latency
Measuring Loaded Latencies...
Inject Delay Latency Bandwidth
=========== ======= =========
00000 215.3 48123 # 최대 부하 시 지연 급증
00100 105.2 42100
02000 83.1 12340 # 저부하 시 기본 지연
# ============ STREAM Benchmark ============
# 노드별 메모리 대역폭 측정
$ numactl --cpunodebind=0 --membind=0 ./stream_c.exe
Function Best Rate MB/s Avg time Min time Max time
Copy: 47231.2 0.013489 0.013472 0.013510
Scale: 47180.5 0.013503 0.013487 0.013525
Add: 52410.8 0.018253 0.018230 0.018280
Triad: 52390.1 0.018260 0.018237 0.018290
# 리모트 노드 대역폭 비교
$ numactl --cpunodebind=0 --membind=1 ./stream_c.exe
# → Triad가 ~25000 MB/s로 약 50% 감소 (리모트 메모리 접근)
# ============ lmbench ============
# 메모리 지연 프로파일 (stride별)
$ numactl --cpunodebind=0 --membind=0 lat_mem_rd 256m 512
# 배열 크기별 접근 지연 측정
# L1 hit: ~1ns, L2 hit: ~4ns, L3 hit: ~12ns, DRAM: ~80ns
# ============ numactl --hardware (빠른 확인) ============
$ numactl --hardware
# 가장 빠른 NUMA 토폴로지 확인 방법
# 노드 수, CPU 매핑, 메모리 크기, 거리 매트릭스 한 번에 확인
https://www.intel.com/content/www/us/en/developer/articles/tool/intelr-memory-latency-checker.html에서 무료로 다운로드할 수 있습니다. AMD 시스템에서도 대부분 동작하지만, AMD 전용으로는 AMD uProf의 메모리 프로파일링(Profiling) 기능을 사용할 수 있습니다. STREAM은 https://www.cs.virginia.edu/stream/에서 소스를 받아 gcc -O3 -fopenmp -DSTREAM_ARRAY_SIZE=...으로 컴파일합니다.
최적화 패턴
| 문제 | 진단 | 해결 |
|---|---|---|
| 높은 리모트 접근 비율 | numastat의 numa_miss 비율 확인 | numactl --membind 또는 --cpunodebind |
| NIC IRQ가 리모트 노드 CPU에서 처리 | cat /proc/irq/*/smp_affinity | IRQ를 NIC 로컬 노드 CPU에 바인딩 |
| 대형 해시 테이블의 불균일 접근 | perf c2c 분석 | numactl --interleave=all |
| NUMA balancing 오버헤드 | /proc/vmstat의 numa_hint_faults | sysctl kernel.numa_balancing=0 |
| 한쪽 노드만 OOM | /sys/.../node*/meminfo | 메모리 정책 조정, vm.zone_reclaim_mode |
| KVM 게스트 성능 저하 | vCPU가 다른 물리 노드로 이동 | vNUMA 설정 + CPU/memory 핀닝 |
| CXL 메모리 활용 부족 | numastat에서 CXL 노드 미사용 | MPOL_WEIGHTED_INTERLEAVE 정책 |
커널 설정 종합
# ===== NUMA 관련 커널 설정 종합 =====
# -- 기본 NUMA 지원 --
CONFIG_NUMA=y
CONFIG_AMD_NUMA=y # AMD NUMA (K8 이상)
CONFIG_X86_64_ACPI_NUMA=y # ACPI SRAT 기반 NUMA
CONFIG_ACPI_NUMA=y
CONFIG_NODES_SHIFT=10 # 최대 NUMA 노드 수 (2^10 = 1024)
# -- NUMA Balancing --
CONFIG_NUMA_BALANCING=y # Automatic NUMA Balancing
CONFIG_NUMA_BALANCING_DEFAULT_ENABLED=y
# -- 메모리 정책 --
CONFIG_MIGRATION=y # 페이지 마이그레이션 지원
# -- 메모리 티어링 (CXL 등) --
CONFIG_MEMORY_TIER=y # 메모리 티어링 프레임워크
CONFIG_DEMOTION=y # 콜드 페이지 디모션
# -- CXL 지원 --
CONFIG_CXL_BUS=y
CONFIG_CXL_MEM=y
CONFIG_CXL_ACPI=y
CONFIG_CXL_REGION=y
# -- HMAT (이종 메모리 속성) --
CONFIG_ACPI_HMAT=y # HMAT 파싱
# -- 디버깅/통계 --
CONFIG_NUMA_EMU=y # NUMA 에뮬레이션 (UMA에서 테스트)
CONFIG_SCHED_DEBUG=y # 스케줄링 도메인 디버깅
CONFIG_VMSTAT=y # /proc/vmstat NUMA 통계
numa=fake=4를 추가하여 가상의 4-노드 NUMA 시스템을 에뮬레이션할 수 있습니다. CONFIG_NUMA_EMU=y가 필요합니다.
트러블슈팅
일반적인 문제와 해결
| 증상 | 원인 | 진단 | 해결 |
|---|---|---|---|
| 한쪽 노드만 OOM 발생 | MPOL_BIND로 특정 노드에 바인딩된 프로세스 | /proc/PID/numa_maps 확인 | MPOL_PREFERRED로 변경 또는 메모리 증설 |
| 성능 저하 (latency 증가) | 리모트 NUMA 접근 비율 높음 | perf stat -e node-load-misses | numactl --cpunodebind --membind |
| NUMA balancing CPU 오버헤드 | 잦은 NUMA hint fault 발생 | perf top에서 do_numa_page 비율 | kernel.numa_balancing=0 |
| kswapd 과도한 활동 (한쪽 노드) | 노드 간 메모리 불균형 | /proc/zoneinfo 워터마크(Watermark) 확인 | vm.zone_reclaim_mode 조정 |
| DB 쿼리 성능 불일정 | NUMA balancing이 페이지 이동(Page Migration) | /proc/vmstat numa_pages_migrated | DB 프로세스에 membind 적용 |
| 멀티스레드 앱 확장성 저하 | 스레드 간 false sharing + cross-node | perf c2c 분석 | 데이터 구조 padding, 노드별 분리 |
디버깅(Debugging) 명령 모음
# ============ 토폴로지 확인 ============
$ numactl --hardware # NUMA 토폴로지 전체
$ lscpu | grep -i numa # CPU-NUMA 매핑 요약
$ lstopo --of txt # hwloc 상세 토폴로지
# ============ 메모리 분포 ============
$ numastat # 시스템 전체 NUMA 통계
$ numastat -p <pid> # 프로세스별 NUMA 메모리
$ cat /proc/<pid>/numa_maps # VMA별 노드 분포
$ cat /sys/devices/system/node/node*/meminfo # 노드별 상세 메모리
# ============ 성능 카운터 ============
$ perf stat -e node-loads,node-load-misses ./app # NUMA 미스 측정
$ perf c2c record -- ./app # false sharing 분석
# ============ 밸런싱 통계 ============
$ cat /proc/vmstat | grep numa # NUMA balancing 통계
$ cat /proc/<pid>/sched | grep numa # 프로세스 NUMA 폴트
# ============ 커널 로그 ============
$ dmesg | grep -iE "numa|srat|slit|node" # NUMA 초기화 로그
# ============ 장치 NUMA 친화성 ============
$ cat /sys/class/net/*/device/numa_node # NIC NUMA 노드
$ cat /sys/block/*/device/numa_node # 블록 장치 NUMA 노드
$ lspci -vvv | grep -i "NUMA node" # PCIe 장치 NUMA 노드
흔한 실수와 안티패턴
NUMA 환경에서 자주 발생하는 성능 문제와 설정 실수를 정리합니다. 대부분 NUMA 토폴로지를 무시하거나 잘못 이해한 상태에서 발생합니다.
| 실수 | 증상 | 원인 | 해결 |
|---|---|---|---|
| malloc 후 첫 접근 전 fork() | 자식 프로세스의 메모리가 부모와 같은 노드에 고정 | CoW(Copy-on-Write) 페이지가 부모 노드에 할당됨 | fork() 후 자식에서 set_mempolicy() 호출, 또는 MADV_HUGEPAGE + NUMA balancing 활용 |
| 모든 것에 membind | 한쪽 노드만 OOM, 다른 노드는 여유 | 과도한 MPOL_BIND로 노드 간 메모리 활용 불균형 |
대부분의 경우 MPOL_PREFERRED 또는 기본 정책이 더 나음 |
| interleave를 모든 워크로드에 적용 | 지연 민감 워크로드 성능 저하 | interleave는 절반의 접근이 리모트 노드 | interleave는 대역폭 위주(해시 테이블, 대형 배열)에만 사용, 지연 민감 워크로드는 bind/preferred |
| NIC IRQ가 잘못된 노드에 배치 | 네트워크 처리량(Throughput) 저하, CPU 사용률 증가 | irqbalance가 IRQ를 NIC 리모트 노드로 이동 | 수동 IRQ 바인딩 또는 irqbalance 힌트 설정, set_irq_affinity.sh 스크립트 사용 |
| vNUMA 없는 대형 VM | VM 내부 성능 불일정, 예측 불가능한 지연 | 게스트 OS가 NUMA를 인식하지 못하여 최적화 불가 | vNUMA 설정 + 호스트 NUMA 노드에 vCPU/memory 핀닝 |
| NUMA balancing + 명시적 바인딩 충돌 | balancing이 바인딩을 무시하고 페이지 이동 | 커널 NUMA balancing과 사용자 정책이 경쟁 | 명시적 membind 사용 시 kernel.numa_balancing=0 고려 |
| 1GB HugePage의 NUMA 배치 무시 | hugepage가 리모트 노드에 할당, 마이그레이션 불가 | 1GB hugepage는 부팅 시 할당되어 마이그레이션 불가능 | 노드별로 /sys/.../hugepages-1048576kB/nr_hugepages 개별 설정 |
| DB 공유 버퍼(Buffer)를 MPOL_BIND로 단일 노드에 고정 | 해당 노드 OOM, 또는 리모트 CPU에서 접근 시 느림 | 대형 공유 버퍼가 한 노드의 대부분을 소비 | MPOL_INTERLEAVE로 분산하거나, 노드별 DB 인스턴스(Instance) 분리 |
코드 레벨 안티패턴
/* ❌ 안티패턴 1: NUMA 무시 대량 할당 */
void *buf = malloc(1UL << 30); /* 1GB 할당 */
memset(buf, 0, 1UL << 30); /* 현재 CPU의 노드에 전부 할당됨 */
/* 다른 노드의 스레드가 접근하면 모두 리모트 접근 */
/* ✅ 개선: 스레드별 첫 접근으로 분산 (first-touch) */
void *buf = mmap(NULL, size, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
/* 각 스레드가 자기 영역만 초기화 → 해당 스레드의 로컬 노드에 할당 */
#pragma omp parallel for
for (int i = 0; i < num_threads; i++) {
memset(buf + i * chunk_size, 0, chunk_size);
}
/* ❌ 안티패턴 2: 커널에서 NUMA 무시 할당 */
ptr = kmalloc(size, GFP_KERNEL); /* 현재 CPU 노드에 할당 */
/* 이 메모리를 다른 노드의 장치 IRQ 핸들러에서 접근 */
/* ✅ 개선: 장치 노드에서 할당 */
ptr = kmalloc_node(size, GFP_KERNEL, dev_to_node(dev));
/* ❌ 안티패턴 3: per-CPU 데이터를 다른 CPU에서 빈번히 접근 */
/* per-CPU는 해당 CPU 노드의 메모리이므로
* 다른 CPU에서 접근 시 리모트 + 캐시 바운싱 */
total += per_cpu(counter, other_cpu); /* 느림! */
/* ✅ 개선: 합산이 필요하면 for_each_possible_cpu + 로컬 합산 */
for_each_possible_cpu(cpu)
total += per_cpu(counter, cpu); /* 전체 순회는 드물게만 */
MPOL_DEFAULT)은 first-touch입니다. 즉, 페이지는 처음 접근(touch)하는 CPU의 NUMA 노드에 할당됩니다. 따라서 대형 배열을 한 스레드에서 초기화한 후 여러 스레드에서 접근하면, 모든 메모리가 초기화 스레드의 노드에 집중됩니다. 멀티스레드 초기화(parallel first-touch) 또는 MPOL_INTERLEAVE로 분산하는 것이 핵심 패턴입니다.
NUMA 초기화 흐름
커널 부팅 시 NUMA 토폴로지 초기화는 여러 단계를 거칩니다. ACPI 테이블 파싱부터 pglist_data 구성, zonelist 빌드, kswapd 기동까지의 전체 흐름을 살펴봅니다.
/*
* NUMA 초기화 흐름 (x86_64 기준)
*
* start_kernel()
* → setup_arch()
* → acpi_boot_init()
* → acpi_numa_init() ← SRAT/SLIT 파싱
* → acpi_parse_srat()
* → acpi_parse_processor_affinity() ← CPU→Node
* → acpi_parse_memory_affinity() ← Memory→Node
* → acpi_parse_slit()
* → acpi_numa_slit_init() ← 거리 매트릭스
* → numa_init()
* → numa_register_memblks() ← 노드별 메모리 범위 등록
* → init_cpu_to_node() ← CPU→Node 매핑 확정
* → mm_core_init()
* → build_all_zonelists() ← zonelist 구성 (SLIT 거리 순)
* → free_area_init() ← pg_data_t, zone 초기화
* → rest_init()
* → kernel_init()
* → smp_init()
* → sched_init_domains() ← NUMA sched_domain 구성
* → kswapd_init() ← 노드별 kswapd 기동
*/
/* arch/x86/mm/numa.c — NUMA 초기화 진입점 */
static int __init numa_init(int (*init_func)(void))
{
int ret;
/* 노드/CPU 데이터 초기화 */
nodes_clear(numa_nodes_parsed);
memset(&numa_meminfo, 0, sizeof(numa_meminfo));
numa_reset_distance();
/* ACPI SRAT/SLIT 파싱 (또는 AMD K8, devicetree 등) */
ret = init_func();
if (ret < 0)
return ret;
/* 파싱된 노드 정보로 memblock 등록 */
ret = numa_register_memblks(&numa_meminfo);
if (ret < 0)
return ret;
/* CPU→Node 매핑 확정 */
for (i = 0; i < nr_cpu_ids; i++)
set_cpu_numa_node(i, early_cpu_to_node(i));
/* numa_distance[][] 유효성 검증 */
numa_add_cpu(0);
return 0;
}
/* mm/page_alloc.c — zonelist 구성 */
static void build_zonelists(pg_data_t *pgdat)
{
struct zoneref *zonerefs;
int node, local_node = pgdat->node_id;
/*
* 로컬 노드 우선, SLIT 거리 순으로 zonelist 구성
* find_next_best_node()가 거리가 가까운 순서로 노드 반환
*/
zonerefs = pgdat->node_zonelists[ZONELIST_FALLBACK]._zonerefs;
/* 로컬 노드의 존들을 먼저 추가 */
build_zonerefs_node(pgdat, zonerefs);
/* 거리 순으로 리모트 노드 추가 */
while ((node = find_next_best_node(local_node, &used_mask))
>= 0) {
build_zonerefs_node(NODE_DATA(node), zonerefs);
}
}
dmesg | grep -iE "numa|srat|slit|node|zone"로 NUMA 초기화 과정을 확인할 수 있습니다. 특히 "SRAT: Node N PXM M" 메시지로 ACPI에서 파싱된 노드 매핑(Mapping)을, "Initmem setup node N"으로 노드별 메모리 범위를 확인합니다.
NUMA와 cgroups 통합
cgroups v2의 cpuset 컨트롤러를 사용하면 컨테이너(Container)나 프로세스 그룹을 특정 NUMA 노드에 격리할 수 있습니다. Kubernetes의 NUMA-aware 스케줄링도 이 메커니즘 위에 구현됩니다.
cpuset.mems — NUMA 노드 제한
# cgroups v2에서 cpuset을 사용한 NUMA 격리
# 1. cpuset 컨트롤러 활성화
$ echo "+cpuset" > /sys/fs/cgroup/cgroup.subtree_control
# 2. NUMA-격리 그룹 생성
$ mkdir /sys/fs/cgroup/numa-isolated
# 3. 사용 가능한 NUMA 노드 제한 (Node 0만 사용)
$ echo 0 > /sys/fs/cgroup/numa-isolated/cpuset.mems
$ echo 0-7 > /sys/fs/cgroup/numa-isolated/cpuset.cpus
# 4. 프로세스를 해당 cgroup에 추가
$ echo $$ > /sys/fs/cgroup/numa-isolated/cgroup.procs
# 확인: cpuset.mems.effective로 실제 적용된 노드 확인
$ cat /sys/fs/cgroup/numa-isolated/cpuset.mems.effective
0
# memory.numa_stat — cgroup의 NUMA별 메모리 사용 통계
$ cat /sys/fs/cgroup/numa-isolated/memory.numa_stat
anon N0=12345678 N1=0
file N0=8901234 N1=0
kernel_stack N0=65536 N1=0
shmem N0=0 N1=0
file_mapped N0=4567890 N1=0
file_dirty N0=12345 N1=0
Kubernetes NUMA-aware 스케줄링
# Kubernetes Topology Manager 정책 (kubelet 설정)
#
# --topology-manager-policy:
# none - NUMA 인식 없음 (기본)
# best-effort - 가능하면 같은 NUMA 노드에 배치
# restricted - NUMA 정렬 불가 시 Pod Admission 거부
# single-numa-node - 반드시 단일 NUMA 노드에 배치
#
# Guaranteed QoS Pod + CPU Manager static 정책 사용 시:
# 1. CPU Manager가 전용 CPU 코어 할당
# 2. Topology Manager가 같은 NUMA 노드의 CPU+메모리+장치 정렬
# 3. 커널 cpuset cgroup으로 격리 적용
#
# Pod 예시:
# resources:
# requests:
# cpu: "4"
# memory: "8Gi"
# limits:
# cpu: "4"
# memory: "8Gi"
# → Topology Manager가 4 CPU + 8Gi를 같은 NUMA 노드에 정렬
cpuset.mems의 상세 동작, cpuset.mems.partition을 이용한 노드 격리, isolcpus/nohz_full과의 조합은 cpusets & CPU Isolation 문서를 참조하세요.
NUMA 메모리 핫플러그
서버 환경에서 메모리를 온라인 상태에서 추가/제거하는 메모리 핫플러그는 NUMA 토폴로지와 밀접합니다. 새 메모리는 특정 NUMA 노드에 속하며, 제거 시에는 해당 노드의 페이지를 먼저 마이그레이션해야 합니다.
# 메모리 핫플러그 — 노드별 메모리 블록 확인
$ ls /sys/devices/system/memory/
memory0 memory1 memory2 ... block_size_bytes probe
# 메모리 블록이 속한 NUMA 노드 확인
$ cat /sys/devices/system/memory/memory32/phys_device
1 # Node 1에 속함
# 메모리 블록 온라인/오프라인
$ echo online > /sys/devices/system/memory/memory32/state # 온라인
$ echo offline > /sys/devices/system/memory/memory32/state # 오프라인
# 오프라인 실패 시: 해당 블록에 이동 불가능한 페이지가 있음
# → movable zone에 할당된 메모리만 오프라인 가능
# → CONFIG_MEMORY_HOTREMOVE=y 필요
# 메모리를 특정 존으로 온라인 (v5.1+)
$ echo online_movable > /sys/devices/system/memory/memory32/state
# online_movable: ZONE_MOVABLE로 추가 (핫 리무브 가능)
# online_kernel: ZONE_NORMAL로 추가 (커널 할당 가능)
/* mm/memory_hotplug.c — 핫플러그 콜백 체인 */
/*
* 메모리 핫플러그 시 발생하는 노티파이어 이벤트:
*
* MEM_GOING_ONLINE — 메모리 온라인 준비 (거부 가능)
* MEM_ONLINE — 메모리 온라인 완료
* MEM_GOING_OFFLINE — 메모리 오프라인 준비 (페이지 마이그레이션)
* MEM_OFFLINE — 메모리 오프라인 완료
* MEM_CANCEL_ONLINE — 온라인 취소
* MEM_CANCEL_OFFLINE — 오프라인 취소
*/
/* 노드의 메모리가 모두 오프라인되면 node_states 업데이트 */
static void node_states_check_changes_offline(
unsigned long nr_pages,
struct zone *zone,
struct memory_notify *arg)
{
struct pglist_data *pgdat = zone->zone_pgdat;
/* 노드에 메모리가 남아있는지 확인 */
if (!node_present_pages(pgdat->node_id)) {
/* 메모리 없는 노드 → N_MEMORY 상태 해제 */
arg->status_change_nid = pgdat->node_id;
}
}
ZONE_NORMAL에 할당된 커널 메모리(slab, page table 등)는 마이그레이션이 불가능하므로 오프라인할 수 없습니다. 핫 리무브가 필요한 환경에서는 새 메모리를 online_movable로 추가하여 ZONE_MOVABLE에 배치해야 합니다. 자세한 내용은 메모리 — 핫플러그를 참조하세요.
NUMA와 Hugepage
Hugepage 할당은 NUMA 토폴로지의 영향을 크게 받습니다. 2MB/1GB 대형 페이지는 한번 할당되면 마이그레이션 비용이 매우 높으므로, 초기 배치가 특히 중요합니다.
HugeTLB 노드별 할당
# 시스템 전체 hugepage 설정 (모든 노드에 균등 분배)
$ echo 1024 > /proc/sys/vm/nr_hugepages
# 특정 노드에만 hugepage 할당 (권장)
$ echo 512 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
$ echo 512 > /sys/devices/system/node/node1/hugepages/hugepages-2048kB/nr_hugepages
# 노드별 hugepage 현황 확인
$ cat /sys/devices/system/node/node0/hugepages/hugepages-2048kB/free_hugepages
480
$ cat /sys/devices/system/node/node1/hugepages/hugepages-2048kB/free_hugepages
510
# 1GB hugepage (노드별)
$ echo 4 > /sys/devices/system/node/node0/hugepages/hugepages-1048576kB/nr_hugepages
# numactl + hugepage 조합
$ numactl --membind=0 --hugepage ./database_app
# 또는 mbind() + MAP_HUGETLB 조합으로 프로그래밍
THP(Transparent Huge Pages)와 NUMA
/*
* THP 할당 시 NUMA 고려사항:
*
* 1. THP는 연속 2MB 물리 메모리 필요 → 노드별 가용 연속 메모리 차이
* 2. khugepaged가 THP 콜랩싱 시 페이지들의 노드를 확인
* → 다른 노드에 흩어진 4KB 페이지를 하나의 THP로 합칠 때
* 다수 페이지가 있는 노드에 THP 할당
* 3. NUMA balancing + THP:
* → 2MB 단위로 NUMA hinting fault 발생
* → 마이그레이션 시 2MB 복사 (4KB 대비 512배 비용)
* → MADV_HUGEPAGE 영역의 NUMA 배치가 특히 중요
*/
/* mm/khugepaged.c — THP collapse 시 NUMA 노드 선택 */
static int hpage_collapse_find_target_node(
struct collapse_control *cc)
{
int nid, target_nid, max_count = 0;
/* 각 노드에 있는 원본 페이지 수를 집계 */
for_each_online_node(nid) {
if (cc->node_load[nid] > max_count) {
max_count = cc->node_load[nid];
target_nid = nid;
}
}
/* 가장 많은 페이지가 있는 노드에 THP 할당 */
return target_nid;
}
NUMA 배치 최적화 플레이북
NUMA 성능 저하는 대부분 원격 메모리 접근 증가에서 시작됩니다. CPU 바인딩과 메모리 바인딩을 함께 설정하고, 자동 밸런싱이 실제로 도움되는지 워크로드별로 검증해야 합니다.
| 워크로드 유형 | 권장 정책 | 확인 지표 |
|---|---|---|
| 지연 민감 DB | --cpunodebind + --membind | remote hit 비율, tail latency |
| 대규모 배치 처리 | interleave 또는 자동 밸런싱 | 처리량(Throughput)/CPU 사용률 |
| 혼합 컨테이너 환경 | cgroup 메모리/CPU 격리 | 노드별 reclaim/oom 분포 |
# 배치/로컬리티 점검
numactl --hardware
numastat -p <pid>
cat /proc/<pid>/numa_maps | head
task_numa_fault() → task_numa_placement() 체인
Automatic NUMA Balancing의 핵심 판단 경로는 do_numa_page()에서 시작하여 task_numa_fault(), task_numa_placement(), task_numa_find_cpu()로 이어지는 함수 호출 체인입니다. 이 체인은 NUMA hinting fault가 발생할 때마다 실행되며, 페이지와 태스크의 최적 배치를 결정합니다.
task_numa_fault()는 NUMA hinting fault의 통계 수집과 배치 판단을 연결하는 핵심 함수입니다. 각 폴트에서 접근된 노드 정보를 기록하고, 스캔 주기가 갱신될 때 task_numa_placement()를 호출하여 최적 노드를 재계산합니다.
/* kernel/sched/fair.c — task_numa_fault() 간략화 */
void task_numa_fault(int last_cpupid, int mem_node,
int pages, int flags)
{
struct task_struct *p = current;
bool migrated = flags & TNF_MIGRATED;
int cpu_node = cycpupid_to_nid(last_cpupid);
int local = !!(flags & TNF_FAULT_LOCAL);
struct numa_group *ng;
int priv;
/* 1. 커널 스레드나 NUMA 비활성 태스크는 무시 */
if (!p->mm)
return;
/* 2. 접근이 현재 CPU의 로컬인지 판단 */
priv = cpupid_match_pid(p, last_cpupid);
if (!priv && !local)
flags |= TNF_SHARED;
/* 3. 태스크의 numa_faults[] 배열 갱신
* 인덱스: (node * 4) + type
* type: NUMA_MEM(0), NUMA_CPU(1),
* NUMA_MEMBUF(2), NUMA_CPUBUF(3) */
p->numa_faults[task_faults_idx(NUMA_MEMBUF, mem_node, priv)] += pages;
p->numa_faults[task_faults_idx(NUMA_CPUBUF, cpu_node, priv)] += pages;
/* 4. NUMA 그룹이 있으면 그룹 faults도 갱신 */
ng = deref_curr_numa_group(p);
if (ng) {
spin_lock_irq(&ng->lock);
ng->faults[task_faults_idx(NUMA_MEM, mem_node, priv)] += pages;
ng->total_faults += pages;
spin_unlock_irq(&ng->lock);
}
/* 5. 스캔 시퀀스 변경 시 → 배치 재계산 */
if (p->numa_scan_seq != p->mm->numa_scan_seq) {
p->numa_scan_seq = p->mm->numa_scan_seq;
task_numa_placement(p);
}
}
코드 설명
- 3행
last_cpupid는 이전에 이 페이지에 접근한 CPU/PID 정보로, PROT_NONE fault 시 PTE에 인코딩된 값입니다. - 8행
TNF_MIGRATED플래그는 이 폴트로 인해 페이지가 실제로 마이그레이션되었는지를 나타냅니다. - 16행
cpupid_match_pid()는 이전 접근자가 현재 태스크인지 확인합니다. 같은 태스크만 접근하면 private 접근으로 분류됩니다. - 18행
TNF_SHARED는 여러 태스크가 같은 페이지를 접근할 때 설정됩니다. 공유 페이지는 마이그레이션 판단이 더 보수적입니다. - 22행
numa_faults[]는 4개 유형(MEM/CPU/MEMBUF/CPUBUF) × 노드 수 × 2(private/shared)로 구성됩니다. BUF 접미사가 붙은 것은 현재 스캔 주기의 버퍼입니다. - 33행
numa_scan_seq가 변경되면 한 스캔 주기가 완료된 것입니다. 이때 누적된 폴트 데이터를 기반으로task_numa_placement()가 최적 배치를 재계산합니다.
task_numa_fault()는 NUMA hinting fault마다 호출되지만, task_numa_placement()는 스캔 시퀀스가 변경될 때만 호출됩니다. 스캔 주기가 1초~60초이므로 placement 재계산은 상대적으로 드물게 발생하여 오버헤드를 제한합니다.
struct numa_group 심층 분석
struct numa_group은 메모리를 공유하는 태스크들을 그룹으로 묶어, 스케줄러가 그룹 전체의 NUMA 배치를 최적화할 수 있게 합니다. 멀티스레드 애플리케이션에서 같은 공유 메모리를 접근하는 스레드들이 자동으로 하나의 NUMA 그룹으로 합쳐집니다.
/* kernel/sched/fair.c — numa_group 구조체와 태스크 NUMA 필드 */
/* numa_faults[] 배열 인덱스 계산
* 4가지 유형 × 노드 수 × 2(private/shared) = 8 * nr_node_ids */
enum numa_faults_stats {
NUMA_MEM = 0, /* 메모리 노드 폴트 (페이지 위치) */
NUMA_CPU, /* CPU 노드 폴트 (접근한 CPU 위치) */
NUMA_MEMBUF, /* 현재 주기 메모리 버퍼 */
NUMA_CPUBUF, /* 현재 주기 CPU 버퍼 */
};
static inline int task_faults_idx(
enum numa_faults_stats s, int nid, int priv)
{
return NR_NUMA_HINT_FAULT_TYPES * (s * nr_node_ids + nid) + priv;
}
/* task_weight: 태스크 개별 폴트 가중치
* 특정 노드에서의 private 폴트가 많을수록 높은 점수 */
static inline unsigned long task_weight(
struct task_struct *p, int nid, int dist)
{
unsigned long faults, total_faults;
/* private + shared 폴트 합산 */
faults = p->numa_faults[task_faults_idx(NUMA_MEM, nid, 0)] +
p->numa_faults[task_faults_idx(NUMA_MEM, nid, 1)];
total_faults = p->total_numa_faults;
if (!total_faults)
return 0;
return 1000 * faults / total_faults;
}
/* group_weight: 그룹 전체의 폴트 가중치 */
static inline unsigned long group_weight(
struct task_struct *p, int nid, int dist)
{
struct numa_group *ng = deref_curr_numa_group(p);
unsigned long faults, total_faults;
if (!ng)
return 0;
faults = ng->faults[task_faults_idx(NUMA_MEM, nid, 0)] +
ng->faults[task_faults_idx(NUMA_MEM, nid, 1)];
total_faults = ng->total_faults;
if (!total_faults)
return 0;
return 1000 * faults / total_faults;
}
코드 설명
- 4행
numa_faults[]배열은 4가지 통계 유형(MEM, CPU, MEMBUF, CPUBUF)을 노드별, private/shared 구분으로 저장합니다. BUF 유형은 현재 스캔 주기의 임시 버퍼로, 주기 종료 시 MEM/CPU로 합산됩니다. - 15행
task_faults_idx()는 다차원 배열을 1차원으로 평탄화한 인덱스 계산 함수입니다.NR_NUMA_HINT_FAULT_TYPES은 2(private=1, shared=0)입니다. - 25행
task_weight()는 특정 노드에 대한 태스크의 메모리 접근 비율을 1000분율로 계산합니다. 높을수록 해당 노드에 태스크를 배치할 이유가 강합니다. - 38행
group_weight()는 numa_group 전체의 폴트를 기반으로 가중치를 계산합니다. 그룹이 있으면 개별 태스크뿐 아니라 그룹 전체의 메모리 접근 패턴을 반영하여 배치를 결정합니다.
NUMA 페이지 마이그레이션 경로 분석
NUMA balancing이 페이지 마이그레이션을 결정하면, migrate_misplaced_page()에서 시작되는 마이그레이션 체인이 실행됩니다. 이 경로는 페이지 격리, 노드 간 복사, PTE 재매핑을 포함하며, 마이그레이션 실패 시 원래 위치를 유지합니다.
/* mm/migrate.c — NUMA misplaced 페이지 마이그레이션 */
int migrate_misplaced_page(struct page *page,
struct vm_area_struct *vma,
int node)
{
struct folio *folio = page_folio(page);
pg_data_t *pgdat = NODE_DATA(node);
int nr_remaining;
unsigned int nr_succeeded;
LIST_HEAD(migratepages);
/* 1. 마이그레이션 속도 제한 (rate limiting)
* 너무 많은 마이그레이션이 동시에 발생하면 성능 저하 */
if (rate_limited_count_exceeded(pgdat))
goto out;
/* 2. folio 격리: LRU에서 분리
* 마이그레이션 중 다른 경로에서 접근하지 않도록 */
if (numamigrate_isolate_folio(pgdat, folio))
goto out;
list_add(&folio->lru, &migratepages);
nr_remaining = migrate_pages(&migratepages,
alloc_misplaced_dst_folio,
NULL, node,
MIGRATE_ASYNC,
MR_NUMA_MISPLACED,
&nr_succeeded);
if (nr_remaining) {
/* 마이그레이션 실패: 원본 folio를 LRU에 복귀 */
if (!list_empty(&migratepages))
putback_movable_pages(&migratepages);
}
return nr_succeeded;
out:
folio_put(folio);
return 0;
}
/* mm/migrate.c — NUMA 마이그레이션용 folio 격리 */
static bool numamigrate_isolate_folio(
pg_data_t *pgdat, struct folio *folio)
{
/* 노드별 마이그레이션 허용량 검사
* numabalancing_migrate_nr_pages 초과 시 거부
* → 한 노드에 너무 많은 페이지가 한꺼번에 몰리는 것 방지 */
if (pgdat->numabalancing_migrate_nr_pages >
NUMA_MIGRATION_THRESHOLD) {
if (time_before(jiffies,
pgdat->numabalancing_migrate_next_window))
return true; /* 속도 제한 */
pgdat->numabalancing_migrate_nr_pages = 0;
pgdat->numabalancing_migrate_next_window =
jiffies + 2 * HZ;
}
/* LRU에서 folio 분리 */
if (!folio_isolate_lru(folio))
return true; /* 격리 실패 */
pgdat->numabalancing_migrate_nr_pages += folio_nr_pages(folio);
return false; /* 성공 */
}
코드 설명
- 14행속도 제한(rate limiting)은 NUMA balancing이 한 번에 너무 많은 페이지를 마이그레이션하여 시스템 성능을 저하시키는 것을 방지합니다. 2초 윈도우당 허용량을 제한합니다.
- 19행
numamigrate_isolate_folio()는 folio를 LRU 리스트에서 분리합니다. 격리하지 않으면 kswapd 등 다른 경로가 동시에 같은 페이지를 조작할 수 있습니다. - 23행
migrate_pages()는 범용 마이그레이션 엔진입니다.alloc_misplaced_dst_folio()가 대상 노드에서 새 folio를 할당하고,MIGRATE_ASYNC모드로 비동기 마이그레이션을 수행합니다. - 27행
MR_NUMA_MISPLACED는 마이그레이션 사유(reason)를 나타냅니다./proc/vmstat의pgmigrate_success/pgmigrate_fail통계에서 사유별로 구분됩니다. - 31행마이그레이션 실패 시
putback_movable_pages()로 원본 folio를 LRU에 복귀시킵니다. 대상 노드 메모리 부족이나 folio가 pinned 상태일 때 실패할 수 있습니다. - 47행
numabalancing_migrate_next_window는 다음 윈도우 시작 시간입니다. 2초(2*HZ) 간격으로 윈도우가 갱신되며, 각 윈도우에서 마이그레이션 카운터가 초기화됩니다.
rate_limited_count_exceeded()로 마이그레이션 속도를 제한하고, 실제로 이득이 있는 경우에만 마이그레이션을 수행합니다.
NUMA 메모리 정책 커널 경로
set_mempolicy()와 mbind()는 결국 커널 내부의 struct mempolicy로 수렴합니다.
페이지 할당 hot path에서는 이 정책 객체의 mode, flags, nodemask를 읽어
alloc_pages_mpol()이 대상 노드를 고릅니다.
다만 shared policy lookup, reference count, cpuset 재바인딩이 섞이면
사용자 공간에서 보는 "간단한 nodemask"보다 실제 경로가 더 복잡해집니다.
struct mempolicy는 참조 카운터를 가지며,
task/VMA policy는 보통 fault path에서 mmap_lock 보호 아래 읽힙니다.
반면 shared policy는 lookup 중 추가 참조와 잠금 경로가 필요하므로
같은 interleave/bind 정책이라도 shared memory에서는 비용이 더 높을 수 있습니다.
/* mm/mempolicy.c — set_mempolicy() 시스템 콜 경로 */
SYSCALL_DEFINE3(set_mempolicy, int, mode,
const unsigned long __user *, nmask,
unsigned long, maxnode)
{
return kernel_set_mempolicy(mode, nmask, maxnode);
}
static long kernel_set_mempolicy(int mode,
const unsigned long __user *nmask,
unsigned long maxnode)
{
nodemask_t nodes;
unsigned short flags;
/* 모드와 플래그 분리 */
flags = mode & MPOL_MODE_FLAGS;
mode &= ~MPOL_MODE_FLAGS;
/* 사용자 공간 nodemask를 커널 nodemask로 복사 */
get_nodes(&nodes, nmask, maxnode);
return do_set_mempolicy(mode, flags, &nodes);
}
static long do_set_mempolicy(unsigned short mode,
unsigned short flags, nodemask_t *nodes)
{
struct mempolicy *new, *old;
struct mm_struct *mm = current->mm;
/* 새 mempolicy 객체 생성 */
new = mpol_new(mode, flags, nodes);
/* current->mempolicy 교체
* 이후 alloc_pages()가 이 정책을 참조 */
task_lock(current);
old = current->mempolicy;
current->mempolicy = new;
task_unlock(current);
mpol_put(old);
return 0;
}
/* mm/mempolicy.c — 정책 기반 페이지 할당 */
struct page *alloc_pages_mpol(gfp_t gfp,
unsigned int order, struct mempolicy *pol,
pgoff_t ilx, int nid)
{
struct page *page;
switch (pol->mode) {
case MPOL_PREFERRED:
case MPOL_PREFERRED_MANY:
/* 선호 노드에서 우선 시도, 실패 시 폴백 */
nid = policy_node(gfp, pol, nid);
break;
case MPOL_BIND:
/* nodemask 내에서만 할당 (zonelist 필터링) */
gfp |= __GFP_HARDWALL;
break;
case MPOL_INTERLEAVE:
/* 라운드-로빈으로 노드 선택 */
nid = interleave_nid(pol, ilx);
break;
case MPOL_WEIGHTED_INTERLEAVE:
/* 대역폭 가중치로 노드 선택 (v6.9+) */
nid = weighted_interleave_nid(pol, ilx);
break;
}
page = __alloc_pages(gfp, order, nid, policy_nodemask(gfp, pol));
return page;
}
interleave_nid(pol, ilx)는 주로 익명 페이지와 공유 메모리의
"페이지 오프셋 기반 분산"을 상징적으로 보여 줍니다.
page cache는 같은 interleave 정책이라도 태스크별 카운터를 사용하므로,
파일 I/O 배치는 주소 오프셋만으로 설명되지 않습니다.
코드 설명
- 17행
MPOL_MODE_FLAGS로 모드 값에 포함된 플래그를 분리합니다. 모드 상위 비트에MPOL_F_STATIC_NODES등의 플래그가 OR됩니다. - 33행
mpol_new()는struct mempolicy를 kmem_cache에서 할당하고 초기화합니다. 참조 카운터를 1로 설정합니다. - 37행
current->mempolicy를 교체하면 이후 해당 태스크의 모든 페이지 할당에 새 정책이 적용됩니다.mbind()는 VMA별 정책(vma->vm_policy)을 설정하며, 이것이 태스크 정책보다 우선합니다. - 52행
MPOL_PREFERRED는policy_node()로 선호 노드를 반환하지만, 할당 실패 시 일반 zonelist 폴백이 동작합니다. - 56행
MPOL_BIND는__GFP_HARDWALL을 추가하여 cpuset/mempolicy 범위 밖 노드로의 폴백을 차단합니다. - 59행
interleave_nid()설명은 익명 페이지와 공유 메모리에 가장 직접적으로 대응합니다. 이 경우 faulting address의 페이지 오프셋(ilx)으로 nodemask를 인덱싱합니다. page cache는 별도 태스크 카운터 기반으로 분산되므로 같은 interleave 정책이어도 파일 I/O 패턴에 따라 결과가 달라질 수 있습니다. - 63행
weighted_interleave_nid()는 v6.9에서 추가된 함수로, 같은 interleave 경로에서 라운드-로빈 대신 sysfs 가중치(/sys/kernel/mm/mempolicy/weighted_interleave/nodeN) 비율을 적용합니다. 익명 페이지/공유 메모리는 페이지 오프셋 기반, page cache는 태스크 카운터 기반이라는 차이는 그대로 유지됩니다.
NUMA 스코어 계산과 노드 배치 최적화
task_numa_placement()는 태스크와 그룹의 NUMA 폴트 데이터를 분석하여 최적 노드를 결정합니다. 각 노드에 대해 task_weight()와 group_weight()를 계산하고, 현재 배치와 이동 후 배치의 스코어를 비교하여 이동 여부를 판단합니다.
/* kernel/sched/fair.c — task_numa_placement() 간략화 */
static void task_numa_placement(struct task_struct *p)
{
int seq, nid, max_nid = NUMA_NO_NODE;
unsigned long max_faults = 0;
unsigned long fault_types[2] = { 0, 0 };
struct numa_group *ng;
/* 1. 스캔 시퀀스 검증 */
seq = READ_ONCE(p->mm->numa_scan_seq);
if (p->numa_scan_seq == seq)
return;
p->numa_scan_seq = seq;
/* 2. 각 노드에 대해 스코어 계산 */
for_each_online_node(nid) {
unsigned long faults = 0, group_faults = 0;
int priv;
for (priv = 0; priv < NR_NUMA_HINT_FAULT_TYPES; priv++) {
unsigned long diff, f_new, f_old;
int idx = task_faults_idx(NUMA_MEM, nid, priv);
/* 지수 이동 평균 (EMA) 적용:
* new = old/2 + buf
* → 최근 폴트에 더 큰 가중치 */
f_old = p->numa_faults[idx];
f_new = f_old / 2 + p->numa_faults[idx + 2]; /* +2 = MEMBUF */
p->numa_faults[idx] = f_new;
p->numa_faults[idx + 2] = 0; /* 버퍼 초기화 */
faults += f_new;
diff = abs(f_new - f_old);
fault_types[priv] += f_new;
}
/* 3. 가장 높은 폴트 카운트를 가진 노드 추적 */
if (faults > max_faults) {
max_faults = faults;
max_nid = nid;
}
/* 4. numa_group이 있으면 그룹 폴트도 EMA 갱신 */
ng = deref_curr_numa_group(p);
if (ng) {
/* 그룹의 faults[]도 같은 EMA 방식으로 갱신 */
update_numa_group_faults(ng, nid);
}
}
/* 5. 스캔 주기 적응
* 로컬 폴트 비율이 높으면 → 주기 증가 (스캔 빈도 감소)
* 리모트 폴트 비율이 높으면 → 주기 감소 (더 자주 스캔) */
update_task_scan_period(p, fault_types[0], fault_types[1]);
/* 6. 선호 노드 갱신 — 그룹 고려 */
ng = deref_curr_numa_group(p);
if (ng) {
/* 그룹이 있으면 task_numa_find_cpu()로
* 그룹 전체의 최적 배치 탐색 */
nid = task_numa_find_cpu(p, max_nid);
}
/* 현재 노드와 다른 노드가 최적이면 선호 노드 변경 */
if (max_nid != p->numa_preferred_nid) {
/* CFS 스케줄러가 다음 wake-up 시 이 노드를 선호 */
sched_setnuma(p, max_nid);
}
}
코드 설명
- 10행
numa_scan_seq는 mm_struct에 저장된 전역 시퀀스 번호입니다.task_tick_numa()가 VMA 스캔을 완료할 때마다 증가합니다. - 26행지수 이동 평균(EMA)을 사용하여 오래된 폴트 데이터를 점진적으로 감쇠시킵니다.
f_old/2 + buf로 최근 스캔 주기의 폴트에 50% 가중치를 부여합니다. - 29행BUF(버퍼) 엔트리를 0으로 초기화하여 다음 스캔 주기의 새로운 폴트 데이터를 수집할 준비를 합니다.
- 36행모든 노드 중 가장 높은 폴트 카운트를 가진 노드(
max_nid)가 태스크의 메모리가 가장 많이 집중된 노드입니다. - 50행
update_task_scan_period()는 private/shared 폴트 비율에 따라 스캔 주기를 동적으로 조정합니다. 지역성이 좋으면 불필요한 스캔을 줄이고, 나쁘면 더 자주 확인합니다. - 56행
task_numa_find_cpu()는 대상 노드에서 현재 태스크와 교환(swap)할 수 있는 최적의 CPU/태스크를 찾습니다. 교환 시 양쪽 태스크의 NUMA 지역성이 모두 개선되어야 합니다. - 62행
sched_setnuma()는p->numa_preferred_nid를 갱신합니다. CFS의select_task_rq_fair()가 이 값을 참조하여 태스크를 해당 노드의 CPU에 배치합니다.
task_weight()는 1000 기준 점수입니다. 예를 들어 2-노드 시스템에서 태스크가 Node 0에 800, Node 1에 200의 task_weight를 가지면, 메모리 접근의 80%가 Node 0에 집중된 것입니다. group_weight()도 같은 방식으로 그룹 전체의 노드별 접근 비율을 나타냅니다. 스케줄러는 task_weight + group_weight를 종합하여 최적 배치를 결정합니다.
task_numa_find_cpu() 내부에서 각 후보 CPU의 현재 태스크와 교환(swap)했을 때의 이득을 평가합니다. 교환 전후의 task_weight + group_weight 합계를 비교하여, 양쪽 모두에게 이득이 있거나 총합이 증가하는 경우에만 교환을 승인합니다. 이는 한 태스크의 지역성을 개선하기 위해 다른 태스크의 지역성을 희생하는 것을 방지합니다.
Linux 6.12 ~ 6.19 NUMA 최신 동향
CXL 상용 제품군이 확대되면서 NUMA의 의미가 "물리적으로 떨어진 소켓"에서 "다른 대역폭·지연 특성을 가진 메모리 티어"로 확장되었습니다. 이에 따라 Auto NUMA balancing, memory tiering, ACPI HMAT 기반 노드 속성 해석, DAMON 기반 핫/콜드 이동이 2024-2025년에 빠르게 통합되었습니다.
| 커널 | 릴리스 | NUMA 주요 변경 | 실무 시사점 |
|---|---|---|---|
| 6.12 (LTS) | 2024-11 | WMARK_PROMO 워터마크가 /proc/zoneinfo에 노출 — DRAM-CXL 간 승격/강등 기준을 시스템 단위로 관찰 가능, NUMA 노드별 folio 통계 세분화 | CXL 메모리가 포함된 호스트의 티어링 품질을 zoneinfo만으로 가시화 가능 |
| 6.13 | 2025-01 | numa_balancing 모드가 NUMA_BALANCING_MEMORY_TIERING 확장을 공식 튜너블로 정리, ACPI HMAT 기반 access attribute가 sysfs access0/1/에 표준화 | 콜드/핫 티어를 자동 이동하는 운영 스크립트가 배포 간 이식 가능 |
| 6.14 | 2025-03 | 메모리 핫플러그 기본 online 정책 빌드 시 선택 가능, memhp_default_online_type 부팅 파라미터 정리 | CXL 메모리 핫추가 시 자동 online_movable/online_kernel 선택 가능 |
| 6.15 | 2025-05 | DAMON MIGRATE_HOT/MIGRATE_COLD 액션과 monitoring intervals auto-tuning이 NUMA 티어링과 결합, sysfs 기반 다중 마이그레이션 타깃 기반 정책 실험 | DAMON으로 "핫은 DRAM, 콜드는 CXL"을 선언적으로 지정하는 운영이 가능 |
| 6.16 | 2025-07 | CXL 2.0/3.0 Dynamic Capacity Device(DCD) 운영 인터페이스, NUMA node distance 계산 정확도 향상, numa_stat 카운터 확장 | 워크로드 단위로 CXL 풀을 동적으로 빌렸다 반납하는 멀티테넌트 배포가 실현 단계 |
| 6.17 | 2025-09 | NUMA 노드별 선제적 회수(proactive reclaim) 인터페이스 추가 — /sys/devices/system/node/nodeN/reclaim에 echo 512M swappiness=10 > 형식으로 노드 단위 회수 트리거 가능; NUMA 노드 알림자(notifier) 내부 API가 메모리 on/offline 알림자에서 분리 독립 | CXL/PMem 혼합 시스템에서 특정 노드만 선택적으로 회수하는 운영 스크립트 작성 가능. cpuset 단일 노드 환경에서 불필요한 NUMA 밸런싱 오버헤드 방지 |
| 6.18 | 2025-11 | NUMA 제약으로 인한 kswapd 스래싱 방지 — 메모리 압박이 해소되거나 강등(demotion) 설정이 변경될 때 kswapd 재활성화 로직 개선; page_alloc 경로에서 NUMA 제약 하의 불필요한 kswapd 트리거 감소 | NUMA 제약 환경(cpuset 또는 mbind BIND 정책)에서 kswapd의 불필요한 스왑(Swap) 폭풍(swap storm) 발생 빈도 감소 |
| 6.19 | 2026-02 | kmalloc_large()의 NUMA 메모리 정책 무시 회귀 버그 수정 — mm/slab_common.c의 정리 과정에서 large kmalloc 경로가 mempolicy를 무시하고 로컬 노드만 선호하는 문제 발생, 수정 커밋으로 재적용 | 대형 메모리 할당(kmalloc 4KB 이상)이 BIND/INTERLEAVE 정책을 올바르게 따르게 됨. AMD Threadripper 등 고코어 시스템에서 모듈 로딩 시간이 최대 60% 늘어나는 증상 해소 |
numactl -H, daxctl list, cxl list를 함께 보는 것이 현실적 디버깅 조합입니다. (3) DAMON·numa_balancing·사용자 정의 migration daemon을 동시에 켜면 정책 충돌이 발생하므로 한 축을 주 컨트롤러로 지정하세요. (4) 6.17부터 /sys/devices/system/node/nodeN/reclaim으로 노드 단위 선제적 회수가 가능합니다.
참고자료
커널 문서
- NUMA Memory Policy — NUMA 메모리 정책 관리자 가이드입니다
- NUMA — NUMA 내부 구현 문서입니다
- NUMA Performance — NUMA 성능 측정 및 튜닝 문서입니다
Man 페이지
- mbind(2) — 메모리 영역에 NUMA 정책을 설정하는 시스템 콜입니다
- set_mempolicy(2) — 프로세스 기본 NUMA 메모리 정책을 설정합니다
- numa(7) — NUMA 아키텍처 개요 man page입니다
LWN 기사
- NUMA scheduling progress — NUMA 인식 스케줄링 개선 진행 상황입니다 (2013)
- Automatic NUMA balancing — 자동 NUMA 밸런싱 메커니즘을 설명합니다 (2012)
- Memory tiering — CXL 기반 메모리 티어링과 NUMA의 관계를 다룹니다 (2022)
- NUMA in a day — NUMA 개념을 종합적으로 정리한 기사입니다 (2014)
커널 소스 코드
- mm/mempolicy.c — NUMA 메모리 정책 구현입니다
- mm/migrate.c — NUMA 페이지 마이그레이션 구현입니다
- kernel/sched/fair.c — CFS 스케줄러 내 NUMA 밸런싱 로직입니다
관련 문서
NUMA와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.