Skip to content

Latest commit

 

History

History
850 lines (574 loc) · 41.2 KB

File metadata and controls

850 lines (574 loc) · 41.2 KB

6.1 NUMA 아키텍처

NUMA는 Non-Uniform Memory Access의 약자로, 번역하면 불균형 메모리 접근이라는 뜻이며 멀티 프로세스 환경에서 적용되는 메모리 접근 방식이다.

그림은 UMA(Uniform Memory Access), 즉 NUMA와 반대되는 개념으로 초창기 아키텍처라고 볼 수 있다. 이 방식에서는 모든 프로세서가 공용 BUS를 이용해서 메모리에 접근한다. 이 방식의 문제점은 BUS를 동시에 사용할 수 없는 것으로, 0번 소켓에 있는 CPU가 메모리에 접근하는 동안 1번 소켓에 있는 CPU는 메모리에 접근할 수가 없다.

그림은 우리가 이번 장에서 이야기할 NUMA의 개념도이다. UMA와 다른 점은 로컬 메모리로의 접근이 동시에 이뤄질 수 있다는 것이다. 0번 CPU가 자신의 로컬 메모리에 접근하는 동안 1번 CPU도 자신의 메모리에 접근할 수 있어서 성능이 향상된다. 하지만 로컬 메모리의 양이 모자라면 다른 CPU에 붙어있는 메모리에 접근이 필요하게 되고, 이때 메모리 접근에 시간이 소요되어 예상치 못한 성능 저하를 경험하게 된다. 그래서 로컬 메모리에서 얼마나 많이 메모리 접근이 일어나느냐가 성능 향상의 가장 중요한 포인트이다.

💡

각각의 cpu마다 별도의 메모리가 있는데 이와 같이 메모리에 접근하는 방식을 로컬 액세스(Local Access)라고 한다. 그리고 이렇게 CPU와 메모리를 합쳐서 노드라고 부른다. NUMA에서는 자신의 메모리가 아닌 다른 노드에 있는 메모리에도 접근할 수 있으며, 이것을 리모트 액세스라고 부른다.

6.2 리눅스에서의 NUMA 확인

리눅스에서는 NUMA를 활용하기 위한 코드를 구현해놓았고 명령어를 통해서 현재 시스템의 NUMA 상태를 확인할 수 있다.

먼저 numactl 명령어를 살펴보자. numactl은 NUMA와 관련된 정책을 확인하거나 설정할 때 사용한다. --show 명령으로 NUMA 정책을 확인해보자.

ubuntu@ip-172-31-0-13:~$ numactl --show
policy: default  // 1
preferred node: current
physcpubind: 0 
cpubind: 0 
nodebind: 0 
membind: 0 
preferred: 
  1. 기본 정책이 default인 것을 알 수 있다. default는 현재 사용중인 프로세스가 포함된 노드에서 메모리를 먼저 가져다가 사용하는 방식이다.

NUMA와 관련된 메모리 할당 정책은 총 4가지이다.

[운영체제] 프로세스와 프로세서의 차이

  1. default 정책: 별도의 설정을 하지 않는 한 모든 프로세스에 적용되며, 현재 프로세스가 실행되고 있는 프로세서(CPU)가 포함된 노드에서 먼저 메모리를 할당 받아 사용한다.
  2. bind 정책: 특정 프로세스를 특정 노드에 바인딩시키는 방식을 취한다. 예를 들어 0번 노드에 할당하면 0번 노드에서만 메모리를 할당 받는다. 이 경우 메모리의 지역성이 좋아지기 때문에 메모리 접근 속도가 빨라서 성능이 좋아질 수 있지만, bind에 설정한 노드의 메모리가 부족하면 성능이 급격히 나빠질 수도 있다.
  3. preferred 정책: bind와 비슷하지만 선호하는 노드를 설정한다. bind가 반드시 설정한 노드에서 메모리를 할당받는 반면에 preferred는 가능한 한 설정한 노드로부터 메모리를 할당 받는다.
  4. interleaved 정책: 다수의 노드에서 거의 동일한 비율로 메모리를 할당 받는다. 라운드 로빈 정책에 따라 다수의 노드로부터 한 번씩 돌아가면서 메모리를 할당받는다.

4. NUMA Architecture.

다음은 -H 옵션이다.

ubuntu@ip-172-31-0-13:~$ numactl -H
available: 1 nodes (0)    // 1
node 0 cpus: 0     // 2
node 0 size: 957 MB
node 0 free: 133 MB
node distances:    // 3
node   0 
  0:  10 
  1. NUMA 노드가 1개로 구성되어 있음을 볼 수 있다.
  2. 각각의 노드 0에 해당하는 CPU의 번호와 각 노드에 할당된 메모리의 크기이다. CPU 번호는 시스템마다 조금씩 다를 수 있다.
  3. 각 노드의 메모리에 접근하는데 걸리는 시간을 의미한다. 각각의 로컬 메모리에 접근할 때 소요되는 시간을 10이라고 한다면, 즉 0번 노드에서 1번 노드에 있는 메모리에 접근하거나 1번 노드에서 0번 노드에 있는 메모리에 접근할 때 소요되는 시간은 20임을 보여준다. 이는 절대적인 시간이 아니라 상대적인 값으로, 리모트 메모리에 접근하는 시간이 로컬 메모리에 접근하는데 필요한 시간의 2배라는 뜻이다.

다음은 NUMA 환경에서 현재 시스템에 할당된 메모리의 상태를 확인할 때 사용하는 명령어인 numastat이다.

