NUMA (Non-Uniform Memory Access)

NUMA: 하드웨어 토폴로지(Topology), ACPI SRAT/SLIT, pglist_data, 메모리 정책(mbind/set_mempolicy), Automatic NUMA Balancing, NUMA-aware 스케줄링, numactl, CXL, vNUMA를 다룹니다.

NUMA 하드웨어 토폴로지, ACPI 테이블 파싱, 커널 자료구조, 메모리 정책, Automatic NUMA Balancing, NUMA-aware 스케줄링, CXL 확장까지 — 비균일 메모리 접근 아키텍처를 소스 코드 수준에서 분석합니다.

관련 표준: ACPI 6.5 (SRAT/SLIT/HMAT 테이블), JEDEC DDR5 (메모리 채널 구조), CXL 3.1 (캐시 일관성(Cache Coherency) 인터커넥트) — NUMA 토폴로지 탐색과 메모리 접근에 관련된 핵심 규격입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.
참고: NUMA의 기초 개념과 pg_data_t 개요는 메모리 관리(Memory Management) — NUMA 섹션을, Hugepage와 NUMA 연동은 메모리 — Hugepage와 NUMA 섹션을 참조하세요.
전제 조건: 메모리 관리 개요CPU 캐시(Cache) 문서를 먼저 읽으세요. 메모리 서브시스템은 가상 메모리(Virtual Memory)와 물리 메모리(Physical Memory) 정책이 동시에 동작하므로, 주소 변환(Address Translation)과 회수 정책을 같이 보는 관점이 필요합니다.

핵심 요약

  • NUMA — 메모리 접근 시간이 CPU와 메모리의 물리적 위치에 따라 달라지는 아키텍처입니다.
  • Node — CPU 소켓(Socket)과 로컬 메모리를 묶은 단위. 커널에서 pglist_data 구조체(Struct)로 표현됩니다.
  • SRAT/SLIT — ACPI 테이블로 NUMA 토폴로지(노드 구성)와 거리(접근 지연(Latency))를 커널에 전달합니다.
  • 메모리 정책mbind()/set_mempolicy()로 프로세스(Process)의 메모리 할당 노드를 제어합니다.
  • NUMA Balancing — 커널이 자동으로 페이지(Page)를 자주 접근하는 CPU 노드로 마이그레이션합니다.

단계별 이해

  1. 토폴로지 확인numactl --hardware로 시스템의 NUMA 노드 수, 각 노드의 CPU와 메모리 크기를 확인합니다.

    lscpu에서도 NUMA 노드 정보를 볼 수 있습니다.

  2. 로컬 vs 원격 접근 — 같은 노드의 메모리 접근은 빠르고, 다른 노드 접근은 느립니다.

    numastat으로 노드별 할당 통계(로컬 hit/miss)를 모니터링할 수 있습니다.

  3. 정책 적용numactl --cpubind=0 --membind=0 ./app으로 특정 노드에 CPU와 메모리를 바인딩합니다.

    데이터베이스 같은 지연 민감 워크로드에서 큰 성능 차이를 만듭니다.

  4. 커널 자동 밸런싱/proc/sys/kernel/numa_balancing으로 자동 마이그레이션을 활성화/비활성화합니다.

    커널이 주기적으로 페이지 접근 패턴을 분석하여 최적 노드로 이동시킵니다.

NUMA 하드웨어 토폴로지

NUMA(Non-Uniform Memory Access) 시스템에서 각 CPU 소켓은 자신에게 직접 연결된 로컬 메모리를 가지며, 다른 소켓의 메모리에 접근할 때는 인터커넥트(QPI, UPI, Infinity Fabric 등)를 경유합니다. 이로 인해 메모리 접근 지연 시간(latency)과 대역폭(bandwidth)이 위치에 따라 달라집니다.

NUMA Node 0 CPU 0-7 L3 Cache Local Memory 64 GB DDR5 NUMA Node 1 CPU 8-15 L3 Cache Local Memory 64 GB DDR5 UPI / IF Local: ~80ns | Remote: ~140ns Local: ~80ns | Remote: ~140ns NUMA Distance Matrix Node 0→0: 10 | Node 0→1: 21 Node 1→0: 21 | Node 1→1: 10
2-소켓 NUMA 토폴로지 — 로컬/리모트 메모리 접근 지연 시간 차이

4소켓 이상의 대규모 서버에서는 인터커넥트가 멀티홉(Multi-hop)으로 구성되어 NUMA 거리 편차가 더 커집니다. 직접 연결된 노드(1홉)과 중간 노드를 경유하는 노드(2홉)의 지연 시간 차이가 크므로, 애플리케이션 배치 전략이 2소켓보다 훨씬 중요합니다.