ubuntu@ip-172-31-0-13:~$ numastat -cm

Per-node system memory usage (in MBs):
Token Unaccepted not in hash table.
                 Node 0 Total
                 ------ -----
MemTotal            957   957
MemFree             133   133
MemUsed             824   824
SwapCached            0     0
Active              318   318
Inactive            357   357
Active(anon)         72    72
Inactive(anon)        2     2
Active(file)        246   246
Inactive(file)      355   355
Unevictable          39    39
Mlocked              27    27
Dirty                 0     0
Writeback             0     0
FilePages           623   623
Mapped               90    90
AnonPages            91    91
Shmem                 1     1
KernelStack           2     2
PageTables            3     3
SecPageTables         0     0
NFS_Unstable          0     0
Bounce                0     0
WritebackTmp          0     0
Slab                 70    70
SReclaimable         26    26
SUnreclaim           44    44
AnonHugePages         0     0
ShmemHugePages        0     0
ShmemPmdMapped        0     0
FileHugePages         0     0
FilePmdMapped         0     0
HugePages_Total       0     0
HugePages_Free        0     0
HugePages_Surp        0     0
KReclaimable         26    26

numastat 명령이 중요한 이유는 NUMA 아키텍처에서 메모리 불균형 상태를 확인할 수 있기 때문이다. 어느 한 쪽 노드의 메모리 사용률이 높으면 메모리 할당 정책에 따라 swap을 사용하는 경우도 있기 때문이다. 분명이 전체 메모리에는 free 영역이 많이 있는데도 불구하고 메모리 할당 정책에 따라 한쪽 노드에서 메모리 할당이 과하게 일어나면 swap을 사용하게 되며, 이런 상태를 numastat을 통해서 확인할 수 있다.

이번에는 프로세스가 어떤 메모리 할당 정책으로 실행되었는지 확인하는 방법을 살펴보자. /proc/{pid}/numa_maps에는 현재 동작 중인 프로세스의 메모리 할당 정책과 관련된 정보가 기록된다.

ubuntu@ip-172-31-0-13:/proc$ sudo cat /proc/568/numa_maps 
5a45b5386000 default file=/usr/lib/systemd/systemd-logind mapped=12 active=0 N0=12 kernelpagesize_kB=4
5a45b5392000 default file=/usr/lib/systemd/systemd-logind mapped=38 active=0 N0=38 kernelpagesize_kB=4
5a45b53b8000 default file=/usr/lib/systemd/systemd-logind mapped=14 active=0 N0=14 kernelpagesize_kB=4
5a45b53c7000 default file=/usr/lib/systemd/systemd-logind anon=5 dirty=5 active=0 N0=5 kernelpagesize_kB=4
5a45b53cc000 default file=/usr/lib/systemd/systemd-logind anon=1 dirty=1 active=0 N0=1 kernelpagesize_kB=4
5a45cbf15000 default heap anon=46 dirty=46 active=0 N0=46 kernelpagesize_kB=4
...

위 프로세스는 default 정책으로 실행된 것이다.

6.3 메모리 할당 정책별 특징

먼저 default 정책을 살펴보자. default는 리눅스 메모리 할당의 기본 정책으로, 아무것도 해주지 않아도 된다. 컴파일된 프로그램을 실행시켜보면 다음과 같다.

ubuntu@ip-172-31-0-13:~$ ./malloc_test 
Allocated 1048576 MB
Allocated 2097152 MB
Allocated 3145728 MB
Allocated 4194304 MB
Allocated 5242880 MB
Allocated 6291456 MB
Allocated 7340032 MB
Allocated 8388608 MB
Allocated 9437184 MB
Allocated 10485760 MB
Allocated 11534336 MB
...
ubuntu@ip-172-31-0-13:~$ numastat `pidof malloc_test`

Per-node process memory usage (in MBs) for PID 1376 (malloc_test)
                           Node 0           Total
                  --------------- ---------------
Huge                         0.00            0.00
Heap                         0.00            0.00
Stack                        0.01            0.01
Private                     40.76           40.76
----------------  --------------- ---------------
Total                       40.78           40.78

numastat 명령으로 확인해보면 실제로 프로세스가 어떤 노드로부터 메모리를 할당받아서 사용하고 있는지 확인할 수 있다. 위 결과를 보면 0번 노드에서 할당 받아서 동작중임을 알 수 있다.

default로 설정하면 현재 프로세스가 동작 중인 CPU가 속한 노드에서 메모리를 할당받는다. preferred 정책과 비슷하게 보일 수도 있는데, preferred 정책은 특정 노드를 선택하고 가능하면 그 노드에서 메모리를 할당 받으려 하는 방식인 반면, default 정책은 그 순간순간 프로세스가 동작하고 있는 CPU를 기준으로 할당받는다.

커피닉스, 시스템 엔지니어의 쉼터 - 커피향이 나는 LINUX/UNIX 세계

이 테스트 결과로 default 정책을 적용하면 프로세스가 동작중인 CPU의 노드에 따라 메모리를 할당한다는 것을 확인했다. 즉, A라는 프로세스가 0번 노드에 있는 CPU를 할당받아 동작하다가 1번 노드에 있는 CPU를 할당받아 동작하게 되면 기존 0번 노드에서 돌때 확보한 메모리는 로컬 메모리가 아닌 원격 메모리가되고, 이후의 접근은 로컬 액세스가 아닌 리모트 액세스가 된다. 그래서 메모리 접근에 더 많은 시간이 소요되고 메모리 지역성이 떨어진다. 하지만 리눅스 스케줄러는 가능한 한 기존에 바인딩된 노드에 계속 바인딩되도록 하려는 경향이 있기 때문에 한쪽 노드에서 할당 가능한 메모리의 양을 넘지 않는 한 크게 문제가 되진 않는다.

  • 원격 접근?

    이 질문 아주 정확한 지점을 짚었어.

    결론부터 말하면, 네가 쓴 설명은 방향은 맞는데, 한 문장이 오해를 만들 수 있어.

    그래서 **“CPU 0 → CPU 1로 옮기면 메모리를 둘 다 접근하나?”**라는 의문이 생긴 거야.

    차근차근 정리해줄게.


    먼저 한 줄로 정답

    아니야.

    CPU가 1번 노드로 이동했다고 해서

    자동으로 1번 노드 메모리만 쓰는 게 아니다.

    이미 할당된 메모리는 그대로 0번 노드에 남아 있고,

    CPU 1번에서 실행되면 0번 노드 메모리를 ‘원격 접근’하게 된다.


    핵심 개념 하나만 먼저 잡자: NUMA의 기본 원칙

    리눅스 NUMA의 기본 정책은 First-touch policy야.

    First-touch란?

    메모리는 “할당(malloc)” 시점이 아니라“처음 접근(write)”한 CPU 노드에 실제로 할당된다.

    네 코드에서 이 줄이 결정적이야:

    memset(myblock, 0, MEGABYTE);

    👉 이 순간에 물리 메모리가 할당

    👉 그리고 그때 실행 중이던 CPU가 속한 노드의 메모리를 받음


    네 코드에서 실제로 벌어지는 일

    1️⃣ CPU 0번 노드에서 실행 중일 때

    CPU: node 0
    malloc() → 가상 주소만 확보
    memset() → 실제 물리 페이지 할당
    
    

    결과:

    myblock → node 0의 메모리
    
    

    2️⃣ 이후 스케줄러가 프로세스를 CPU 1번 노드로 이동

    ⚠️ 중요

    • 이때 이미 할당된 메모리는 이동하지 않음
    • NUMA는 자동 메모리 마이그레이션을 기본으로 하지 않음

    그래서 상태는 이렇게 됨:

    CPU: node 1
    메모리: node 0 (기존에 할당됨)
    
    

    👉 CPU 1번에서 실행 중이지만

    👉 메모리는 node 0에 있음


    그래서 네 질문에 대한 정확한 답

    ❓ “CPU 0번 → 1번 가면 0번이랑 1번 둘 다 접근해?”

    👉 그럴 수 있다.

    왜냐하면:

    • 이전에 할당한 메모리 → node 0
    • CPU 1에서 실행 중 → node 1
    • CPU 1이 node 0 메모리를 원격 접근

    그리고 만약:

    • CPU 1번에서 새로 malloc + memset을 하면
    • 새 메모리는 node 1에 할당

    결과적으로:

    프로세스 하나가
    - node 0 메모리 일부
    - node 1 메모리 일부
    를 동시에 가지고 있을 수 있음
    
    

    ❓ “그럼 1번에서만 할당받는 거 아니야?”

    👉 아니다.

    기존 메모리는 그대로 남는다.

    리눅스는 기본적으로:

    • CPU 이동 ❌
    • 메모리 자동 재배치 ❌

    네 설명에서 살짝 보완하면 좋은 부분

    기존 문장 (조금 헷갈릴 수 있음)

    기존 0번 노드에서 돌때 확보한 메모리는 로컬 메모리가 아닌 원격 메모리가 되고

    추천 보완 문장

    기존 0번 노드에서 first-touch로 할당된 메모리는

    CPU가 1번 노드로 이동하더라도 그대로 0번 노드에 남아 있으며,

    이후에는 해당 메모리에 대해 원격 메모리 접근이 발생한다.


    “그럼 왜 큰 문제 안 되냐?”에 대한 설명도 정확히 정리해보면

    네가 쓴 이 문장:

    리눅스 스케줄러는 가능한 한 기존에 바인딩된 노드에 계속 바인딩되도록 하려는 경향

    이건 맞고, 조금 더 정확히 말하면:

    • 스케줄러는 cache locality / NUMA locality를 고려
    • 한 번 특정 노드에서 실행되면
      • 같은 노드 CPU에서 계속 실행하려고 함
    • 그래서:
      • 메모리 노드 초과 상황이 아니면
      • CPU가 자주 왔다 갔다 하지 않음

    최종 요약 (이해 확인용)

    • malloc() → 메모리 안 줌
    • memset() → first-touch, 물리 메모리 할당
    • 할당 시점 CPU 노드 = 메모리 노드
    • CPU가 다른 노드로 이동해도:
      • 메모리는 이동 ❌
      • 원격 접근 발생 ⭕
    • 이후 새로 할당되는 메모리는:
      • 새로운 CPU 노드에 할당 가능

    발표용 한 문장 요약

    NUMA 환경에서 리눅스는 first-touch 정책을 사용하므로,

    메모리는 최초 접근 시점의 CPU 노드에 할당된다.

    이후 프로세스가 다른 노드의 CPU로 이동하더라도

    이미 할당된 메모리는 이동하지 않으며,

    이 경우 원격 메모리 접근이 발생해 메모리 지역성이 저하될 수 있다.


    원하면 다음도 바로 이어서 설명해줄 수 있어:

    • numactl --membind / --cpunodebind 차이
    • 자동 NUMA balancing이 켜져 있으면 뭐가 달라지는지
    • 이 코드로 NUMA 실험할 때 더 정확하게 보는 방법 (numastat, perf)

    어디까지 더 파볼까?

  • CPU 바인딩?

    좋은 질문이야. 이 문장은 맞는 말이긴 한데, 압축이 너무 심해서 의미가 안 보이는 문장이야.

    풀어서 말하면 사실 아주 현실적인 이야기를 하고 있어.


    먼저 한 줄로 번역해줄게

    리눅스는 “메모리를 어디서 할당했는지”를 기억하고,가능하면 그 메모리를 쓰던 CPU 노드에서 계속 실행시키려고 한다.그래서 메모리가 한 노드에 충분히 있는 한,CPU가 다른 노드로 왔다 갔다 하면서 원격 메모리를 접근하는 상황은 잘 안 생긴다.


    이제 문장을 쪼개서 해석해보자

    원문 ①

    리눅스 스케줄러는 가능한 한 기존에 바인딩된 노드에 계속 바인딩되도록 하려는 경향이 있기 때문에

    이 말의 진짜 의미는 이거야 👇

    • 프로세스가 어느 CPU 노드에서 오래 실행됐는지
    • 그 노드의 메모리를 많이 쓰고 있는지
    • 캐시/NUMA 지역성(locality)

    이걸 리눅스 스케줄러가 고려한다는 뜻이야.

    즉,

    “이 프로세스는 node 0 메모리를 많이 쓰네?

    그럼 node 0 CPU에서 계속 돌게 하자.”

    👉 이걸 NUMA locality 유지라고 해.


    원문 ②

    한쪽 노드에서 할당 가능한 메모리의 양을 넘지 않는 한

    이 말은 굉장히 중요해.

    뜻은:

    • node 0에서 실행 중
    • node 0 메모리도 거기서 할당 중
    • node 0에 아직 여유 메모리가 충분하면

    👉 굳이:

    • CPU를 node 1로 옮길 이유 ❌
    • 메모리를 node 1에 새로 할당할 이유 ❌

    원문 ③

    크게 문제가 되진 않는다

    이건 결과야.

    왜 문제가 안 되냐면:

    • CPU가 계속 같은 노드에서 실행되고
    • 메모리도 같은 노드에 있고
    • 로컬 메모리 접근만 발생

    즉,

    • 원격 메모리 접근 ❌
    • NUMA 성능 저하 ❌

    전체 문장을 사람 말로 다시 쓰면

    리눅스 스케줄러는 프로세스가 사용하던 메모리의 위치를 고려해,

    가능한 한 동일한 NUMA 노드의 CPU에서 계속 실행되도록 스케줄링한다.

    따라서 한 NUMA 노드의 메모리가 충분한 상황에서는

    CPU와 메모리가 다른 노드로 분리되는 일이 드물어

    NUMA로 인한 성능 저하는 크게 발생하지 않는다.


    왜 이 문장이 NUMA 실험에서 자주 나오냐면

    네가 앞에서 한 실험 구조를 보면:

    malloc → memset → sleep
    
    
    • 한 번 할당
    • 접근 패턴 단순
    • 메모리 계속 증가

    이런 경우:

    • 스케줄러 입장에서는
      • “굳이 옮길 이유가 없음”
    • 그래서:
      • CPU도
      • 메모리도
      • 같은 노드에 고정되는 경향

    핵심만 요약하면 (발표용)

    리눅스 스케줄러는 NUMA 환경에서 메모리 지역성을 유지하기 위해

    프로세스를 기존에 실행되던 CPU 노드에 계속 배치하려는 경향이 있다.

    이로 인해 한 노드의 메모리가 충분한 경우에는

    CPU와 메모리가 다른 노드로 분리되는 상황이 잘 발생하지 않는다.


    딱 한 문장으로 끝내면

    “리눅스는 CPU를 아무 데나 막 옮기지 않고,메모리를 쓰던 자리에서 계속 쓰게 하려고 애쓴다.”

    이 문장이 이 문단의 진짜 의미야.


    원하면 다음도 이어서 설명해줄게:

    • 자동 NUMA balancing이 켜져 있으면 이 문장이 언제 깨지는지
    • 왜 메모리가 부족해지면 갑자기 성능이 확 떨어지는지
    • 이 코드로 NUMA worst-case 만드는 방법

    어디까지 더 볼까?

    짧게 말하면 아니야.

    강제로 바인딩하면, “NUMA가 알아서” 하지 않는다.

    👉 네가 시킨 대로 한다.

    다만, **“어디까지 강제력이 있는지”**에 따라 결과가 달라져.

    그래서 헷갈리는 거야.


    한 문장 정답

    NUMA는 기본 정책일 때만 알아서 동작하고,명시적으로 바인딩하면 커널은 그 결정을 존중한다.


    상황별로 정확히 나눠서 보자

    ① 아무것도 안 했을 때 (기본 상태)

    ./a.out
    

    이때는:

    • CPU 배치 → 스케줄러가 결정
    • 메모리 할당 → first-touch
    • CPU 이동 → 가능
    • 메모리 이동 → 기본적으로 ❌

    👉 이 상태에서만

    • *“리눅스가 알아서 NUMA locality를 유지한다”**는 말이 성립함

    ② CPU만 바인딩한 경우

    numactl --cpunodebind=0 ./a.out
    

    의미:

    • CPU는 무조건 node 0
    • 메모리는?
      • first-touch 정책
      • → 결국 node 0에 할당

    👉 이건 자연스럽게 locality가 맞아떨어지는 케이스


    ③ 메모리만 바인딩한 경우

    numactl --membind=0 ./a.out
    

    의미:

    • 메모리는 무조건 node 0
    • CPU는?
      • 스케줄러가 자유롭게 이동

    결과:

    • CPU가 node 1에서 실행될 수 있음
    • 하지만 메모리는 node 0
    • 👉 강제 원격 메모리 접근

    ④ CPU + 메모리 둘 다 바인딩 (완전 강제)

    numactl --cpunodebind=0 --membind=0 ./a.out
    

    이 경우:

    • CPU → node 0 고정
    • 메모리 → node 0 고정
    • NUMA balancing?
      • 개입 불가

    👉 이때는 NUMA가 “알아서” 할 여지가 없다


    “NUMA가 알아서 한다”는 말의 정확한 의미

    이 말은 사실 이렇게 풀어야 정확해:

    “아무 제약을 안 걸면,커널이 locality를 최대한 유지하려고 노력한다.”

    ❌ 강제 바인딩 상태

    ⭕ 자유 상태에서만 해당


    자동 NUMA Balancing은 어떨까?

    /proc/sys/kernel/numa_balancing

    • 기본값: 1 (켜짐)
    • 하는 일:
      • 원격 메모리 접근이 많으면
        • 페이지를 옮기거나
        • 태스크를 옮김

    ⚠️ 하지만:

    • numactl --membind / -cpunodebind 사용 시
    • 자동 NUMA balancing은 해당 영역에 개입 못함

    👉 “강제”가 우선권을 가짐


    그래서 네 질문에 대한 정확한 답

    ❓ 강제로 바인딩시켜도 NUMA가 알아서 해?

    👉 정답

    • 아무것도 안 강제하면 → NUMA가 알아서
    • CPU/메모리 바인딩하면 → 커널은 개입 안 함
    • 둘 다 바인딩하면 → 완전 수동 모드

    실무에서 자주 쓰는 판단 기준

    상황 권장
    일반 서비스 바인딩 ❌ (자동에 맡김)
    NUMA 민감 워크로드 CPU+메모리 바인딩 ⭕
    DB, HPC 명시적 바인딩 ⭕
    실험/벤치 바인딩 ⭕

    한 문장으로 마무리

    NUMA는 기본 정책일 때만 “알아서” 동작하고,강제로 바인딩하면 커널은 사용자의 결정을 그대로 따른다.

    이제 NUMA 관련 문장들 거의 다 정확히 읽힐 거야.