4-소켓 NUMA 토폴로지 (Multi-hop Interconnect) Node 0 CPU 0-15 64 GB DDR5 로컬: ~80ns Node 1 CPU 16-31 64 GB DDR5 로컬: ~80ns Node 2 CPU 32-47 64 GB DDR5 로컬: ~80ns Node 3 CPU 48-63 64 GB DDR5 로컬: ~80ns 1홉 UPI ~140ns 1홉 UPI ~140ns 1홉 ~140ns 1홉 ~140ns 2홉 ~200ns 2홉 ~200ns 4-소켓 NUMA 거리 매트릭스 Node0 Node1 Node2 Node3 Node0 10 21 21 31 Node1 21 10 31 21 Node2 21 31 10 21 Node3 31 21 21 10
4-소켓 NUMA 토폴로지 — 직접 연결(1홉, 거리 21)과 중간 경유(2홉, 거리 31)의 비대칭 거리

NUMA Ratio와 영향

시스템 유형인터커넥트로컬 지연 (일반적 범위)리모트 지연 (일반적 범위)NUMA Ratio
Intel 2S (Xeon Scalable)UPI 2.0/3.0~80ns~130-150ns1.6-1.9x
Intel 4S/8SUPI (멀티홉)~80ns~170-300ns2.1-3.7x
AMD EPYC (2S)Infinity Fabric~90ns~140-160ns1.5-1.8x
AMD EPYC (NPS4)IF (소켓 내)~85ns~110-130ns1.3-1.5x
ARM64 서버CCIX/CXL~100ns~200-350ns2.0-3.5x
CXL 메모리 확장CXL 2.0~80ns~170-250ns2.1-3.1x
AMD NPS (Nodes Per Socket): AMD EPYC 프로세서는 NPS 설정으로 단일 소켓 내부를 여러 NUMA 노드로 분할합니다. NPS1(1노드), NPS2(2노드), NPS4(4노드) 모드를 지원합니다. NPS4에서는 소켓 당 4개의 CCD 그룹이 각각 독립 NUMA 노드가 되어, 로컬 메모리 접근 범위는 줄지만 지연 시간 편차가 감소합니다.

AMD EPYC NPS(Nodes Per Socket) 토폴로지

AMD EPYC 프로세서(Processor)는 CCD(Core Complex Die) 칩렛 아키텍처를 사용합니다. BIOS에서 NPS 설정을 변경하면 단일 소켓 내부의 NUMA 노드 수를 조절할 수 있습니다. NPS 값이 높을수록 로컬 메모리 범위는 줄어들지만 접근 지연 편차가 감소합니다.

AMD EPYC NPS 모드 비교 (단일 소켓) NPS1 (1 NUMA Node) CCD0 CCD1 CCD2 CCD3 IOD (통합 메모리 컨트롤러) 전체 메모리 256 GB Node 0: 모든 CPU + 전체 메모리 최대 대역폭, 높은 편차 NPS2 (2 NUMA Nodes) CCD0 CCD1 CCD2 CCD3 MC 0-3 MC 4-7 Node 0: 128GB Node 1: 128GB 소켓 ÷ 2 = 2개 NUMA 노드 균형 잡힌 대역폭/지역성 NPS4 (4 NUMA Nodes) CCD0 CCD1 CCD2 CCD3 N0 64GB N1 64GB N2 64GB N3 64GB CCD당 1개 NUMA 노드 최소 편차, 좁은 로컬 범위
AMD EPYC NPS 모드 — NPS1(전체 통합), NPS2(2분할), NPS4(CCD별 독립 노드)의 메모리-CPU 매핑
NPS 모드소켓당 노드 수노드당 메모리 채널특징적합한 워크로드
NPS11전체 (8 또는 12)최대 대역폭, 높은 지연 편차단일 대형 프로세스 (JVM, 단일 DB 인스턴스)
NPS22절반 (4 또는 6)대역폭/지역성 균형범용 서버, 가상화
NPS441/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) 슬라이스를 기준으로 분할합니다.