두번째는 bind 정책이다. 이번에는 numactl 명령을 이용해서 테스트를 진행할 것이다. numactl로 할 수 있는 bind 정책은 membind, cpunodebind, physcpubind 3가지가 있다.

먼저 membind에 대해 살펴보자.

ubuntu@ip-172-31-0-13:~$ numactl --membind=0 ./malloc_test
Allocated 1048576 MB
Allocated 2097152 MB
Allocated 3145728 MB
Allocated 4194304 MB
Allocated 5242880 MB
Allocated 6291456 MB
Allocated 7340032 MB
Allocated 8388608 MB
Allocated 9437184 MB
Allocated 10485760 MB
Allocated 11534336 MB
Allocated 12582912 MB
Allocated 13631488 MB
Allocated 14680064 MB
Allocated 15728640 MB
Allocated 16777216 MB
Allocated 17825792 MB
Allocated 18874368 MB
Allocated 19922944 MB
Allocated 20971520 MB
Allocated 22020096 MB
Allocated 23068672 MB
Allocated 24117248 MB
Allocated 25165824 MB
Allocated 26214400 MB
Allocated 27262976 MB
ubuntu@ip-172-31-0-13:~$ numastat `pidof malloc_test`

Per-node process memory usage (in MBs) for PID 1899 (malloc_test)
                           Node 0           Total
                  --------------- ---------------