Intel Xeon SNC2 (Sub-NUMA Clustering) 단일 소켓 (Single Socket) SNC Domain 0 (Node 0) Core 0-11 LLC Slice 0-5 MC 0-2 (96GB) 로컬 LLC + 로컬 MC 접근 SNC Domain 1 (Node 1) Core 12-23 LLC Slice 6-11 MC 3-5 (96GB) 로컬 LLC + 로컬 MC 접근 SNC 도메인 간 접근: LLC miss 증가 BIOS 설정: SNC=Enabled → 소켓당 2개 NUMA 노드 생성 (2소켓 시스템에서 총 4개 노드) 효과: 로컬 MC 접근률 향상, LLC 히트률 향상 | 주의: 노드당 메모리 용량 감소
Intel SNC2 — 단일 소켓 내 코어/LLC/메모리 컨트롤러를 2개 NUMA 도메인으로 분할
NPS/SNC 변경 주의: NPS나 SNC 설정을 변경하면 시스템의 NUMA 토폴로지가 바뀌므로, numactl 바인딩 스크립트, cgroup cpuset.mems 설정, IRQ 친화성(Affinity) 규칙을 모두 재검토해야 합니다. 변경 전후에 numactl --hardware로 토폴로지를 확인하세요.
CPU 토폴로지: AMD CCX/CCD/IOD 칩렛 구조, Intel Tile/Hybrid 아키텍처, ARM DynamIQ, Infinity Fabric, 스케줄링 도메인(Scheduling Domain) 계층에 대한 종합적인 내용은 CPU 토폴로지 페이지를 참조하세요.

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
HMAT와 메모리 티어링: 커널의 메모리 티어링 프레임워크(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])
pglist_data → zone → zonelist 관계 (Node 0) pg_data_t (Node 0) node_id = 0 node_start_pfn node_present_pages node_zones[3]: [0] ZONE_DMA (0-16MB) [1] ZONE_DMA32 (16MB-4GB) [2] ZONE_NORMAL (4GB+) node_zonelists[2]: [0] ZONELIST_FALLBACK [1] ZONELIST_NOFALLBACK kswapd (kswapd0) __lruvec (LRU 벡터) per_cpu_nodestats ZONELIST_FALLBACK (Node 0 기준) 할당 시도 순서 (로컬 노드 우선 → SLIT 거리 순): N0:NORMAL N0:DMA32 N0:DMA ↓ 로컬 노드 소진 시 리모트 폴백 N1:NORMAL N1:DMA32 N1:DMA → ... struct zone 주요 필드 _watermark[NR_WMARK] — min/low/high 워터마크 (kswapd 기동 기준) free_area[MAX_ORDER+1] — 버디 할당기 프리 리스트 (order별) zone_pgdat — 소속 pglist_data 포인터 (역참조) zone_start_pfn — 존 시작 페이지 프레임 번호 managed_pages — 버디 할당기가 관리하는 페이지 수 spanned_pages — 시작~끝 전체 범위 (hole 포함) present_pages — 실제 물리적으로 존재하는 페이지 수
pglist_data(Node 0) — 존 배열, zonelist 폴백 순서, zone 구조체의 주요 필드

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 도구로 제어합니다.

정책 유형

정책상수동작용도
DefaultMPOL_DEFAULT프로세스가 실행 중인 CPU의 로컬 노드에서 할당대부분의 일반 워크로드
BindMPOL_BIND지정된 노드 집합에서만 할당 (실패 시 OOM)메모리 격리(Isolation), 전용 노드
PreferredMPOL_PREFERRED선호 노드에서 우선 할당, 실패 시 다른 노드 폴백소프트 바인딩
Preferred ManyMPOL_PREFERRED_MANY여러 선호 노드 지정 가능, 순서대로 시도유연한 선호 (v5.15+)
InterleaveMPOL_INTERLEAVE지정된 노드들에 라운드-로빈으로 페이지 분산대역폭 극대화, 해시 테이블(Hash Table)
LocalMPOL_LOCAL항상 현재 CPU의 로컬 노드에서 할당마이그레이션 후에도 로컬 유지
Weighted InterleaveMPOL_WEIGHTED_INTERLEAVE노드별 대역폭 가중치로 분산CXL 이종 메모리 (v6.9+)

시스템 콜

#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, ... */
);

/* 페이지의 현재 노드 위치 조회 */
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
);

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 지역성을 최적화합니다.

동작 원리

Automatic NUMA Balancing은 4단계 파이프라인(Pipeline)으로 동작합니다. 스캔 → 폴트 → 판단 → 마이그레이션 순서로, 커널이 자동으로 페이지와 태스크(Task)의 NUMA 배치를 최적화합니다.

Automatic NUMA Balancing 파이프라인 1. 스캔 (Scan) task_tick_numa() 주기적 호출 VMA 스캔 PTE → PROT_NONE (접근 비트 제거) 2. 폴트 (Fault) PROT_NONE 접근 시 page fault 발생 do_numa_page() 접근 CPU/노드 기록 PTE 권한 복원 3. 판단 (Decision) 접근 노드 vs 현재 페이지 노드 numa_migrate_prep() 마이그레이션 후보 등록 or 태스크 이동 판단 4. 마이그레이션 페이지 이동 또는 태스크 이동 migrate_misplaced _page() or task migration 스캔 주기 적응 (Adaptive Scan Period) NUMA fault 많음 (locality 나쁨) → 스캔 주기 감소 (더 자주 스캔) → scan_period_min_ms (1000ms) NUMA fault 적음 (locality 좋음) → 스캔 주기 증가 (덜 스캔) → scan_period_max_ms (60000ms) 한 번에 스캔하는 크기: scan_size_mb (기본 256MB) — VMA를 순차 스캔하며 점진적으로 전체 주소 공간 커버 경로 A: 페이지 마이그레이션 페이지가 잘못된 노드에 있을 때 migrate_misplaced_page()로 올바른 노드로 이동 비용: 페이지 복사 (4KB~2MB) + PTE 재매핑 장점: 메모리 로컬리티 직접 개선 통계: /proc/vmstat → numa_pages_migrated 경로 B: 태스크 마이그레이션 태스크가 잘못된 노드에서 실행 중일 때 CFS 스케줄러가 태스크를 페이지가 있는 노드로 이동 비용: 캐시 미스 (L1/L2/L3 콜드 시작) 장점: 대량 페이지 이동 없이 로컬리티 확보 참고: numa_preferred_nid 기반 판단
Automatic NUMA Balancing — 스캔 → 폴트 → 판단 → 마이그레이션 4단계와 두 가지 최적화 경로

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
NUMA Balancing 비활성화가 좋은 경우: 1) 이미 numactl --membind로 메모리를 명시적으로 바인딩한 경우, 2) 대규모 인메모리 DB(Redis, memcached)에서 마이그레이션 오버헤드(Overhead)가 크면 성능 저하, 3) 실시간(RT) 워크로드에서 NUMA fault의 지연 시간 편차가 허용 불가, 4) KVM 게스트에서 호스트의 NUMA balancing과 충돌할 수 있음.

NUMA-aware 스케줄링

CFS 스케줄러(Scheduler)는 NUMA 토폴로지를 인식하여 태스크를 배치합니다. 스케줄링 도메인(sched_domain) 계층이 NUMA 거리를 반영합니다.

스케줄링 도메인 계층

2-소켓 서버의 스케줄링 도메인 계층 SD_NUMA (전체 시스템) flags: SD_NUMA | SD_SERIALIZE — NUMA 간 마이그레이션 (비용 최대) SD_MC / SD_DIE (Node 0) flags: SD_SHARE_PKG_RESOURCES | SD_ASYM_PACKING SD_MC / SD_DIE (Node 1) flags: SD_SHARE_PKG_RESOURCES | SD_ASYM_PACKING SD_SMT Core 0 HT0 | HT1 SD_SMT Core 1 HT0 | HT1 SD_SMT Core 0 HT0 | HT1 SD_SMT Core 1 HT0 | HT1 로드 밸런싱 동작 ① SMT → MC → NUMA 순으로 상위 도메인 탐색 (비용 순) ② NUMA 도메인에서의 마이그레이션 비용이 가장 높음 ③ 확인: /proc/sys/kernel/sched_domain/cpu0/domain{0,1,2}/... domain0 = SMT, domain1 = MC/DIE, domain2 = NUMA ④ numactl --hardware 로 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;
}
NUMA 페이지 마이그레이션 흐름 (6단계) 1. 할당 대상 노드에서 새 folio 할당 alloc_misplaced 2. 잠금 원본 folio lock_page() LRU 격리 3. 언매핑 모든 PTE에서 원본 제거 migration entry 4. 복사 페이지 데이터 copy_highpage() 4KB 또는 2MB 5. 재매핑 migration entry → 새 folio PTE TLB flush 6. 해제 원본 folio 반환 3단계: Migration Entry 상세 • PTE를 migration entry로 교체 (try_to_migrate) • 다른 프로세스가 접근하면 migration entry에서 대기 • 마이그레이션 완료 시 자동 깨어남 → 새 PTE로 접근 4단계: 복사 비용 • 4KB 페이지: ~1μs (메모리 대역폭 의존) • 2MB THP: ~500μs (512배 비용) • 1GB HugePage: ~250ms (마이그레이션 거의 불가) 마이그레이션 실패 경로 실패 원인: 대상 노드 메모리 부족 | folio가 pinned (get_user_pages) | writeback 진행 중 | folio가 잠금 경합 | 속도 제한 초과 실패 처리: putback_movable_pages()로 원본 folio를 LRU에 복귀 → 다음 NUMA fault에서 재시도 통계: /proc/vmstat → pgmigrate_fail (실패), pgmigrate_success (성공), numa_pages_migrated (NUMA용)
NUMA 페이지 마이그레이션 6단계 — 할당/잠금/언매핑/복사/재매핑/해제와 실패 경로