Huge                         0.00            0.00
Heap                         0.00            0.00
Stack                        0.01            0.01
Private                     14.66           14.66
----------------  --------------- ---------------
Total                       14.68           14.68

우리가 예상한 것처럼 0번 노드에서 메모리를 할당해서 사용하는 것을 볼 수 있다. 그런데 만약 강제로 1번 노드에 있는 CPU에 바인딩한다면 어떻게 될까?

taskset 명령으로 강제로 1번 노드의 CPU에 할당해도 메모리는 여전히 0번 노드에서 할당 받는다. 이렇게 되면 해당 프로세스는 메모리의 지역성을 전혀 살리지 못하고 1번 노드에서 0번 노드의 메모리에 접근하게 되고 결과적으로 성능의 저하가 일어난다. 프로세스가 사용할 CPU는 상황에 따라 노드가 변경될 수 있기 때문에 membind는 사실 그리 선호하는 정책은 아니다. taskset 등의 명령을 통해 추가로 특정 노드에서만 CPU를 사용하도록 할 필요가 있다. 만약 노드에서 사용 가능한 메모리의 영역 이상의 요청이 들어오면 어떤 일이 일어날까?

0번 노드에서 할당 가능한 16GB 정도의 양을 벗어나자 swap 영역을 사용하는 것을 볼 수 있다. 1번 노드로의 메모리 할당은 일어나지 않고 swap 영역을 사용하다가 결국엔 OOM(Out Of Memory)로 프로세스가 죽게된다. 이처럼 membind 정책은 신경 써야 할 부분이 많기 때문에 사용을 권장하지 않는다.