마이그레이션 도구

# 프로세스의 모든 페이지를 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 high (24576) low (20480) ← kswapd 깨어남 min (16384) ← direct reclaim OOM 위험 kswapd0 Node 0 전용 회수 LRU Active/Inactive free: 48,230 MB used: 17,306 MB slab: 1,530 MB Node 1 high — kswapd 슬립 kswapd1 Node 1 전용 회수 LRU Active/Inactive free: 51,200 MB used: 14,336 MB slab: 1,280 MB 주의: 노드별 독립 회수의 함정 Node 0이 메모리 부족(OOM 위험)이어도 Node 1에 충분한 여유가 있으면 시스템 전체 OOM이 아닌 Node 0만 OOM 발생 가능 → zone_reclaim_mode / MPOL_PREFERRED 활용
노드별 kswapd — 독립적 워터마크 기반 회수와 노드 간 메모리 불균형 문제
/*
 * 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)을 반드시 고려해야 합니다.

PCIe 장치와 NUMA 노드 친화성 Node 0 (CPU 0-15 + 64GB DRAM) PCIe Root 0 NVMe 0 NIC eth0 GPU 0 로컬 접근 최적 성능 Node 1 (CPU 16-31 + 64GB DRAM) PCIe Root 1 NVMe 1 NIC eth1 로컬 접근 최적 성능 크로스 노드 I/O: 지연 증가 NUMA-aware I/O 최적화 체크리스트 1. 장치 NUMA 노드 확인: cat /sys/class/net/eth0/device/numa_node, cat /sys/block/nvme0n1/device/numa_node 2. IRQ 친화성: NIC/NVMe IRQ를 장치 로컬 노드의 CPU에 바인딩 (irqbalance 또는 수동 smp_affinity 설정) 3. 애플리케이션 배치: I/O 집약 스레드를 장치와 같은 NUMA 노드에서 실행 (numactl --cpunodebind) 4. DMA 버퍼: 장치 로컬 노드에서 할당 (dev_to_node(), alloc_pages_node()) 5. io_uring/aio: 제출 CPU와 장치 NUMA 노드 일치 확인 → 불일치 시 IOPS 최대 30% 감소 가능
PCIe 장치와 NUMA — 장치별 로컬 노드 식별과 I/O 최적화 체크리스트

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: irqbalance 데몬은 기본적으로 NUMA 토폴로지를 인식하여 IRQ를 장치의 로컬 노드에 배분합니다. 그러나 고성능 환경에서는 irqbalance를 비활성화하고 수동으로 /proc/irq/*/smp_affinity를 설정하는 것이 더 안정적입니다. 특히 DPDK나 SPDK를 사용하는 경우 IRQ 바인딩을 직접 관리해야 합니다.

CXL과 이종 NUMA

CXL(Compute Express Link)은 CPU에 외부 메모리를 연결하여 NUMA 노드로 노출하는 새로운 인터커넥트 기술입니다. CXL 메모리는 로컬 DRAM보다 지연이 크지만, 용량 확장과 비용 효율성을 제공합니다.

CXL 메모리 토폴로지

CPU Socket 0 Node 0 Local DRAM 64 GB ~80 ns 초기 레이턴시 CXL Device Node 2 CXL Memory 256 GB ~170 ns CXL 경유 레이턴시 CXL Link ~170 ns CPU Socket 1 Node 1 Local DRAM 64 GB ~80 ns 거리 매트릭스 (Distance Matrix) Node0 Node1 Node2 Node0 10 21 28 Node1 21 10 31 Node2 28 31 10
# 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

메모리 티어링 (Memory Tiering)

메모리 티어링 (Memory Tiering) — 프로모션/디모션 Tier 0 — Fast (DRAM) Node 0, Node 1 (로컬 DDR5) ~80ns 지연 | 51.2 GB/s 대역폭 핫 데이터: 자주 접근하는 페이지 memtier = 1 Tier 1 — Slow (CXL/PMEM) Node 2 (CXL Memory) ~170ns 지연 | 32 GB/s 대역폭 콜드 데이터: 드물게 접근하는 페이지 memtier = 2 프로모션 ↑ 디모션 ↓ 프로모션 (Tier 1 → Tier 0) 트리거: NUMA hinting fault (Tier 1 페이지에 빈번한 접근) 경로: do_numa_page() → migrate_misplaced_page() 조건: Tier 0에 여유 메모리 있을 때 통계: /proc/vmstat → pgpromote_success 비용: 페이지 복사 + PTE 재매핑 + TLB flush 이득: 지연 시간 ~170ns → ~80ns (2배 개선) 디모션 (Tier 0 → Tier 1) 트리거: kswapd가 Tier 0 워터마크 부족 감지 경로: shrink_folio_list() → demote_folio_list() 대상: LRU 비활성(inactive) 리스트의 콜드 페이지 통계: /proc/vmstat → pgdemote_kswapd, pgdemote_direct 장점: swap-out 대비 대폭 빠름 (디스크 I/O 회피) 설정: demotion_enabled = 1, demotion_targets 설정
메모리 티어링 — DRAM(Tier 0)과 CXL(Tier 1) 간 자동 프로모션/디모션 흐름과 트리거 조건
/*
 * 커널 메모리 티어링 (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에 의한 디모션
참고: CXL.mem/cache/io 프로토콜, Type1/2/3 장치, drivers/cxl/ 구조, memory_tier 자동 demotion/promotion, LLM KV 캐시 CXL 배치는 CXL 메모리 페이지에서 자세히 다룹니다.

가상화(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>
vNUMA 핀닝: 게스트의 vNUMA 노드를 호스트의 물리 NUMA 노드에 고정(pin)하는 것이 중요합니다. 핀닝 없이 게스트의 vCPU가 호스트의 다른 노드로 마이그레이션되면, 게스트 내부에서 "로컬"이라고 인식한 메모리가 실제로는 호스트의 리모트 노드에 있게 되어 성능이 급격히 저하됩니다.

성능 분석과 최적화

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 매핑, 메모리 크기, 거리 매트릭스 한 번에 확인
Intel MLC 설치: Intel MLC는 https://www.intel.com/content/www/us/en/developer/articles/tool/intelr-memory-latency-checker.html에서 무료로 다운로드할 수 있습니다. AMD 시스템에서도 대부분 동작하지만, AMD 전용으로는 AMD uProf의 메모리 프로파일링 기능을 사용할 수 있습니다. STREAM은 https://www.cs.virginia.edu/stream/에서 소스를 받아 gcc -O3 -fopenmp -DSTREAM_ARRAY_SIZE=...으로 컴파일합니다.

최적화 패턴

문제진단해결
높은 리모트 접근 비율numastatnuma_miss 비율 확인numactl --membind 또는 --cpunodebind
NIC IRQ가 리모트 노드 CPU에서 처리cat /proc/irq/*/smp_affinityIRQ를 NIC 로컬 노드 CPU에 바인딩
대형 해시 테이블의 불균일 접근perf c2c 분석numactl --interleave=all
NUMA balancing 오버헤드/proc/vmstatnuma_hint_faultssysctl 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 에뮬레이션: 단일 소켓(UMA) 시스템에서 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-missesnumactl --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_migratedDB 프로세스에 membind 적용
멀티스레드 앱 확장성 저하스레드 간 false sharing + cross-nodeperf 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가 잘못된 노드에 배치 네트워크 처리량 저하, 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 공유 버퍼를 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);  /* 전체 순회는 드물게만 */
First-Touch 정책: Linux의 기본 메모리 정책(MPOL_DEFAULT)은 first-touch입니다. 즉, 페이지는 처음 접근(touch)하는 CPU의 NUMA 노드에 할당됩니다. 따라서 대형 배열을 한 스레드에서 초기화한 후 여러 스레드에서 접근하면, 모든 메모리가 초기화 스레드의 노드에 집중됩니다. 멀티스레드 초기화(parallel first-touch) 또는 MPOL_INTERLEAVE로 분산하는 것이 핵심 패턴입니다.

NUMA 초기화 흐름

커널 부팅 시 NUMA 토폴로지 초기화는 여러 단계를 거칩니다. ACPI 테이블 파싱부터 pglist_data 구성, zonelist 빌드, kswapd 기동까지의 전체 흐름을 살펴봅니다.