다음으로 cpunodebind를 살펴보자. 이 정책은 BIND 중에서도 특정 노드에 있는 CPU에서만 프로세스가 돌아가도록 설정한다. 이렇게 되면 메모리 할당도 해당 프로세스가 돌고 있는 CPU가 속해 있는 노드에서 할당 받기 때문에 메모리 지역성이 좋아진다.

taskset을 통해서 확인해보면 해당 프로세스는 1번 노드에 위치해 있는 1, 3, 5, 7, 9, 11번 CPU에서 동작하도록 설정했다. 이 정책에서는 하나의 노드에서 할당해줄 수 있는 최대치 이상의 메모리가 필요할 때 어떻게 동작할까? membind와는 다른 결과를 보여줄까?

이번엔 확실히 다른 결과를 보여준다. 1번 노드에서 더 이상 할당받을 메모리가 없어지자 0번 노드를 통해서 메모리를 할당받는다. 이는 메모리의 지역성을 높일 수 있기 때문에 BIND 정책 중에서 membind 정책보다 선호된다. 다만 멀티 스레드로 동작하는 경우 CPU를 절반밖에 사용할 수 없기 때문에 CPU 리소스가 낭비될 수 있다.

마지막으로 physcpubind는 cpunodebind와 비슷하지만 조금 다르다. cpunodebind가 특정 노드에 위치한 CPU를 프로세스에 매핑하는 개념이라면 physcpubind는 CPU 번호를 매핑하는 개념이다. 즉, 한쪽 노드에 위치한 CPU 번호를 나열하면 cpunodebind와 같은 개념으로 동작하고, 서로 다른 노드에 위치한 CPU 번호를 나열하면 해당 CPU에서만 프로세스가 실행되도록 설정된다. 이는 메모리의 지역성을 살릴 수도 그렇지 않을 수도 있다.

세 번째 정책은 preferred 정책으로 BIND와 비슷하지만 가능한 한 특정 노드에서 메모리를 할당받도록 하는 정책이다.

numactl --preferred=1 ./malloc_test

numastat 명령으로 확인해보면 1번 노드에서 메모리를 할당받는 것을 확인할 수 있다. CPU가 어느 쪽 노드를 사용하는지와 무관하게 메모리의 할당을 1번 노드에서 받도록 한다.

가능한 한 1번 노드에서 받는 것이지 무조건 1번 노드에서 받는 것은 아니다. 그렇기 때문에 설정한 노드 이상의 메모리를 사용하게 되면 OOM이 발생해서 프로세스가 중지되는 membind 정책과 달리, 설정한 노드에서 메모리가 부족해지면 다른 노드로부터 메모리를 할당받기 때문에 OOM이 발생하지 않는다.

마지막으로 살펴볼 정책은 interleaved로, 이름에서 알 수 있듯이 다수의 노드로부터 공평하게 메모리 할당을 받는 정책이다. 주로 한 노드 이상의 메모리 할당이 필요한 경우에 사용한다.

ubuntu@ip-172-31-0-13:~$ numactl --preferred=all ./malloc_test
Allocated 1048576 MB
Allocated 2097152 MB
Allocated 3145728 MB
...

ubuntu@ip-172-31-0-13:~$ numastat `pidof malloc_test`

Per-node process memory usage (in MBs) for PID 1515 (malloc_test)
                           Node 0           Total
                  --------------- ---------------
Huge                         0.00            0.00
Heap                         0.00            0.00
Stack                        0.01            0.01
Private                     28.71           28.71
----------------  --------------- ---------------
Total                       28.73           28.73

numastat으로 확인해보면 두 개의 노드로부터 거의 동일한 양의 메모리를 할당받았다. 어느 노드에 속한 CPU에서 돌아가고 있건 상관없이 각 노드들로부터 순차적으로 메모리 할당을 받게 된다.

6.4 numad를 이용한 메모리 할당 관리

지금까지 numactl을 통해 수동으로 NUMA 아키텍처에서의 메모리 할당 정책을 설정하는 방법과 그 변화 과정을 살펴봤다. 리눅스에서는 numad를 통해 NUMA 메모리 할당 정책을 직접 설정하지 않고도 메모리 지역성을 높일 수 있는 방법을 제공해준다. numad는 백그라운드 데몬과 같은 형태로 시스템에 상주하면서 프로세스들의 메모리 할당 과정을 주기적으로 살펴보고, 프로세스들을 최적화하는 작업을 담당한다.

프로세스 A와 B 두 개가 동작하고 있는 시스템을 가정해보자. 경우에 따라서는 default 정책에 따라 메모리 지역성을 높인 상태로 운영될 수 있지만, 그렇지 않으면 각 프로세스가 필요로 하는 메모리가 여러 노드에 걸쳐서 존재할 수도 있다. 프로세스가 필요로 하는 메모리의 크기가 노드 하나의 메모리 크기보다 작기 때문에 충분히 메모리 지역성을 높일 수 있다. 하지만 다수의 프로세스를 관리해야 하기 때문에 numactl 등을 사용해 수작업으로 실행시키기 어려운 것도 사실이다. 이런 경우에 numad 가 도움이 된다. 하나의 프로세스가 필요로 하는 메모리를 하나의 노드에서만 할당받을 수 있도록 설정할 수 있기 때문에 메모리의 지역성을 높이고 성능을 최적화할 수 있다.