NUMA 초기화 흐름 (x86_64 부팅 시) BIOS/UEFI 펌웨어 하드웨어 탐색 → SRAT(CPU↔Node, Memory↔Node) + SLIT(거리 매트릭스) + HMAT(지연/대역폭) ACPI 테이블 생성 start_kernel() → setup_arch() acpi_numa_init() → SRAT/SLIT 파싱 numa_init() → memblk 등록 + CPU→Node 매핑 결과: numa_meminfo, numa_distance[][] mm_core_init() free_area_init() → pg_data_t, zone 초기화 build_all_zonelists() → SLIT 거리 순 정렬 결과: node_data[], zonelist 완성 smp_init() → sched_init_domains() NUMA 거리 기반 sched_domain 계층 구성 결과: SD_NUMA 도메인, 노드별 밸런싱 그룹 kswapd_init() 각 NUMA 노드마다 kswapd 스레드 기동 결과: kswapd0, kswapd1, ... 노드별 회수 런타임 NUMA 관리 (부팅 완료 후) • Automatic NUMA Balancing: task_tick_numa() → VMA 스캔 → NUMA hinting fault → 페이지/태스크 마이그레이션 • 사용자 정책: set_mempolicy() / mbind() / numactl → alloc_pages_mpol()에서 정책 적용 • 메모리 티어링: kswapd → 콜드 페이지 디모션(Tier0→Tier1) / NUMA fault → 핫 페이지 프로모션(Tier1→Tier0)
NUMA 초기화 전체 흐름 — 펌웨어 ACPI 테이블 → 커널 파싱 → 자료구조 구성 → 런타임 관리
/*
 * 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: 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;
}
Hugepage: hugetlbfs 마운트(Mount) 옵션, THP 정책(always/madvise/never), khugepaged 튜닝, compound page 구조에 대한 상세 내용은 Huge Pages 문서를 참조하세요.

NUMA 배치 최적화 플레이북

NUMA 성능 저하는 대부분 원격 메모리 접근 증가에서 시작됩니다. CPU 바인딩과 메모리 바인딩을 함께 설정하고, 자동 밸런싱이 실제로 도움되는지 워크로드별로 검증해야 합니다.

워크로드 유형권장 정책확인 지표
지연 민감 DB--cpunodebind + --membindremote 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가 발생할 때마다 실행되며, 페이지와 태스크의 최적 배치를 결정합니다.

NUMA Balancing 판단 경로 (Decision Chain) do_numa_page() mm/memory.c — NUMA hinting fault 진입점 PTE 복원 + 페이지 노드 확인 page_nid, last_cpupid task_numa_fault() kernel/sched/fair.c — 폴트 통계 기록 numa_faults[] 갱신 + scan_period 조정 scan_seq 체크 → placement 호출 결정 scan_seq 변경 시 task_numa_placement() kernel/sched/fair.c — 최적 노드 결정 모든 노드의 faults 비교 → preferred_nid 갱신 group_weight/task_weight 스코어 계산 더 나은 노드 발견 시 task_numa_find_cpu() 대상 노드에서 교환 가능한 CPU/태스크 탐색 task_numa_compare()로 스왑 이득 평가 태스크 마이그레이션 preferred_nid 갱신 CFS가 다음 wakeup에서 이동 현재 배치 유지 이동 이득 부족 다음 스캔까지 대기 numa_group 있으면 그룹 faults도 함께 갱신/평가 ※ 전체 체인은 page fault 컨텍스트에서 실행 (프로세스 컨텍스트, preemptible)
NUMA Balancing 판단 경로 — do_numa_page()에서 task_numa_find_cpu()까지의 함수 호출 체인

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 그룹으로 합쳐집니다.

numa_group과 task_struct NUMA 필드 관계 struct numa_group refcount_t refcount 참조 카운터 int nr_tasks 그룹 태스크 수 pid_t gid 그룹 ID (첫 PID) spinlock_t lock 동시성 보호 int active_nodes 활성 NUMA 노드 수 unsigned long total_faults 총 폴트 카운트 unsigned long max_faults_cpu 최대 CPU 폴트 unsigned long faults_cpu[] CPU 노드별 폴트 unsigned long faults[] MEM 노드별 폴트 faults[] 크기: 4 × nr_node_ids (priv/shared × MEM/CPU) task_struct (NUMA 필드) int numa_preferred_nid 선호 노드 unsigned long numa_scan_seq 스캔 시퀀스 unsigned long numa_scan_period 현재 주기 unsigned long numa_scan_offset VMA 스캔 위치 unsigned long *numa_faults 폴트 배열 포인터 unsigned long total_numa_faults 총 폴트 수 unsigned long numa_pages_migrated 마이그레이션 수 struct numa_group *numa_group 그룹 포인터 unsigned long numa_faults_locality[3] 지역성 통계 numa_faults_locality[0]=remote [1]=local, [2]=private numa_group 포인터 NUMA 그룹 형성 과정 1. 태스크 A가 페이지 P에 NUMA fault → task_numa_fault()에서 last_cpupid 기록 2. 태스크 B가 같은 페이지 P에 NUMA fault → last_cpupid가 태스크 A의 것 3. task_numa_group() 호출 → A와 B가 같은 numa_group에 합류 4. 그룹의 faults[]가 모든 멤버 태스크의 접근 패턴을 통합 → 스케줄러가 그룹 전체를 같은 노드에 배치 5. 그룹 해체: nr_tasks가 1이 되면 해제, 태스크가 exit하거나 exec하면 탈퇴
numa_group과 task_struct의 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/vmstatpgmigrate_success/pgmigrate_fail 통계에서 사유별로 구분됩니다.
  • 31행마이그레이션 실패 시 putback_movable_pages()로 원본 folio를 LRU에 복귀시킵니다. 대상 노드 메모리 부족이나 folio가 pinned 상태일 때 실패할 수 있습니다.
  • 47행numabalancing_migrate_next_window는 다음 윈도우 시작 시간입니다. 2초(2*HZ) 간격으로 윈도우가 갱신되며, 각 윈도우에서 마이그레이션 카운터가 초기화됩니다.
마이그레이션 비용: NUMA 페이지 마이그레이션은 페이지 데이터 복사(4KB 또는 2MB THP), PTE 언매핑/재매핑, TLB 플러시를 수반합니다. 특히 THP의 경우 2MB를 복사해야 하므로 마이그레이션 비용이 512배 증가합니다. 이 때문에 커널은 rate_limited_count_exceeded()로 마이그레이션 속도를 제한하고, 실제로 이득이 있는 경우에만 마이그레이션을 수행합니다.

NUMA 메모리 정책 커널 경로

set_mempolicy() 시스템 콜은 프로세스의 NUMA 메모리 할당 정책을 설정합니다. 커널 내부에서 struct mempolicy로 표현되며, 페이지 할당 시 alloc_pages_mpol()이 정책에 따라 노드를 선택합니다.

NUMA 메모리 정책 (mempolicy) 유형과 할당 경로 alloc_pages_mpol() 정책에 따라 노드 선택 pol->mode switch (pol->mode) MPOL_DEFAULT 로컬 노드 할당 numa_node_id() 폴백: zonelist 순 MPOL_BIND 지정 노드만 허용 nodemask 제한 실패 시 OOM MPOL_INTERLEAVE 라운드-로빈 분산 interleave_nid() 대역폭 균등화 MPOL_PREFERRED 선호 노드 우선 실패 시 다른 노드 소프트 바인딩 WEIGHTED_INTLV 가중치 기반 분산 BW 비례 할당 CXL 이종 메모리 struct mempolicy 주요 필드 atomic_t refcnt — 참조 카운터 (여러 VMA/태스크가 공유 가능) unsigned short mode — MPOL_DEFAULT/BIND/PREFERRED/INTERLEAVE/LOCAL/WEIGHTED_INTERLEAVE unsigned short flags — MPOL_F_STATIC_NODES, MPOL_F_RELATIVE_NODES, MPOL_F_NUMA_BALANCING nodemask_t nodes — 대상 NUMA 노드 비트마스크 (BIND/INTERLEAVE에서 사용) int home_node — 선호 노드 (MPOL_BIND + home_node: v5.17+, 폴백 순서 결정) union { int preferred_nid; nodemask_t preferred_nodes; } — PREFERRED/PREFERRED_MANY 대상
NUMA 메모리 정책 — alloc_pages_mpol()의 정책별 분기와 struct mempolicy 구조
/* 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;
}
코드 설명
  • 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_PREFERREDpolicy_node()로 선호 노드를 반환하지만, 할당 실패 시 일반 zonelist 폴백이 동작합니다.
  • 56행MPOL_BIND__GFP_HARDWALL을 추가하여 cpuset/mempolicy 범위 밖 노드로의 폴백을 차단합니다.
  • 59행interleave_nid()는 페이지 오프셋(ilx)을 기반으로 노드를 라운드-로빈 선택합니다. 이를 통해 큰 메모리 영역의 페이지가 여러 노드에 균등하게 분산됩니다.
  • 63행weighted_interleave_nid()는 v6.9에서 추가된 함수로, 각 노드의 대역폭 가중치(/sys/kernel/mm/mempolicy/weighted_interleave/nodeN)에 비례하여 페이지를 분배합니다.

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_compare(): task_numa_find_cpu() 내부에서 각 후보 CPU의 현재 태스크와 교환(swap)했을 때의 이득을 평가합니다. 교환 전후의 task_weight + group_weight 합계를 비교하여, 양쪽 모두에게 이득이 있거나 총합이 증가하는 경우에만 교환을 승인합니다. 이는 한 태스크의 지역성을 개선하기 위해 다른 태스크의 지역성을 희생하는 것을 방지합니다.

참고자료

커널 문서

Man 페이지

LWN 기사

커널 소스 코드

NUMA와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.