numad가 대체적으로 좋은 성능을 낼 수 있도록 도와주지만 단점도 있다. 그림 6-8과 같은 경우라면 성능에 좋지 않은 영향을 끼칠 수 있다.

프로세스 A는 interleaved 정책으로 실행되어 각각의 노드에서 메모리를 순차적으로 할당받고 있다. 하지만 이때 프로세스 B가 실행되고, 이 프로세스는 메모리 요청이 노드 하나의 크기보다 작아서 numad에 의해 한쪽 노드에 바인딩되고 해당 노드로부터 메모리를 할당 받는다. 문제는 프로세스 B가 지역성을 높이기 위해 Node 1에서 메모리 할당을 너무 많이 받아서 더 이상 프로세스 A에 할당해줄 메모리가 없을 때 발생한다. 프로세스 A는 워크로드에 따라 interleave로 실행되었지만, numad가 지역성을 너무 높인 탓에 메모리 불균형이 발생할 수밖에 없다.

이렇게 numad 는 사용자가 신경쓰지 않아도 메모리 할당 문제를 해결해 주긴 하지만 경우에 따라서는 오히려 성능에 좋지 않은 영향을 끼칠 수 있기 때문에 현재 시스템의 워크로드에 따라 numad를 켜는 것이 더 좋을지 아닐지를 잘 판단해서 적용해야 한다.

그런데 numad 말고도 NUMA 아키텍처에서 메모리 할당에 영향을 주는 커널 파라미터가 하나 있다. 바로 vm.zone_reclaim_mode이다.

6.5 vm.zone_reclaim_mode 커널 파라미터

vm.zone_reclaim_mode에 대한 이야기를 하기 전에 zone이 무엇인지 먼저 살펴보자. 커널은 메모리를 사용 용도에 따라 zone이라 부르는 영역으로 구분해 관리한다. zone에 대한 정보는 /proc/buddyinfo 파일을 통해서 확인할 수 있다.

ubuntu@ip-172-31-0-13:~$ cat /proc/buddyinfo
Node 0, zone      DMA      0      0      0      0      0      1      1      1      0      1      3 
Node 0, zone    DMA32      1      1      0    120     88     29     10      4      1      3    108 

NUMA 아키텍처가 Node 0, Node 1로 각각의 노드 영역을 구분한 것을 볼 수 있으며, Node 0은 3개 영역(DMA, DMA32, Normal)으로, Node 1은 하나의 영역(Normal)으로 구분했다.

DMA, DMA32에서 DMA는 Direct Memory Access의 약자로, 주로 오래된 하드웨어의 동작을 위해 존재하는 영역이다. 과거 일부 하드웨어가 DMA라고 정의된 일부 메모리 영역에서만 동작 가능했기 때문에 만들어진 것으로, 현재 시점에서는 해당 영역을 필요로 하는 하드웨어가 가의 없다.

Normal은 그 이름이 의미하는 것처럼 일반적인 용도로 사용되는 영역이다. 커널, 프로세스 등이 메모리를 필요로 할 때 Normal 영역에서 메모리를 할당 받아서 사용한다.

이렇게 각각의 메모리 영역은 용도별로 구분되어 있으며 메모리는 용도에 맞는 곳에서 할당 받아서 사용된다. 그리고 vm.zone_reclaim_mode는 바로 이런 영역들 사이에서 특정 영역의 메모리가 부족할 경우 다른 영역의 메모리를 할당할 수 있게 해준다. 엄밀히 말하면 NUMA 아키텍처를 위해 만들어진 커널 파라미터가 아니라 기존부터 있던 커널 파라미터이지만 영역별 할당 여부에 영향을 주는 역할 때문에 NUMA 아키텍처가 도입된 후 중요성이 커졌다. 왜냐하면 Node 0의 Normal 영역과 Node 1의 Normal 영역은 Normal 이라는 용도는 같지만, Node가 달라서 서로 다른 영역으로 구분되며, 경우에 따라서 Node 0의 Normal 영역의 메모리가 부족해서 Node 1의 Normal 영역에서 메모리를 필요로 하는 경우가 발생할 수 있기 때문이다.

vm.zone_reclaim_mode 파라미터는 총 4개의 값을 가질 수 있지만 실제적으로 중요한 값은 0과 1이다. 0은 disable을 의미하며 zone 안에서 재할당하지 않는다는 의미이다. zone 안에서 재할당을 하지 않는다는 이야기는 다른 zone에서 가져와서 사용한다는 의미이다.

반대로 1은 enable을 의미하며 zone 안에서 재할당을 한다는 의미이다. 즉 메모리가 부족한 상황이 되면 해당 zone 안에서 재할당할 수 있는 메모리 영역을 먼저 찾아서 필요한 만큼 재할당해서 재사용하고, 그렇지 않으면 다른 zone에서 메모리를 할당 받아서 사용한다.

그래서 0이 되면 page cache 등과 같은 재할당 대상 메모리들이 반환되지 않고 다른 노드에 있는 메모리를 할당 받아서 사용한다. 파일 서버와 같이 다수의 I/O가 발생하는 서버의 경우 메모리에 대한 로컬 액세스를 통해서 얻을 수 있는 이점보다 많은 양의 page cache를 확보함으로써 얻을 수 있는 이점이 더 크기 때문에 vm.zone_reclaim_mode를 0으로 사용하는 것이 더 좋다. 반대로 page cache 확보보다는 메모리에 대한 로컬 액세스 방식이 성능상 더 유리할 때는 vm.zone_reclaim_mode 를 1로 설정해서 가능한 한 동일한 노드에서 메모리를 할당받을 수 있게 해주는 것이 좋다.

6.6 NUMA 아키텍처의 메모리 할당 정책과 워크로드

NUMA 시스템에서 워크로드를 확인하는 방법 중에 가장 먼저 생각해볼 것은 사용할 메모리의 크기와 프로세스의 스레드 개수이다. NUMA 노드 한 개 이상의 메모리를 사용하게 되는지, 프로세스가 싱글 스레드로 동작하는지 등을 확인해봐야 한다. 경우의 수를 따져보면 아래 표와 같다.

스레드 개수 메모리의 크기
싱글 스레드 메모리가 노드 하나 크기를 넘지 않음
싱글 스레드 메모리가 노드 하나의 크기를 넘음
멀티 스레드 메모리가 노드 하나 크기를 넘지 않음
멀티 스레드 메모리가 노드 하나의 크기를 넘음

메모리가 노드 하나의 크기를 넘지 않음 + 싱글 스레드

이런 경우는 거의 없다. 이런 워크로드를 가진 서버라면 NUMA 아키텍처를 사용하는 2 소켓 이상의 시스템을 사용할 필요가 없다. 1 소켓의 UMA 아키텍처를 사용하는 서버를 사용하는 것이 워크로드에도, 성능 최적화에도 가장 적합하다. 하지만 그럼에도 불구하고 NUMA 아키텍처를 사용해야 하는 구조라면 BIND 정책으로 특정 CPU에 바인딩하는 것이 도움이 된다. 싱글 스레드이기 때문에 하나 이상의 CPU가 필요하지 않고, 특정 CPU에 바인딩 시킴으로써 CPU Cahe도 최대로 사용할 수 있기 때문이다. 같은 NUMA 노드 안에서 계속 CPU가 할당된다고 해도 CPU Cache를 사용하지 못할 수 있기 때문에 싱글 스레드로 동작하는 경우에는 CPU를 바인딩하는 것이 가장 좋다. 또한, vm.zone_reclaim_mode도 1로 켜두면 가급적 하나의 노드에 메모리할당이 몰리기 때문에 메모리의 로컬 액세스가 늘어나서 성능이 도움이 된다.

메모리가 노드 하나의 크기를 넘지 않음 + 멀티 스레드

메모리가 노드 하나의 크기를 넘지 않기 때문에 메모리 할당도 한 곳에서만 이루어지게 할 수 있다. 이 경우에는 cpunodebind 모드를 통해서 여러 개의 코어에 프로세스를 바인딩시키고 해당 노드에서만 메모리를 할당 받아서 사용하게 하면 성능이 가장 좋다. 하지만 이 경우에도 CPU Usage에 대한 세심한 모니터링이 필요하다. 시스템의 사용량이 높아져서 특정 노드에 위치한 CPU 만으로는 CPU 리소스가 모자랄 수 있다. 전체 CPU Usage가 아닌 개별 CPU Usage를 세심하게 살피면서 CPU 리소스가 부족하지는 않은지 모니터링 해야한다. 이 경우에는 vm.zone_reclaim_mode의 값은 0보다는 1이 성능에 유리할 수 있다. 메모리의 크기 자체가 하나의 노드를 넘어가지 않기 때문에 주로 한쪽 노드에서 할당받는 것이 로컬 액세스가 늘어나서 성능에 도움이 되기 때문이다. 그리고 numad가 가장 효과적으로 동작할 수 있는 워크로드이기도 하다.

메모리가 노드 하나의 크기를 넘음 + 싱글 스레드

메모리가 노드 하나의 크기를 넘기 때문에 메모리의 지역성을 최고로 올릴 수 있는 방법을 사용해야 한다. 게다가 프로세스가 싱글 스레드로 동작하기 때문에 어쩔 수 없이 리모트 액세스가 발생할 수밖에 없다. 이런 경우에는 리모트 액세스를 어떻게 최소화할 것인지가 성능 최적화의 핵심이다. 싱글 스레드라면 CPU Cache 사용을 최적화하기 위해 동일한 CPU에 계속해서 바인딩되도록 하는 것이 가장 중요하다. 그래서 첫번째와 마찬가지로 cpunodebind 정책을 사용하는 것이 좋다. 이 경우에는 vm.zone_reclaim_mode의 값은 0으로 하는 것이 성능에 유리하다. 어차피 메모리가 한 개 이상의 노드를 필요로 하기 때문에 재할당해서 메모리를 확보하기 보다는 처음부터 다수의 노드로부터 메모리를 할당받는 것이 좋다.

메모리가 노드 하나의 크기를 넘음 + 멀티 스레드

아마도 이 경우가 가장 많을 것이다. 이 역시 메모리가 노드 하나의 크기를 넘기 때문에 어쩔 수 없이 리모트 액세스가 발생할 수밖에 없다. 게다가 멀티 스레드라서 여러 개의 스레드가 여러 개의 CPU에서 동작하게 된다. 이 경우에는 interleave 모드가 최적의 성능을 낼 수 있다. 어떤 CPU에 어떤 스레드가 바인딩될지 모르기 때문에 가능한 한 메모리 할당을 여러 영역에 넓게 펼치는 것이 유리하기 때문이다. 이 경우에도 vm.zone_reclaim_mode의 값은 0으로 지정하는 것이 성능 면에서 좋다.